OpenTelemetry
Symfony
Spans

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.com

Child 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:

LibraryPackageSpan kind
Doctrine DBALopen-telemetry/opentelemetry-auto-doctrineCLIENT
PDOopen-telemetry/opentelemetry-auto-pdoCLIENT
Guzzleopen-telemetry/opentelemetry-auto-guzzleCLIENT
PSR-18 HTTP clientsopen-telemetry/opentelemetry-auto-psr18CLIENT

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.

GoodBad
db.users.findquery
cache.sessions.getcache
stripe.chargeapi
s3.upload-imageupload
email.send-welcomesend user 4821 an email