Lesson 04 / 10
The Debugger
Measure which profile shows the information seen while debugging, that is, the argument value in the exception trace, the warning text, and the call stack, and whether it reaches production.
Published Updated
In this lesson
- Defines the step debugger, the breakpoint, and stepping as concepts
- Measures how an argument value in the exception trace behaves in the development and production profiles
- Shows that debug_backtrace() and the exception trace do not depend on the same setting
- Measures the scope of the protection when the value is written into the exception message
Contents
The previous lesson showed that the same assertion can be caught in development and pass silently in production. Does the information we see while debugging split in the same direction? Which value appears in the trace of an error, which warning is printed to the screen, and how much of the call stack is opened all depend on directives in the configuration file as well. This lesson measures whether the information that debugging sees reaches production.
A step debugger is the class of tool that stops a running program line by line and shows its inside. We place a breakpoint on the line where we want the program to stop; when the program gets there, it stops, and the variables, the call stack, and the values at that moment can be read. Stepping is advancing the stopped program by one line, into a function, or out of a function. These tools work on the developer’s machine by attaching to the run from the outside. This lesson names no extension, installs nothing, and opens no port; the existence of the debugger is described only as a concept, and what is measured is what PHP’s own tools show.
The fifth course of the ninth curriculum (M09/K05) asked which tool a piece of information is read from. Here the question changes: is the information the tool shows visible in production as well?
Three Pieces of Information Debugging Sees
Three of PHP’s own tools produce information similar to what a debugger shows. The trace of an exception (getTrace(), getTraceAsString()) lists the functions called up to the place where the exception was thrown and the arguments given to each call. debug_backtrace() returns the same stack at a requested moment, without an exception. Warnings and notices are printed directly into the response when display_errors is on.
The measurement core is the same as in the previous lessons. side.php does not change at all.
<?php // side.php — does this behavior reach production? declare(strict_types=1); // The two configuration files PHP ships (php.ini-development, php.ini-production) differ // in seven directives; all seven are copied here verbatim. The last two production lines // are comments in the shipped file: code cache on for the command line too, file checks off. const PROFILES = [ 'development' => [ 'display_errors' => '1', 'display_startup_errors' => '1', 'error_reporting' => '32767', 'mysqlnd.collect_memory_statistics' => '1', 'zend.assertions' => '1', 'zend.exception_ignore_args' => '0', 'zend.exception_string_param_max_len' => '15', ], 'production' => [ 'display_errors' => '0', 'display_startup_errors' => '0', 'error_reporting' => '24575', 'mysqlnd.collect_memory_statistics' => '0', 'zend.assertions' => '-1', 'zend.exception_ignore_args' => '1', 'zend.exception_string_param_max_len' => '0', 'opcache.enable_cli' => '1', 'opcache.validate_timestamps' => '0', ], ]; /** Runs the script in a separate process with no configuration file, only the profile's directives. */ function run(string $script, array $directives): bool { $command = ['php', '-n']; foreach ($directives as $name => $value) { array_push($command, '-d', "$name=$value"); } $command[] = $script; $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes); $output = stream_get_contents($pipes[1]); fclose($pipes[1]); proc_close($process); $lines = array_values(array_filter(array_map('trim', explode("\n", $output)))); return end($lines) === 'yes'; } /** Derives the side from whether the behavior was observed in the two runs. */ function side(bool $inDevelopment, bool $inProduction): string { return match (true) { $inDevelopment && $inProduction => 'both sides', $inDevelopment => 'development only', $inProduction => 'production only', default => 'not in the run', }; } function sideReport(string $name, string $script): string { $inDevelopment = run($script, PROFILES['development']); $inProduction = run($script, PROFILES['production']); $finalSide = side($inDevelopment, $inProduction); echo "$name → $finalSide\n development ", $inDevelopment ? 'yes' : 'no', ' · production ', $inProduction ? 'yes' : 'no', "\n"; return $finalSide; }
A neutral example is used: the pay() function takes the value card-0042 and throws a RuntimeException. The value resembles a card number but is not a real number; the aim is to show that an argument visible in the trace is a value that could leak somewhere else. Three probes run in the same driver.
<?php // error.php — three pieces of information debugging sees, two profiles declare(strict_types=1); require 'side.php'; $probes = [ 'the exception trace carries the argument value' => <<<'PHP' <?php function pay(string $card): void { throw new RuntimeException('declined'); } try { pay('card-0042'); } catch (RuntimeException $e) { echo str_contains($e->getTraceAsString(), 'card-0042') ? "yes\n" : "no\n"; } PHP, 'debug_backtrace carries the argument value' => <<<'PHP' <?php function pay(string $card): array { return debug_backtrace(); } $trace = pay('card-0042'); echo in_array('card-0042', $trace[0]['args'] ?? [], true) ? "yes\n" : "no\n"; PHP, 'a warning is printed into the response' => <<<'PHP' <?php ob_start(); echo $undefined; $response = ob_get_clean(); echo str_contains($response, 'Warning') ? "yes\n" : "no\n"; PHP, ]; foreach ($probes as $name => $code) { file_put_contents('probe.php', $code); sideReport($name, 'probe.php'); }
the exception trace carries the argument value → development only
development yes · production no
debug_backtrace carries the argument value → both sides
development yes · production yes
a warning is printed into the response → development only
development yes · production no
In the first probe the setting difference is directly visible. In the development profile zend.exception_ignore_args=0, so the exception trace carries the arguments of every call and card-0042 can be read in the trace. In the production profile zend.exception_ignore_args=1, so the trace carries only function names and lines; the argument list is empty. This is a protection: a trace that lands in an error log or on an error page does not carry the value the user gave. Because it is a directive tightened in production, this side being development only is not a defect but the intended result.
The third probe is on the same side. In the development profile display_errors=1 and error_reporting=32767, so the undefined variable warning is printed into the output. In the production profile display_errors=0, so the warning does not enter the response. Printing an error message into the response shows the visitor the file path, the line number, and often a value. This is called information disclosure, and it is the reason it is kept off in production.
The second probe falls outside this table. The debug_backtrace() call returns the argument in both profiles. The reason is that the zend.exception_ignore_args directive concerns only the exception trace; it does not affect a stack dump that the code itself requests. The protection is a directive, not a barrier: it hides the argument from an exception trace but not from a debug_backtrace() call written into the code. If this call is left in a file that goes to production, the value can be read in production as well.
The Value Written into the Message
If the protection covers the argument list, what happens when the value is written somewhere else? A developer can write the value into the message of the exception to make the error understandable: new RuntimeException("declined: $card"). This is a convenience often used while debugging, because the message is visible both in the log and on the screen. The question is this: does the production profile hide this value as well?
Why Production Does Not Stop
The breakpoint of a debugger stops the program and makes its inside readable. This has two meanings. A stopped program does not answer requests: in production a breakpoint becomes a lock that keeps that request waiting. In addition, every variable a stopped program shows is open to the person attached to the debugger; this person’s authority is wider than the authority of the one who sent the request. Both make the debugger a tool that belongs to the development profile.
In production narrower tools take its place: the log, the exception trace, and the metric. They do not stop the program, but the information they show depends on settings. This is exactly what this lesson measures. In development detailed information is wanted, because the reader is the person who will solve the error. In production the same information may go to a log, an error page, or a monitoring service, and who will read it there is not known. For this reason zend.exception_ignore_args is 1 in the production profile, and for this reason display_errors is 0.
A distinction has to be made here. These directives do not hide the error; they decide where the error is shown. In production the error still occurs, is still written to the log, and is still thrown as an exception. What changes is which fields are present in the response and in the trace. So the conclusion is not “there is no error in production”; it is only “the detail of the error is not visible in production”.
Trace Format and Notice Level
We measure the trace itself as well. getTraceAsString() writes each call on one line, and each line starts with a sequence number such as #0 or #1. debug_print_backtrace() prints the same information directly to the output. We measure whether the two follow the same protection. The first probe asks whether a trace line carries the name of the called function, and the second asks whether the output of debug_print_backtrace() carries the argument value. The third looks at another piece of information: the deprecation notice. Even if printing to the screen is turned on with ini_set('display_errors', '1'), the notice is never produced if the error_reporting value leaves it out. In the production profile error_reporting=24575, so the E_DEPRECATED flag is off.
<?php // trace.php — trace format and notice level declare(strict_types=1); require 'side.php'; $probes = [ 'a trace line carries the function name' => <<<'PHP' <?php function pay(string $card): void { throw new RuntimeException('declined'); } try { pay('card-0042'); } catch (RuntimeException $e) { echo str_contains($e->getTraceAsString(), '#0') && str_contains($e->getTraceAsString(), 'pay(') ? "yes\n" : "no\n"; } PHP, 'debug_print_backtrace carries the argument value' => <<<'PHP' <?php function pay(string $card): void { debug_print_backtrace(); } ob_start(); pay('card-0042'); echo str_contains(ob_get_clean(), 'card-0042') ? "yes\n" : "no\n"; PHP, 'a deprecation notice arrives even when display is turned on' => <<<'PHP' <?php ini_set('display_errors', '1'); ob_start(); strlen(null); echo str_contains(ob_get_clean(), 'Deprecated') ? "yes\n" : "no\n"; PHP, ]; foreach ($probes as $name => $code) { file_put_contents('probe.php', $code); sideReport($name, 'probe.php'); }
a trace line carries the function name → both sides
development yes · production yes
debug_print_backtrace carries the argument value → development only
development yes · production no
a deprecation notice arrives even when display is turned on → development only
development yes · production no
It reads as follows. The trace format is independent of the settings: a trace line carries the sequence number and the function name in both profiles. The second line shows an unexpected distinction: debug_print_backtrace() prints the argument value only in development and not in production. This difference is not created by zend.exception_ignore_args; that directive concerns only the exception trace. The difference comes from the zend.exception_string_param_max_len directive: when debug_print_backtrace() turns the stack into text, it truncates string arguments to this length. Because the value is 15 in development and 0 in production, card-0042 is printed in full in development and is not printed in production. debug_backtrace(), measured earlier in this lesson, returns the stack as an array without turning it into text, and it gives the argument on both sides. Two functions that read the same stack behave differently from the point of view of protection. Which one is called therefore matters.
The third probe is also development only: the deprecation notice is not produced in the production profile even if display_errors is turned on, because error_reporting turns that level off. This shows that a warning can be invisible for two separate reasons: the notice may be produced but not printed to the screen, or it may not be produced at all. In production the second case means that a deprecation leaves no trace, either in the response or in this measurement. If a deprecated call is not noticed in development, it is not noticed in production either.
Simulating a Breakpoint
A breakpoint stops at a line and shows the caller, the arguments, and the local variables. How much of this can debug_backtrace() simulate? Three probes ask what can be read from the stopped point: the name of the calling function, the argument of the call, and a local variable of the calling function.
<?php // simulate.php — how much of a breakpoint does debug_backtrace simulate declare(strict_types=1); require 'side.php'; $probes = [ 'the caller\'s name can be read' => <<<'PHP' <?php function halt(): array { return debug_backtrace(); } function pay(string $card): array { $balance = 'balance-77'; return halt(); } $trace = pay('card-0042'); echo ($trace[1]['function'] ?? '') === 'pay' ? "yes\n" : "no\n"; PHP, 'the caller\'s argument can be read' => <<<'PHP' <?php function halt(): array { return debug_backtrace(); } function pay(string $card): array { $balance = 'balance-77'; return halt(); } $trace = pay('card-0042'); echo in_array('card-0042', $trace[1]['args'] ?? [], true) ? "yes\n" : "no\n"; PHP, 'the caller\'s local variable can be read' => <<<'PHP' <?php function halt(): array { return debug_backtrace(); } function pay(string $card): array { $balance = 'balance-77'; return halt(); } $trace = pay('card-0042'); echo str_contains(json_encode($trace), 'balance-77') ? "yes\n" : "no\n"; PHP, ]; foreach ($probes as $name => $code) { file_put_contents('probe.php', $code); sideReport($name, 'probe.php'); }
the caller's name can be read → both sides
development yes · production yes
the caller's argument can be read → both sides
development yes · production yes
the caller's local variable can be read → not in the run
development no · production no
The result draws the limit of the simulation. The caller’s name and argument can be read; the local variable cannot. A real breakpoint shows all local variables on the line where it stops, while debug_backtrace() gives only the call chain and the arguments. The two tools are not in the same family: one stops the program and looks inside it, and the other reads a record the program leaves by itself. This difference is the reason we do not use a debugger in production, and also the reason a forgotten debug_backtrace() call is dangerous: the second one goes to production and shows the arguments in both profiles.
The Bounding Measurement
The driver runs two exceptions side by side: one takes the value as an argument, and the other writes the value into the message. For each of them, whether the value card-0042 is found somewhere inside the exception is asked separately. For the trace, getTraceAsString() is read; for the message, getMessage().
<?php // limit.php — is the value in the argument or in the message declare(strict_types=1); require 'side.php'; $probes = [ 'a value in an argument is carried by the trace' => <<<'PHP' <?php function pay(string $card): void { throw new RuntimeException('declined'); } try { pay('card-0042'); } catch (RuntimeException $e) { echo str_contains($e->getTraceAsString(), 'card-0042') ? "yes\n" : "no\n"; } PHP, 'a value in the message is carried by the message' => <<<'PHP' <?php function pay(string $card): void { throw new RuntimeException("declined: $card"); } try { pay('card-0042'); } catch (RuntimeException $e) { echo str_contains($e->getMessage(), 'card-0042') ? "yes\n" : "no\n"; } PHP, 'a value in the message is carried by the trace too' => <<<'PHP' <?php function pay(string $card): void { throw new RuntimeException("declined: $card"); } try { pay('card-0042'); } catch (RuntimeException $e) { echo str_contains($e->getTraceAsString(), 'card-0042') ? "yes\n" : "no\n"; } PHP, ]; foreach ($probes as $name => $code) { file_put_contents('probe.php', $code); sideReport($name, 'probe.php'); }
a value in an argument is carried by the trace → development only
development yes · production no
a value in the message is carried by the message → both sides
development yes · production yes
a value in the message is carried by the trace too → development only
development yes · production no
The result shows the limit of the protection. If the value is in an argument, the production profile removes it from the trace. If the value is written into the message, zend.exception_ignore_args does not touch it: getMessage() carries the value card-0042 in both profiles, and the message goes to the error log or the error page as it is. The protection covers only the argument list; the message is a string, and PHP does not interfere with its contents. The third line completes the distinction: even if the value is in the message, the trace does not carry the value in production, because the argument list is empty. The value leaks not from the trace but from the message. Which field is read decides the measurement.
This has two consequences. First, the trace carrying no value in production is not a guarantee by itself; it must be verified separately that the value is not written into the message. Second, the message that is useful while debugging, the one that carries the value, has to be reviewed before it goes to production.
Summary
- A step debugger is the class of tool that stops at a breakpoint and shows the variables and the call stack; PHP’s own counterparts are the exception trace,
debug_backtrace(), anddisplay_errors. - The argument value in the exception trace is visible only in development; in production
zend.exception_ignore_args=1hides it, and this is a protection. - Printing a warning into the response is also seen only in development; in production
display_errors=0closes the information disclosure. debug_backtrace()returns the argument in both profiles: the protection is specific to the exception trace and does not cover a call written into the code.- If the value is written into the exception message, the production profile does not hide it; the protection covers only the argument list.
Next Step
With this lesson all the quality tools have been scanned with the question of whether a behavior splits between the two profiles: static analysis and style checking stayed outside the run, and assertions and detailed error information appeared only in development. In the next lessons the question returns to the file itself: when a file changes, which version does PHP run, the one on disk or the one in the cache? The answer is again written in the configuration file and takes a different value in the production profile.
To keep your progress and take notes, Log in
My notes
Log in to take notes.