Back to HTTP Headers

Traceparent observability both

Carries distributed tracing context across service boundaries — the W3C standard for propagating trace IDs in microservices.

What it does

Traceparent carries distributed tracing context as a request travels across services. When Service A calls Service B which calls Service C, Traceparent threads a single trace ID through all three hops — so your observability tooling can stitch the full request journey into one timeline rather than three disconnected logs.

It's the W3C standard for trace context propagation, adopted by OpenTelemetry, Datadog, Jaeger, Zipkin, Honeycomb, and basically every modern observability platform. If you're doing microservices without it, you're debugging in the dark.

Syntax

Traceparent: <version>-<trace-id>-<parent-id>-<trace-flags>

All four fields are required, hyphen-separated:

Traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Field breakdown:

  • 00 — version (always 00 currently)
  • 4bf92f3577b34da6a3ce929d0e0e4736 — trace ID: 16-byte hex, unique per trace
  • 00f067aa0ba902b7 — parent ID: 8-byte hex, identifies the current span
  • 01 — trace flags: 8-bit flags, 01 = sampled, 00 = not sampled

The four fields explained

Version (00)

Always 00 for the current spec. Future versions may change the format. Parsers must reject unknown versions.

Trace ID (32 hex chars = 16 bytes)

The globally unique identifier for the entire trace — the same value across every service the request touches. Generated by the first service to receive the request and passed unchanged to all downstream calls.

4bf92f3577b34da6a3ce929d0e0e4736

All-zeros (00000000000000000000000000000000) is invalid — means no trace context.

Parent ID (16 hex chars = 8 bytes)

Identifies the current span — the specific unit of work making this outgoing call. Each service generates a new parent ID for its own span and passes it downstream. This is how the trace tree is reconstructed: Service B's parent ID becomes Service C's incoming parent, linking B's span as the parent of C's span.

00f067aa0ba902b7

All-zeros is invalid.

Trace Flags (2 hex chars = 1 byte)

Currently only one flag is defined:

  • 01 — sampled: this trace is being recorded, downstream services should record it too
  • 00 — not sampled: don't record (useful for high-volume sampling decisions)

How a trace flows through services

Browser → API Gateway → User Service → Orders Service → DB

1. API Gateway receives request, generates:
   Traceparent: 00-abc123...-span001-01

2. API Gateway calls User Service with:
   Traceparent: 00-abc123...-span001-01
   (same trace-id, span001 = API Gateway's span)

3. User Service records span with parent=span001, generates its own span002
   User Service calls Orders Service with:
   Traceparent: 00-abc123...-span002-01
   (same trace-id, span002 = User Service's span)

4. Orders Service records span with parent=span002

Your observability tool receives all these spans, sees the same trace-id, and reconstructs the full tree: API Gateway → User Service → Orders Service, with timing for each hop.

Implementing in Laravel

// Middleware: propagate incoming trace context + generate if none
class TraceContextMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $traceparent = $request->header('Traceparent');

        if ($traceparent && $this->isValid($traceparent)) {
            // Extract trace-id from incoming header
            [, $traceId, , $flags] = explode('-', $traceparent);
        } else {
            // Generate new trace
            $traceId = bin2hex(random_bytes(16));
            $flags = '01';
        }

        // Generate new span ID for this service
        $spanId = bin2hex(random_bytes(8));
        $newTraceparent = "00-{$traceId}-{$spanId}-{$flags}";

        // Store in request for downstream HTTP calls to pick up
        app()->instance('traceparent', $newTraceparent);
        app()->instance('trace-id', $traceId);

        $response = $next($request);

        return $response;
    }

    private function isValid(string $tp): bool
    {
        return (bool) preg_match(
            '/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/',
            $tp
        );
    }
}

// When making outgoing HTTP calls, forward the context:
Http::withHeaders([
    'Traceparent' => app('traceparent'),
    'Tracestate'  => request()->header('Tracestate', ''),
])->get('http://other-service/api/data');

With OpenTelemetry (the proper way)

If you're using OpenTelemetry SDK (which you should be for anything serious), it handles all of this automatically:

// composer require open-telemetry/sdk open-telemetry/exporter-otlp
// OpenTelemetry auto-instruments Laravel and propagates Traceparent automatically
// You just define spans for your business logic:

$tracer = Globals::tracerProvider()->getTracer('my-service');
$span = $tracer->spanBuilder('process-order')->startSpan();
$scope = $span->activate();

try {
    // your logic
} finally {
    $span->end();
    $scope->detach();
}

Traceparent vs Tracestate

They're always used together:

  • Traceparentstandardised trace context (trace-id, span-id, flags). Vendor-neutral.
  • Tracestatevendor-specific extra data alongside the standard context (Datadog sampling decisions, Jaeger debug flags, etc.)

Always propagate both. Strip neither.

Common mistakes and gotchas

Generating a new trace-id on every hop. The whole point is one trace-id for the entire journey. Only generate a new trace-id if no valid Traceparent arrives. Always pass the existing trace-id downstream.

Not propagating to async jobs. When a request dispatches a queue job, the trace context dies unless you explicitly pass Traceparent to the job. Pass it as a job property and re-attach when the job runs.

Dropping Tracestate when forwarding. Always forward Tracestate alongside Traceparent. Dropping it breaks vendor-specific sampling decisions in the middle of a trace.

Using it without a collector. Traceparent headers alone don't give you traces — you need spans being sent to a collector (Jaeger, Tempo, Datadog, Honeycomb). The header is just the propagation mechanism; the actual tracing happens in your instrumentation.

Real-world examples

Incoming request with trace context:

GET /api/orders HTTP/1.1
Host: orders.example.com
Traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Tracestate: dd=s:1;t.dm:-0,vendorx=abc123

Service generating new trace (no incoming context):

# First service in chain generates:
Traceparent: 00-a3ce929d0e0e47364bf92f3577b34da6-b7a3ce929d0e0e47-01

Not sampled (high-volume, don't record):

Traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00

FAQ

Is Traceparent the same as a request ID?

Similar concept, different scope. A request ID (X-Request-Id) identifies a single HTTP request. A trace ID in Traceparent identifies the entire distributed operation — potentially dozens of HTTP requests across multiple services. Use both: X-Request-Id for individual request logging, Traceparent for cross-service trace correlation.

Do I need OpenTelemetry or can I implement it manually?

You can implement it manually (validate incoming header, generate new span-id, forward the header). For a single service or simple setups, manual is fine. For anything with more than 2-3 services, use OpenTelemetry — the SDK handles propagation, sampling, span management, and exporting to your backend automatically.

What happens if an upstream service doesn't send Traceparent?

Generate a new trace-id and start a fresh trace at your service. The trace won't include the upstream context, but you'll still get tracing within your own service boundary and anything downstream.

Fun fact

Before Traceparent, every observability vendor invented their own header: X-B3-TraceId (Zipkin/B3), X-Datadog-Trace-Id, uber-trace-id (Jaeger), X-Amzn-Trace-Id (AWS X-Ray). A request passing through services using different vendors would lose trace context at every boundary — you'd see half a trace in Datadog and the other half in Jaeger with no way to connect them. W3C Trace Context (and Traceparent specifically) was the industry's attempt to end this Tower of Babel situation. It became a W3C Recommendation in 2020, and within two years every major observability vendor had adopted it.