inspector:trace --trace-var

April 29, 2026 · View on GitHub

inspector:trace --trace-var attaches PHP variable values to each trace sample as they are captured, so you can join "what the stack was doing" to "what the runtime state was" without running a separate tool. Values are read from the target process with the same techniques as inspector:peek-var and inspector:watch --watch-var, then emitted next to each sample in the trace output.

Quick Start

# Tag every sample with a global's value
reli inspector:trace -p <pid> --trace-var='global::$request_uri'

# Multiple variables — each appears as its own annotation line
reli inspector:trace -p <pid> \
  --trace-var='global::$request_uri' \
  --trace-var='local::App\Controller::handle()$user_id'

# Only read every 10th sample to cut overhead at high sampling rates
reli inspector:trace -p <pid> -s 10000000 \
  --trace-var='global::$state' \
  --trace-var-every=10

# Only read when a specific frame is active — skips process_vm_readv entirely
# when the target function isn't on the stack
reli inspector:trace -p <pid> \
  --trace-var='local::App\PDOProxy::execute()$query' \
  --trace-var-on-function='App\PDOProxy::execute'

# Track memory usage per sample
reli inspector:trace -p <pid> \
  --trace-var='memory::memory_get_usage' \
  --trace-var='memory::memory_get_peak_usage'

# Binary (rbt) output — annotations ride on SAMPLE_ANNOTATION events
# (-f rbt is implied by the .rbt extension on -o)
reli inspector:trace -p <pid> -o trace.rbt \
  --trace-var='global::$counter'

Requirements

See Getting started § Requirements for the common runtime and target requirements. No command-specific overrides apply.

Variable Specification

--trace-var takes exactly the same expression grammar as inspector:peek-var --var — see the full reference in peek-var-command.md § Variable Specification. In short:

ScopeSyntaxWhat it reads
Globalglobal::$var$GLOBALS['var']
Locallocal::func()$varLocal variable in a specific function frame
Static propertystatic::Class::$propClass static property
Function staticfunc_static::func()$varFunction's static $var
Memorymemory::memory_get_usageZend MM heap stats

Nested array keys and object properties use the same path syntax:

--trace-var='global::$config[database][host]'
--trace-var='global::$app->cache->size'
--trace-var='global::$container->services[cache]->pool[active]'

The memory:: scope exposes memory_get_usage() / memory_get_peak_usage() equivalents. Available names: memory_get_usage, memory_get_peak_usage, memory_get_usage_real, memory_get_peak_usage_real. See peek-var-command.md § Memory Scope for details.

For local:: and func_static::, the function name is required; use <main> for the top-level script scope.

Annotations in each output format

phpspy text (-f template:phpspy, default)

Annotations appear as # <spec> = <value> comment lines after the frame list, before the blank-line separator:

0 App\Controller::handle /app/src/Controller.php:17
1 App\Http\Router::dispatch /app/src/Router.php:42
2 <main> /app/public/index.php:9
# global::$request_uri = (string) "/users/1234"
# local::App\Controller::handle()$user_id = (int) 1234

0 App\Controller::handle /app/src/Controller.php:18
1 App\Http\Router::dispatch /app/src/Router.php:42
2 <main> /app/public/index.php:9
# global::$request_uri = (string) "/users/1234"
# local::App\Controller::handle()$user_id = (int) 1234

reli's own phpspy parser and every downstream tool that treats #-prefixed lines as comments will simply ignore these lines, so the output stays round-trip safe — you can pipe it into reli converter:pprof, reli converter:speedscope, or any phpspy-compatible consumer without breaking them.

Value rendering is stable and single-line by construction — strings are JSON-escaped and truncated at 200 characters, so embedded newlines, tabs, and non-ASCII bytes can never break the line-oriented format:

TypeRendered
int(int) 42
float(float) 3.14
string(string) "hello\nworld" (truncated at 200 chars with ...)
bool(bool) true / (bool) false
array(array) count=10
nullnull
unknown type(unknown)
spec not resolvedthe annotation line is omitted for that sample

phpspy with opcode (-f template:phpspy_with_opcode)

Same shape as plain phpspy, with annotations after the frame list. The opcode column on frame lines is unaffected.

rbt binary (-f rbt / -f rbt-bundled)

Annotations are emitted as SAMPLE_ANNOTATION (event type 0x0B) directly following each sample event. Keys and values are interned via STRING_DEF per segment, so repeated values (e.g. the same SQL query across many samples) cost only one pair of varints per sample after the first. Full format details are in docs/tracing/binary-trace-format.md.

All downstream converters that already read .rbt (reli converter:phpspy, reli converter:speedscope, reli converter:pprof, reli converter:folded) preserve or discard annotations based on what the target format supports — the phpspy text converter re-emits them as # key = value lines, matching the live-capture output.

RLE interaction

In compact (no-timestamp) mode rbt uses REPEAT_SAMPLE to run-length-encode consecutive identical samples. The writer considers two samples identical only if both their stack and their annotations match. This has two practical consequences:

  • Stable annotations (e.g. global::$config['app_name'], session ID, tenant ID): RLE keeps working perfectly; the annotation adds roughly 2 bytes per changed run.
  • Noisy annotations (e.g. a monotonically increasing counter, microsecond timestamps): RLE effectively breaks per sample. Output grows to roughly samples × (compact_sample + annotation), which is still far smaller than phpspy text but larger than annotation-free rbt. Prefer values that are stable over the scales you care about, or use --trace-var-every to cut the read rate.

JSON Lines (-f template:json_lines)

The json_lines template is currently unchanged when --trace-var is set — its JSON shape is stable for jq-based consumers. Annotations in JSON Lines output are a planned follow-up via a dedicated template; for now, use phpspy text or rbt if you need per-sample annotations.

Native (C-level) Trace Support

--with-native-trace is fully supported. The merged native+PHP output carries annotations end-to-end in both phpspy text and rbt formats:

reli inspector:trace -p <pid> --with-native-trace \
  --trace-var='global::$request_uri'
0 libphp.so::zend_execute+0x42 [native]:0
1 App\Controller::handle /app/src/Controller.php:17
2 <main> /app/public/index.php:9
# global::$request_uri = (string) "/users/1234"

Variable reads always resolve against the PHP state of the sample (call frames, symbol table, CVs); the presence of native frames has no effect on which variables are visible. When a sample is native-only (via --native-trace-anytime, emitted during interpreter initialization or shutdown), no annotations are attached, because there are no PHP frames to inspect.

Rate Limiting

Variable reads go through process_vm_readv(2) just like other inspector:* reads. At default sampling rates the overhead is modest, but at high sampling rates (10ms intervals × several variables) it adds up. Two flags let you throttle without losing trace resolution:

--trace-var-every=<N> (default: 1)

Read variables only every N-th sample. The trace itself is captured at full rate; only the variable reads are throttled. Useful when the variable you're watching changes on a much slower timescale than the sampling interval:

# 10ms sampling, but only read $memory_usage every 100th sample (every 1s)
reli inspector:trace -p <pid> -s 10000000 \
  --trace-var='global::$memory_usage' \
  --trace-var-every=100

--trace-var-on-function=<fqn>

Skip the variable read entirely when the named fully-qualified function is not on the call stack. This is a cheap in-process gate — it scans the already-captured PHP call trace in microseconds and only pays the process_vm_readv cost when the target frame is actually active:

# Only read $query when PDO::execute is on the stack
reli inspector:trace -p <pid> \
  --trace-var='local::App\PDOProxy::execute()$query' \
  --trace-var-on-function='App\PDOProxy::execute'

This is the right knob when you're correlating a specific function's state with the stacks that reach it — you pay zero extra cost on samples that are elsewhere in the program.

The two flags compose: --trace-var-every=5 --trace-var-on-function=... means "at most once every 5 samples and only when the gate passes".

Error Handling

Variable reads can fail for harmless reasons — the target frame may have just returned, an object may have been freed mid-read, or the process may be in the middle of a memory allocation that briefly makes the structure inconsistent. --trace-var degrades gracefully:

  • A single spec that fails (target not found, indirect chain dangling, etc.) is silently omitted from that sample's annotation set. Other specs in the same sample are still emitted.
  • If the whole readVariables call throws (transient memory read failure), that sample's annotation set is dropped entirely. The trace sample itself is still emitted — you get a naked frame list, never a gap in the trace.
  • Specs that resolve but have no value at the moment (e.g. a CV that hasn't been assigned yet) produce no line for that sample.

This is the same contract as inspector:peek-var --repeat and inspector:watch --watch-var: transient reader failures never interrupt the loop.

Daemon Mode

inspector:daemon supports --trace-var in all three output modes:

# Per-worker rbt (annotations never leave the worker; zero IPC overhead)
reli inspector:daemon -P 'php-fpm' -f rbt \
  --trace-var='local::App\Svc::run()$req_id'

# Bundled rbt (annotations ride on PID_SAMPLE + SAMPLE_ANNOTATION)
reli inspector:daemon -P 'php-fpm' -f rbt-bundled \
  --trace-var='global::$request_uri'

# Template text (annotations flow over IPC to the controller)
reli inspector:daemon -P 'php-fpm' -f template:phpspy \
  --trace-var='global::$request_uri'

The same --trace-var-every / --trace-var-on-function knobs apply to every worker. The specs apply uniformly across all discovered targets — per-target specs are a planned future extension.

Performance considerations in daemon mode

Variable reads are paid per attached worker, not once globally — the cost across the worker pool is roughly active_workers × vars_per_sample × sample_rate. At 8 workers sampling at 10ms with 5 variables each, the daemon uses a measurable but still small fraction of a CPU. Two mitigations worth knowing:

  • --trace-var-on-function is disproportionately valuable in daemon mode: only the workers whose current stack contains the gate function pay the process_vm_readv cost. For rare target frames (e.g. "only when inside PDO::execute"), total daemon cost can drop by 10× or more.
  • --trace-var-every is a uniform knob across all workers. Use it when the variable you care about changes on a slower timescale than the sampling interval.

CG (compiler globals) resolution for static:: and func_static:: specs happens in the searcher, once per discovered PID, and is cached. So adding a static-scope spec doesn't make the hot reader path any more expensive than the equivalent local-scope spec.

All Options

OptionDefaultDescription
--trace-var=<expr>Variable to peek per sample (repeatable). Same grammar as inspector:peek-var --var
--trace-var-every=<N>1Read variables only every N-th sample
--trace-var-on-function=<fqn>Skip reads when the given FQN is not on the stack

All existing inspector:trace / inspector:daemon options (sampling interval, output format, native traces, etc.) continue to work unchanged. Run reli inspector:trace --help for the complete list.

Relationship with peek-var and watch --watch-var

inspector:peek-varinspector:watch --watch-varinspector:trace --trace-var
PurposeRead current valueTrigger actions on conditionAttach values to every trace sample
OutputVariable valuesTrigger events + actionsCall traces with per-sample annotations
When values are readOn demand (one-shot or repeat)Every poll while monitoringOnce per trace sample (or every N-th)
Joins to stacks?NoNo (separate trace captures)Yes, 1:1 with each sample
Best forQuick checks, scriptingProduction monitoring / alertingCorrelating runtime state with hot stacks

Use --trace-var when you want to answer "which stacks are hot while $request_uri is /api/heavy?" or "what values of $user_id show up in the PDO::execute stack?" — questions that need the stack and the value to be recorded together, sample by sample.