open-telemetry/context
Immutable, execution-scoped context propagation for PHP based on the OpenTelemetry specification. Activate contexts with scopes and detach them safely, with optional fiber support and guidance for event loops.
open-telemetry/context is the storage primitive that the rest of OpenTelemetry PHP sits on. Nothing in it emits spans, exports data, or knows about Laravel — it is an immutable, copy-on-write key/value store with an activation stack, and everything interesting you'll do with it is get current → derive a new Context → activate → detach.
Install and orient yourself
composer require open-telemetry/context
Three files matter:
OpenTelemetry\Context\Context — getCurrent(), getRoot(), createKey(), storage()ContextKeyInterface — opaque, identity-based keys (not string-keyed)ScopeInterface — what activate() returns; detach() is mandatoryRead the upstream context spec once. It's short and it explains why contexts are immutable, which is the source of most beginner bugs.
First useful use case in a Laravel app
Don't start with tracing. Start with a context-carrying unit of work you already understand: a request-scoped correlation ID that survives into logs and into the job you dispatch from that request.
use OpenTelemetry\Context\Context;
final class RequestContext
{
public static function key(): ContextKeyInterface
{
static $key;
return $key ??= Context::createKey('app.request_id');
}
}
// Placeholder for now — activate a context the rest of this file can read.
$context = Context::getCurrent()->with(RequestContext::key(), (string) Str::uuid());
$scope = $context->activate();
try {
// everything in here sees the request id via Context::getCurrent()
} finally {
$scope->detach();
}
That 8-line pattern is 80% of day-to-day usage. Everything else in this assessment is that pattern applied at the right seam.
Prerequisites worth checking before you commit
open-telemetry/php. Pin this and the SDK to compatible minor versions and upgrade them together.A single HTTP middleware should own the outermost activate()/detach() pair. Everything nested inside it then behaves correctly without knowing about scopes.
final class OpenTelemetryContextMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$parent = $this->propagator->extract($request->headers->all()); // W3C traceparent/tracestate
$context = $parent->with(RequestContext::key(), $request->header('X-Request-Id') ?? (string) Str::uuid());
$scope = $context->activate();
try {
return $next($request);
} finally {
$scope->detach(); // never omit, never reorder
}
}
}
Register it before anything that dispatches jobs, calls HTTP, or writes logs that you want correlated.
Log::withContext() in the same middleware is cheaper than reading Context from a Monolog processor, and it composes with the scope:
Log::withContext([
'request_id' => $context->get(RequestContext::key()),
'trace_id' => $span->getContext()->getTraceId(),
]);
If you prefer pulling at write time (useful when the trace ID is only known mid-request), push a Monolog processor in a service provider:
Log::getLogger()->pushProcessor(function (array $record) {
if ($id = Context::getCurrent()->get(RequestContext::key())) {
$record['extra']['request_id'] = $id;
}
return $record;
});
The processor is the better pattern for long-running workers, because the value is read at log-write time rather than frozen in a logger instance.
Context does not survive serialization. You must stamp the payload on dispatch and rehydrate in a job middleware.
// AppServiceProvider::boot() — runs in the dispatching process
Queue::createPayloadUsing(function ($connection, $queue, array $payload) {
$scope = Context::getCurrent()->activate();
try {
$carrier = [];
TraceContextPropagator::getInstance()->inject($carrier);
return $payload + ['otel' => $carrier];
} finally {
$scope->detach();
}
});
final class RestoreTraceContext
{
public function handle(object $job, Closure $next)
{
$carrier = $job->otel ?? json_decode($job->getRawBody(), true)['otel'] ?? [];
$parent = TraceContextPropagator::getInstance()->extract($carrier);
$scope = ($parent ?: Context::getRoot())->activate();
try {
return $next($job);
} finally {
$scope->detach();
}
}
}
Register via $job->middleware([RestoreTraceContext::class]) or globally with Bus::pipeThrough(). Rehydrating from Context::getRoot() when the carrier is empty is important — otherwise a job dispatched by a tinker session inherits whatever stale context exists in the worker.
Laravel's HTTP client is the seam where propagation is usually forgotten:
Http::withHeaders($carrier)->post(...);
Wrap it in a macro that injects traceparent from Context::getCurrent() so call sites stay clean:
Http::macro('traced', function () {
$carrier = [];
TraceContextPropagator::getInstance()->inject($carrier);
return Http::withHeaders($carrier);
});
For anything that defers execution, bind the context at scheduling time. The README's helper is the canonical shape:
function bindContext(Closure $closure): Closure {
$context = Context::getCurrent();
return static function (mixed ...$args) use ($closure, $context): mixed {
$scope = $context->activate();
try { return $closure(...$args); }
finally { $scope->detach(); }
};
}
Use this around queue:work daemons' per-job wrappers, websocket message handlers, and any Redis/pubsub subscriber. Without it, the callback runs in whatever context happens to be current.
Same middleware logic, different entry point. For php artisan commands, activate in handle() and detach in a finally. For the scheduler, wrap each event closure with bindContext() — scheduled tasks run inside a long-lived schedule:run process where a leaked scope will silently apply to the next task.
Assert the invariant rather than the internals:
public function test_middleware_restores_context(): void
{
$root = Context::getRoot();
$this->get('/')->assertOk();
$this->assertSame($root, Context::getCurrent());
}
For unit tests of context-dependent code, Context::storage() is swappable — replace it with a trivial storage implementation in setUp() if you need deterministic isolation from the fiber storage.
Contexts are immutable — with() returns a new object. $context->with($key, $value); on its own is a no-op with no warning. Always assign: $context = $context->with(...). This is the single most common bug.
Scopes must detach LIFO. If you activate A then B, detach B then A. Out-of-order detaches leave the current context pointing at a node that's gone or, worse, at the wrong ancestor. In PHP-FPM you often get away with it because the process dies; in Octane, queue workers and Swoole this silently corrupts the next request's trace.
Debug scopes warn loudly, and dd()/exit trip them. A scope alive at process shutdown produces notices. Laravel's dd(), die() and hard exit calls will do this in local dev. Set OTEL_PHP_DEBUG_SCOPES_DISABLED=1 in .env.local if the noise is unmanageable — but disabling it in CI removes your safety net, so keep it on there. The warnings are the only feedback you get for a leaked scope.
Nothing propagates across a process boundary for free. Context is per-process memory. HTTP headers, queue payloads, and log shipping all require explicit inject/extract. If traces look "broken at the queue", the bug is almost always a missing inject() at dispatch, not a missing activate() at consume.
Context keys are identity-based, not string-based. Two packages both doing Context::createKey('tenant.id') get different keys and will not see each other's values. Share a single static key (or a small Keys class) across your app. This is also why you can't override or accidentally clobber another library's value — by design.
Fiber support is opt-in and environment-sensitive. It requires an NTS build, ext-ffi, OTEL_PHP_FIBERS_ENABLED=true, and vendor/autoload.php preloaded for non-CLI SAPIs. It is explicitly unsafe on ZTS. Laravel Octane with Swoole uses coroutines, not fibers, so fiber propagation does not cover it — treat Octane as a case where you must manually reset context per request and per tick, and test under load.
Baggage is untrusted input. Anything extracted from headers or job payloads can be attacker-controlled. Never put secrets, tokens or raw PII in baggage, and strip/replace incoming baggage at the edge rather than trusting X-Correlation-Id verbatim. Correlation is an enabler for audit and incident response, not a compliance control in itself.
Don't hold a Context in a singleton. Storing a Context (or worse, a Scope) on a bound singleton pins the whole linked list for the life of the worker and defeats the garbage collection improvements shipped in 1.2.1. Read Context::getCurrent() at the point of use; pass primitives (trace ID strings) across boundaries.
Scope is cheap but not free. Activating inside a tight loop (per-row in an import, per-attribute in a serializer) adds up in profile traces. Hoist the activate/detach outside the loop and mutate via with() inside.
Version alignment matters more than usual. This package is a split of one monorepo along with open-telemetry/api, open-telemetry/sdk and open-telemetry/sem-conv. Mixing minor versions across them produces confusing type errors on ContextInterface. Upgrade as a set, and check the monorepo's CHANGELOG/releases, not the split repo's.
Extension point: Context::storage(). The default storage is fiber-aware in recent versions; swapping in a custom ContextStorageInterface is how you'd integrate a non-fiber async runtime. That's an advanced, invasive lever — exhaust bindContext() wrappers first, because a custom storage is global and applies to every library in the process.
How can I help you explore Laravel packages today?