Spans
One span per HTTP request becomes an endpoint row in TracePath. Everything below it in the trace is a child span: database queries, outgoing HTTP calls, and any work you instrument yourself.
A typical request ends up looking like this in the trace view:
GET /users/{id} 120ms
├─ db.users.find 12ms your own span
│ └─ SELECT 1ms db.system.name=sqlite
└─ GET 40ms server.address=api.example.comChild spans come from two places: the open-telemetry/opentelemetry-auto-* package for that library, and your own code. TracePath does not care which — both arrive as OTLP spans and both appear on the trace.
Spans from auto-instrumentation
Each library has its own zero-code package on Packagist. Install the one you want alongside the setup in the Quick Start, and it produces child spans with no code:
| Library | Package | Span kind |
|---|---|---|
| Doctrine DBAL | open-telemetry/opentelemetry-auto-doctrine | CLIENT |
| PDO | open-telemetry/opentelemetry-auto-pdo | CLIENT |
| Guzzle | open-telemetry/opentelemetry-auto-guzzle | CLIENT |
| PSR-18 HTTP clients | open-telemetry/opentelemetry-auto-psr18 | CLIENT |
All of them need the opentelemetry PECL extension, same as the Symfony instrumentation. Check the package on Packagist for the versions it supports before adding it — a client version outside the supported range installs cleanly and then produces no spans.
Outgoing HTTP spans are named after the method alone. The host lives in the server.address attribute, which keeps one busy client from splitting into hundreds of span names.
Exclude the OTLP endpoint from HTTP client instrumentation, or every export creates a span, which is exported, which creates a span. The auto-instrumentation packages do not know which host is your telemetry backend.
Creating spans yourself
Use the tracer from the global provider. This works identically with or without the PECL extension, because the API and the SDK are pure PHP:
use OpenTelemetry\API\Globals;
$tracer = Globals::tracerProvider()->getTracer('app');
$span = $tracer->spanBuilder('process-order')->startSpan();
$scope = $span->activate();
try {
$this->processOrder($orderId);
} finally {
$scope->detach();
$span->end();
}Always detach the scope and end the span in a finally block. A span that is never ended is never exported, and a scope that is never detached leaves the wrong parent active for everything that runs afterwards.
That boilerplate is worth wrapping once. A small service keeps the finally block in one place and gives you nesting for free, since a span opened while another is active becomes its child automatically:
<?php
// src/Telemetry/Tracing.php
namespace App\Telemetry;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
final class Tracing
{
/**
* @template T
* @param callable():T $callback
* @param array<string, bool|float|int|string> $attributes
* @return T
*/
public function trace(
string $name,
callable $callback,
array $attributes = [],
int $kind = SpanKind::KIND_INTERNAL,
): mixed {
$span = Globals::tracerProvider()
->getTracer('app')
->spanBuilder($name)
->setSpanKind($kind)
->setAttributes($attributes)
->startSpan();
$scope = $span->activate();
try {
return $callback();
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$scope->detach();
$span->end();
}
}
}Autowiring picks it up with no service definition. Then the call sites read as one line each, and nesting is implicit:
$this->tracing->trace('order.fulfill', function () {
$this->tracing->trace('inventory.reserve', fn () => $this->reserve());
$this->tracing->trace('payment.charge', fn () => $this->charge(), kind: SpanKind::KIND_CLIENT);
});Adding Attributes
Attach metadata to spans for filtering and debugging:
$span->setAttribute('db.system', 'mysql');
$span->setAttribute('cache.key', $key);
$span->setAttribute('db.row_count', $rowCount);Span::getCurrent() gets you the active span from anywhere, including a controller, a service, or a Messenger handler, without passing it around:
use OpenTelemetry\API\Trace\Span;
Span::getCurrent()->setAttribute('user.id', $userId);Attributes set on the request span show up on the endpoint row, and on any Issue raised during that request. See Exceptions.
Recording Errors on Spans
When a span's operation fails, record the exception and set the status:
use OpenTelemetry\API\Trace\StatusCode;
$span = $tracer->spanBuilder('external-api-call')->startSpan();
try {
$response = $this->httpClient->request('GET', 'https://api.example.com/data');
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$span->end();
}Span Naming Conventions
Span names group in the dashboard, so they must be low cardinality. Never put an id, an email, or a URL with parameters in a span name. Put those in attributes.
| Good | Bad |
|---|---|
db.users.find | query |
cache.sessions.get | cache |
stripe.charge | api |
s3.upload-image | upload |
email.send-welcome | send user 4821 an email |