0002 — Who owns a native unit of work
0002 — Who owns a native unit of work
Status: accepted, 2026-09-16.
Context
cboxdk/telemetry-native measures one unit of work at a time:
begin() opens it, finish() closes it and returns what it measured. The
extension deliberately holds no opinion about what a unit is — a unit is a
label and a trace id, and any Laravel knowledge would be knowledge in C.
Two properties of that API decide everything on this side of the boundary.
Units do not nest. A second begin() inside an open unit abandons the
first one, silently, along with its samples; the abandoned handle then
returns an empty array. The extension is explicit that a caller nesting
units is a bug in the caller.
A unit is opened per process, not per process lifetime. RINIT fires
once per request under FPM and once per process in a worker, which is why
cbox_telemetry.auto exists and why it is off by default.
Laravel violates the first property without being asked to. A sync job
dispatched inside a request is a queue unit inside an HTTP unit. A scheduled
task is a unit inside schedule:run. Every job is a unit inside
queue:work. Three of the four places this package brackets a unit can
contain another one.
Nesting had to be resolved by someone. Leaving it to the extension means the outer unit is abandoned — and the outer unit is the request, whose samples cover the work the inner one caused.
Decision
1. The outermost unit wins. NativeProfiler holds one open unit per
process and refuses a second begin() while it is open. The inner site gets
null and records nothing native; its span, metrics and resource measurements
are unaffected.
This is a refusal, not a queue: an inner unit that "waits" would measure a period that had already ended.
2. The outermost unit is not always the outermost call. A command that
runs for hours and hosts its own units — queue:work, horizon*,
octane:*, schedule:run, schedule:work, reverb:* — opens no unit at
all, so the jobs and tasks inside it can. The list is configuration
(native.exclude_commands), because which commands a given app treats as
daemons is the app's knowledge, not this package's.
That leaves a command's own bootstrap unmeasured in exactly the cases where
measuring it would have meant measuring nothing else. It is the right trade
even though it is a real loss: a queue:work unit would report one number
covering a thousand jobs.
3. The latch is released on every reset path. NativeProfiler
implements ManagesRequestState, so an Octane or NativePHP request that
dies with a unit open closes it and discards the result. Without that, one
failed request would hold the one-unit rule shut for the remaining life of
the worker — a bug that would look like "native telemetry stopped working
on this one box".
4. Never two profilers at once, and never zero by accident. Where both
cbox_telemetry and ext-excimer are installed, the native profiler runs
and excimer does not: two statistical samplers running concurrently spend a
meaningful share of their samples observing each other, and neither profile
is then a description of the application.
The test is whether the native sampler is running, not whether a call
obtained a unit. A unit opens even where profiling is unavailable — the
handle is real and finish() reports profiling => false — so reading a
handle as "profiling is covered" silences an installed excimer on every host
where the extension is loaded but its timer is not usable (macOS has no
per-thread CPU timer; cbox_telemetry.profiler.enabled=0;
native.profile=false). Conversely a site refused a unit for nesting is
running inside one that is sampling, and must not start a second sampler
either.
5. The unit decides its own duration, including the part that predates
it. The tail threshold that keeps or discards a profile
(profiling.min_duration_ms) is evaluated against the unit's own monotonic
clock, at finish(), while the span is still open — not against
Span::durationMs(), which has no value until the span ends. Ending the
span first would answer the question correctly and have nothing left to
attach the answer to.
An adopted automatic unit was already running before any of this code
existed, so its elapsed time is measured from the SAPI's request start
(REQUEST_TIME_FLOAT) rather than from the adoption, and bounded by the
extension's own auto_max_ms. Otherwise an 800 ms bootstrap followed by
10 ms of routing reads as a 10 ms unit, and the threshold discards exactly
the profiles automatic mode exists to collect.
That anchor is only consulted when the extension reports an automatic unit
waiting to be adopted, which is why a long-running worker cannot inherit a
process-age offset from it: auto does not belong in a worker, and with
auto=0 there is never a unit to adopt.
6. Whether to keep a profile is asked at the end. The sampling decision
in force at finish() is the one that counts: a per-route Sample::never()
drops every span of the trace, and a profile with no trace to line it up
against is not worth materialising.
With one exception, because the package has one: an error span escapes
sampling (traces.always_sample_errors). A failing slow request is the one
whose profile is worth most, so a unit whose span ended in error keeps its
profile whatever the sampling policy says.
Consequences
- A sync job inside a request contributes to the request's native measurements rather than getting its own. That is the accurate reading: the work happened inside the request.
- An app that runs a custom worker command has to add it to
exclude_commandsor that command will open one very long unit, and nothing bounds it: the extension gives a deadline only to automatic units ("an explicit one has an owner who is going to call finish()"), and adoption clears even that.auto_max_msis not a safety net here. - The one-unit rule is per process, and a
fork()copies the PHP object that holds it. Ownership is therefore checked against the current pid, so a child opens its own unit rather than inheriting a latch it can never release. cbox_telemetry.auto=1remains an FPM-only setting, andtelemetry:doctorreports it rather than trying to infer whether it is right — the SAPI a php.ini serves is not visible from a console command.