PHP stack trace arguments have landed in Flare
Since Flare's beginning, we've shown you stack traces with each error you've sent us. These stack traces are exact copies of the traces generated by Ignition, the error page we ship for Laravel and generic PHP projects:
This week we've rolled out a new feature: stack trace arguments, and I'll know for sure you'll love them!
The stack trace shows each method/function called from the start of your application until an exception is thrown. A frame with a file of the method/function, line number, code snippet, and arguments represents each method/function call. Until this week, we didn't show you these arguments. You can read more about why we did this here.
That all changes when you now update to the newest Ignition client. We already had a stack trace:
When clicking through the stack frames you'll see these arguments popping up, they represent the arguments passed to the method/function the frame is currently highlighting.
We're smart with these arguments and will try to convert them to more readable objects. Read on for more information about this.
And, of course, you'll be able to see these arguments within Flare! From now on, you'll know which values have been passed around within your code.
Are you interested in the technical implementation? Keep on reading!
How we did it
To get this all working, we needed to update a whopping seven repositories! The most important one was spatie/backtrace. This is the heart of Ignition. It uses PHP-generated stack traces and creates an optimized, more readable version from it.
In the past, we already had support for adding arguments to stack traces using the package. But this was turned off for Ignition and Flare. So let's enable it:
Backtrace::createForThrowable($exception)->withArguments()->frames();
Cool, that's it. Now we're done, books closed ... That's not how we roll at Spatie. Let's see how we can make this even better.
First of all, to which frame do these arguments belong? Let's take a look at the following example:
public function __invoke()
{
function thisIsTheWay(
string $mandalorianName,
string $vehicle,
bool $takesOffHelmet = true,
string ...$children
) {
return GalaxyPerson::create(
$vehicle,
$mandalorianName,
$takesOffHelmet,
...$children,
);
}
$person = thisIsTheWay(
'Din Djarin',
'Razor Crest',
true,
'Grogu',
);
}
The create method does not exist, so that an exception will be thrown. On which stack frame would you expect the arguments? Here:
Or here?
As you can see, we opted for the second frame. This might feel strange in the beginning. Isn't it handy to see the current arguments in the code they are used in?
We have a few reasons for this:
-
You'll see the arguments used to make the function call
-
When you have a giant function, the code snippet might be too small to quickly see what's happening to the arguments making the feature rather useless
-
It's PHP's default
Now that we know that, let's look at how we're parsing these arguments.
Parsing PHP arguments
Let's use the example above and look at the frame we're receiving from PHP:
[
"file" => "/Users/ruben/Spatie/star-wars/app/Http/Controllers/DataController.php"
"line" => 23
"function" => "App\Http\Controllers\thisIsTheWay"
"args" => array:3 [▼
0 => "Din Djarin"
1 => "Razor Crest"
2 => true
]
]
How do we transform this information into this?
Compatibility
We're now going through the code. If you're asking yourself, why not use this fancy PHP 8.* feature? We want Flare and Ignition to be installable in as many projects as possible, even the older ones. Our code is written in PHP 7.3 to support these older projects. The downside is that we're missing out on some excellent new features.
The need for reflection
First, we need the names of these parameters. We're nothing with parameter names 0, 1, and 2. Reflection to the rescue:
protected function getParameters(?string $class, ?string $method): ?array
{
try {
$reflection = $class !== null
? new ReflectionMethod($class, $method)
: new ReflectionFunction($method);
} catch (ReflectionException $e) {
return null;
}
return array_map(
function (ReflectionParameter $reflectionParameter) {
return ProvidedArgument::fromReflectionParameter($reflectionParameter);
},
$reflection->getParameters(),
);
}
So what happens here? We create a reflection object based upon that a frame is a method or function call. And then, we'll retrieve all parameters for this method/function and transform them to our structure: ProvidedArgument
. The code for this looks like this:
public static function fromReflectionParameter(ReflectionParameter $parameter): self
{
return new self(
$parameter->getName(),
$parameter->isPassedByReference(),
$parameter->isVariadic(),
$parameter->isDefaultValueAvailable(),
$parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null,
);
}
So we keep for each parameter the name, if it is passed by reference, if it is variadic (these are the ... kinds of arguments), if it has a default value, and what that default value is.
Why keep track of the default value? Let's take a look at a PHP frame for this method call:
thisIsTheWay(
'Din Djarin',
'Razor Crest',
//Notice how we're missing $takesHelmetOff, which is a default value
);
The PHP frame now looks like this:
[
"file" => "/Users/ruben/Spatie/star-wars/app/Http/Controllers/DataController.php"
"line" => 34
"function" => "App\Http\Controllers\thisIsTheWay"
"args" => array:2 [▼
0 => "Bo-Katan Kryze"
1 => "Gauntlet Fighter"
]
]
This value is true
by default, but we're missing it. That's why we take notice of the value when we're reflecting the function.
Reducing the payload
Now that we have our ProvidedArguments
, let's look at the values provided. Strings, ints, booleans, floats, and arrays until a certain point are easily readable, but what if you have complex objects?
The quickest fix would be to show the arguments' values if they're simple types and replace complex objects with an "object" value.
But we can do this better, so we introduced ArgumentReducers
. These simple classes may reduce an argument from its complex form into a more simple readable form.
You can define them as such in the backtrace package:
Backtrace::createForThrowable($exception)
->withArguments()
->reduceArguments([
new BaseTypeArgumentReducer(),
new ArrayArgumentReducer(),
new StdClassArgumentReducer(),
new EnumArgumentReducer(),
new ClosureArgumentReducer(),
new SensitiveParameterArrayReducer(),
new DateTimeArgumentReducer(),
new DateTimeZoneArgumentReducer(),
new SymphonyRequestArgumentReducer(),
new StringableArgumentReducer(),
])
->frames();
For example, let's take a look at the DateTimeArgumentReducer
, it takes a DateTimeInterface
object so, for example, a DateTimeImmutable
or Carbon
object, and converts it into a string:
class DateTimeArgumentReducer implements ArgumentReducer
{
public function execute($argument): ReducedArgumentContract
{
if (! $argument instanceof DateTimeInterface) {
return UnReducedArgument::create();
}
return new ReducedArgument(
$argument->format('d M Y H:i:s e'),
get_class($argument),
);
}
}
Since every argument is passed into each reducer, we'll need to specify that a reducer cannot reduce the argument. We return an UnReducedArgument
object when the argument is not a DateTimeInterface
object.
When we can reduce the argument, we return a ReducedArgument
object with the reduced value, in this case, a string representation of the date and the original type of the object. So we can show you if a DateTime
, CarbonImmutable
, or any other object was passed as an argument.
Both these objects implement the ReducedArgumentContract
, which makes it possible to return them both in the same function.
The package can now take the arguments we used to call the function/method and reduce them into readable values. The quick approach we described above will be used if no reducer can be found for a value.
Putting it all together
So we now have our reduced argument values and our ProvidedArgument
objects. Now let's combine this information:
$argumentsCount = count($arguments);
foreach ($providedArguments as $index => $providedArgument) {
if ($index + 1 > $argumentsCount) {
$providedArgument->defaultValueUsed();
} elseif ($providedArgument->isVariadic) {
$providedArgument->setReducedArgument(new VariadicReducedArgument(array_slice($arguments, $index)));
} else {
$providedArgument->setReducedArgument($arguments[$index]);
}
}
So we loop over each ProvidedArgument
object and try to match it with a stack trace argument. For each step in the loop, we have three cases:
a) we're passed the arguments provided by the stack trace, which means that the argument was not provided and a default value is used. Luckily we've reflected that value earlier.
b) if the argument is variadic, we know it is the last argument. So we set the value of the ProvidedArgument
object to a special kind of ReducedArgumentContract
object: the VariadicReducedArgument
, which is an array of all the values still left in the stack trace arguments list.
c) In The default case, we map the value of a stack trace argument directly to its ProvidedArgument
object.
That's it. There's a lot more happening to handle some more edge cases. You can check out the PR if you wish.
A small problem
When running the tests for this feature on GitHub Actions, we noticed that no stack trace arguments were provided, even though we explicitly asked the package to include them.
Since PHP 7.4, there is an ini setting: zend.exception_ignore_args
, which is default enabled on Linux machines. This setting will always hide arguments from exception stack traces which is a bummer.
It can be turned off within your ini or by using this piece of PHP code:
ini_set('zend.exception_ignore_args', 0);
Conclusion
You'll find all the information required about writing ArgumentReducers and how to enable them in our docs. Also, if you're uncomfortable with your arguments being shared with Flare, this feature can be turned off.
We currently have plans in our pipeline to add this feature to our JS clients. Give this feature a thumbs up on our Flare Roadmap if you want to see this in the future.
Further, we're constantly improving Flare now our redesign is out, so expect to see some prominent new features in the future. Step on the Flare train and enjoy the ride!