open-telemetry/api
OpenTelemetry API for PHP provides vendor-neutral interfaces for traces, metrics, logs, and context propagation, letting you instrument applications without binding to a specific backend.
The first thing to understand is that open-telemetry/api is only the contract layer — interfaces, no-op implementations, context storage, baggage, and propagation. Installing it alone wires everything to no-ops: your code compiles, runs, and exports nothing. In a Laravel app you almost always get it transitively via the SDK:
composer require open-telemetry/sdk \
open-telemetry/exporter-otlp \
open-telemetry/opentelemetry-auto-laravel
Verify it landed:
composer show open-telemetry/api
# open-telemetry/api 1.x.x API for OpenTelemetry PHP.
Where to look first (all under OpenTelemetry\API\):
| Namespace / Class | Why you care |
|---|---|
Globals |
The single entry point: Globals::tracerProvider(), meterProvider(), loggerProvider(), propagator(), registerTracerProvider() |
Trace\Span |
Span::getCurrent() — grab the active span anywhere, no DI |
Trace\TracerInterface / SpanBuilderInterface |
Building spans manually |
Context\Context / Context\Scope |
Activation/detachment of the current span & baggage |
Baggage\Baggage |
Cross-service key/value propagation |
Common\Time\Clock |
The PSR-20 clock used for span timestamps (moved into API in 1.1) |
First use case — a span around a slow service call inside a Laravel service class:
use OpenTelemetry\API\Globals;
$tracer = Globals::tracerProvider()->getTracer('app', '1.0.0');
$span = $tracer->spanBuilder('pdf.render')
->setSpanKind(\OpenTelemetry\API\Trace\SpanKind::KIND_INTERNAL)
->setAttribute('pdf.template', $template)
->startSpan();
$scope = $span->activate();
try {
$result = $this->renderer->render($template);
return $result;
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$scope->detach();
$span->end();
}
That is the whole day-to-day loop: start → activate → work → detach → end. Everything else is attributes, status, and propagation.
open-telemetry/opentelemetry-auto-laravel already creates a server span for each request. Rather than starting a second one, enrich the current one in a middleware:
public function handle($request, Closure $next)
{
$span = \OpenTelemetry\API\Trace\Span::getCurrent();
if ($span->isRecording()) { // cheap guard, see Gotchas
$span->setAttribute('app.tenant', tenant()->id);
$span->setAttribute('app.locale', app()->getLocale());
}
return $next($request);
}
Register it late in the web group so the route (and therefore route.action) is resolved when it runs.
Wrap the domain calls that can be slow or fail — PDF rendering, third-party HTTP, report generation — not controllers. Guard attribute construction, since building attributes isn't free:
$span = Span::getCurrent();
if ($span->isRecording()) {
$span->setAttribute('order.items', $order->items->count());
$span->setAttribute('order.total', $order->total);
}
Auto-instrumentation handles the producer/consumer handshake, but a Link makes the causal chain explicit when a job is dispatched from a request:
$span = Globals::tracerProvider()->getTracer('app')
->spanBuilder('invoice.email')
->addLink(Span::getCurrent()->getContext())
->startSpan();
Better still: use the WithSpan attribute from OpenTelemetry\API\Instrumentation on the job's handle() method if auto-instrumentation is loaded — you get span creation, activation, and error recording for free.
Guzzle middleware via the SDK's open-telemetry/opentelemetry-guzzle (contrib) covers this, but if you're using raw Http::withHeaders() the API lets you inject traceparent yourself:
$carrier = [];
Globals::propagator()->inject($carrier);
Http::withHeaders($carrier)->post('https://billing.internal/charge', $payload);
And on the way in, extract from a PSR-7 request before starting your own span:
$parent = Globals::propagator()->extract(
$request->headers->all() // flatten to scalar values first
);
$span = Globals::tracerProvider()
->getTracer('app')
->spanBuilder('webhook.handle')
->setParent($parent)
->startSpan();
Set baggage once per request (in a middleware), then read it in any service without threading parameters through:
$baggage = \OpenTelemetry\API\Baggage\Baggage::getBuilder()
->set('tenant.id', (string) tenant()->id)
->set('user.id', (string) auth()->id())
->build();
$ctx = \OpenTelemetry\API\Context\Context::storage()->get()
->withContextValue($baggage);
$scope = $ctx->activate();
// ... later, anywhere:
// Baggage::fromContext(Context::storage()->get())->getEntry('tenant.id')->getValue()
Baggage travels automatically to downstream services via the propagator.
The API is identical across the SDK, so metric code written now survives exporter changes:
$meter = Globals::meterProvider()->getMeter('app');
$ordersCreated = $meter->createCounter('orders.created', 'orders', 'Orders created');
$ordersCreated->add(1, ['plan' => $tenant->plan]);
For anything derived from existing state (queue depth, active subscriptions), use an observable/callback instrument rather than incrementing on every code path.
Build log records through Logger::logRecordBuilder() so trace_id/span_id attach automatically, then bridge Monolog via open-telemetry/opentelemetry-logger-monolog. Do not hand-format [trace_id=...] into the message — the exporter's attributes are what your backend queries.
OTEL_SERVICE_NAME=my-laravel-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
OTEL_PHP_DISABLED_INSTRUMENTATIONS=laravel # local dev escape hatch
OTEL_LOG_LEVEL=debug
Register a test provider with an in-memory exporter, assert on finished spans, and never rely on a real collector in CI. Globals::registerTracerProvider() is the hook; keep a TestCase helper that swaps in a fresh provider per test so span state doesn't leak between tests.
No-op by default is the #1 time sink. If Globals::tracerProvider() returns a NoopTracerProvider, every call succeeds, every span is a no-op, and nothing appears in your backend. Diagnose with OTEL_LOG_LEVEL=debug, and confirm the SDK's Sdk::builder() actually ran (typically via the composer autoloader file the SDK installs). A missing composer dump-autoload --optimize after installing the auto-instrumentation packages silently skips bootstrap.
Detach before end, always in finally. Forgetting $scope->detach() leaves the span active, so every subsequent span in the request becomes its child. Forgetting $span->end() means the span is never exported — no error, just silence.
Octane and long-running workers. A worker process keeps its provider and context storage alive across requests. Any span you activate must be detached in finally, and any baggage you set must be scoped to the request. If you see span trees growing across requests under Octane, you have a leaked scope — check that custom middleware isn't returning early before detach().
isRecording() guards, not just isEnabled(). Sampling decisions are made at span start. Checking Span::getCurrent()->isRecording() before computing expensive attributes avoids costing CPU on the 90% of requests your sampler drops. The same applies to metric callbacks — they're always weakened for async observers, so don't assume the callback runs on the thread that registered it.
Attribute value rules are strict. Only scalars and homogeneous arrays of scalars are accepted. Nested arrays, null, and mixed arrays are silently dropped (see the 1.0.0beta10 change "drop attributes with non-homogenous array values"). For anything structured, json_encode() it and send a string, or flatten with dotted keys (order.item.0.sku).
Baggage is size-capped. W3C limits (8192 bytes / 180 entries) were enforced in 1.10.0. Never put payloads, JWTs, or user profiles in baggage — put an ID in baggage and look up the rest.
Deprecations to steer around. InstrumentationInterface and ConfigurationResolver are deprecated as of 1.9.0 — use Globals plus env/file config. The event logger was deprecated in 1.2.0 in favor of Logger::logRecordBuilder(). Writing new code against the deprecated surface means a rewrite in the next minor.
Status semantics matter to your backend. Set STATUS_ERROR with a description on failure; leave status Unset on success. Marking spans STATUS_OK explicitly breaks many backends' error-rate calculations and conflicts with the spec's guidance.
The SpanAttribute attribute needs hooks. #[SpanAttribute] on a method parameter is only interpreted by the auto-instrumentation extension (opentelemetry-php-instrumentation) plus a registered hook. In plain PHP without the extension it is inert metadata — don't expect attributes to appear from a normal method call.
Extension vs. no extension. Auto-instrumentation of third-party libraries requires the native ext-opentelemetry. Laravel's own auto-instrumentation works without it, but if a package's spans never appear, check php -m | grep opentelemetry before debugging config.
Pin API and SDK together. They're separate Packagist packages released from one monorepo. Mixing a fresh open-telemetry/sdk with a stale open-telemetry/api (or a beta contrib instrumentation package with stable core) produces subtle interface-mismatch errors. Constrain both to the same minor and let Composer resolve.
Context::storage() is process-global static state. That's what makes Span::getCurrent() work without DI — and also what makes tests order-dependent. Reset context between tests; don't let one test's activated span bleed into the next.
Extension points for custom needs: implement TextMapPropagatorInterface for a bespoke header format, or a SamplerInterface for per-tenant sampling (sample 100% for your enterprise tenants, 1% for free tier). Both plug in via the SDK builder, and your application code never changes.
How can I help you explore Laravel packages today?