Skip to content

Tracing for the serving path

Moduleobs.01 · build · Go · Pass 7 · 4 to 6 h
You buildgo/otelx/: Setup (the process’s TracerProvider), Inject, Extract, Traceparent, ContextWithTraceparent, IDs, the HTTP Middleware and Transport, StartServer with Span.SetAttribute, four gRPC interceptors, and LogHandler
Contractspan names, kinds, attributes, and propagation: otel/semconv.md; the Go API: section 4 below and the course tests; W3C Trace Context
Testscourse/tests/go/obs_01/ (what they check: section 4)
Needsnothing to call; read obs.00 first (the hand-written tracer export this replaces)
Used byobs.02 wires it into your gateway (log correlation, the SERVER span, the provider); dur.04 (activity spans from ActivityTask.trace_context) and ag.03 (agent spans) in later passes
MilestoneMS-prod (one trace from curl through gateway.route, kv.transfer, and engine.decode)
Optional depthOpenTelemetry Go (free), Sampling (free), Observability Engineering, ch. 6 and 7
  • Every hop extracts the caller’s traceparent, starts its own span as the child, and injects its own span id onward. The tests prove the tree across HTTP and gRPC in one request.
  • An outgoing call is a CLIENT span that lasts until the response body is done; a streamed completion is a long CLIENT span, not a 1 ms one.
  • A wrapper ResponseWriter must keep http.Flusher and Unwrap, or the SSE stream it wraps is buffered.
  • The sampler is ParentBased: a ratio decides only for new traces, so a sampled request never loses its middle hops.
  • Spans leave through a batch processor; ending a span never waits for the collector. No endpoint means no exporter at all.
Terminal window
ol start obs.01 # stubs go/otelx/*.go into your repo
ol tests obs.01 # read the test catalog first
cd go && go get go.opentelemetry.io/otel@v1.44.0 go.opentelemetry.io/otel/sdk@v1.44.0 \
go.opentelemetry.io/otel/trace@v1.44.0 \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.44.0 google.golang.org/grpc@v1.83.1
ol check obs.01 # exit code is the verdict
ol diff obs.01 # after passing: your code against the reference

Pass 1 traced one hop: the gateway’s gateway.proxy span and the engine’s server span, exported by hand as OTLP/HTTP JSON (obs.00). The serving platform you are building now has more hops and a second protocol: the gateway authenticates, rate-limits, routes, and calls a prefill engine over gRPC (tl.engine.v1.EngineControl/Prefill), which pushes KV blocks over gRPC (tl.kv.v1) to a decode engine, which streams tokens back over HTTP. When TTFT doubles under load, the question is which of those hops grew, and only a trace that crosses all of them answers it. A hand-written exporter per hop and per protocol does not scale to that; one small kit that every Go service (gateway now, durable engine and agent later) uses the same way does.

2.1 Span context and the three steps of a hop

Section titled “2.1 Span context and the three steps of a hop”
SymbolMeaningType
TTtrace id, shared by every span of one request16 bytes, 32 lowercase hex digits
SSspan id of one span8 bytes, 16 hex digits
P(s)P(s)the parent span id of span ss8 bytes, or none for the root
fftrace flags; bit 0 is sampled1 byte, 2 hex digits
rrthe sample ratio, [otel].trace_sample_ratioreal in [0,1][0, 1]

A span context is (T,S,f)(T, S, f), plus tracestate for vendor data. A hop does three things:

  1. Extract: read traceparent from the incoming request (HTTP header or gRPC metadata). If it is valid, the context of the request holds a remote span context (T,Scaller,f)(T, S_{caller}, f); if not, it holds none.
  2. Start: a new span ss with a fresh random SsS_s. If the context held a span context, TT is kept and P(s)=ScallerP(s) = S_{caller}; otherwise TT is fresh and ss is a root.
  3. Inject: every outgoing call carries traceparent = 00-T-S_c-f, where ScS_c is the span id of the span that makes the call. For HTTP and gRPC that is a CLIENT span started for the call.

The test suite checks these per protocol and then all at once (TestServingTreeAcrossHTTPAndGRPC).

KindWho records itName
SERVERthe side that handled a request<METHOD> <route> for HTTP (POST /v1/chat/completions); <service>/<method> for gRPC (tl.engine.v1.EngineControl/Prefill)
CLIENTthe side that made a call<METHOD> for HTTP; the same <service>/<method> for gRPC
INTERNALwork inside a processgateway.route, engine.decode

Names must have low cardinality: the route pattern /v1/models/{model}, never the path /v1/models/smol-135m. Each distinct name becomes a row in every trace search and a series in every span-derived metric, so a name per model (or per request id) explodes both.

A server span records http.response.status_code always. It sets status Error (and error.type to the code) only for code≥500code \ge 500: a 4xx is the client’s mistake, and marking it as a server error would page someone for a typo in a request. gRPC spans record rpc.grpc.status_code (0 is OK) and set Error with error.type = the code’s name (NotFound) for anything else.

Recording every span of every request costs CPU, network, and storage. A sampler decides at the root. TraceIDRatioBased(r) keeps a trace when the low 8 bytes of TT, read as a big-endian unsigned integer and shifted right once, are below r⋅263r \cdot 2^{63}:

keep(T)  ⟺  ⌊u64(T[8:16])2⌋<r⋅263\text{keep}(T) \iff \left\lfloor \tfrac{\text{u64}(T[8{:}16])}{2} \right\rfloor < r \cdot 2^{63}

Every process makes the same decision for the same TT without talking to the others. ParentBased(sampler) applies the ratio only to roots; a span with a parent copies the parent’s sampled flag. Without ParentBased, a gateway at r=1r = 1 and an engine at r=0.1r = 0.1 produce traces with 90% of their engine spans missing.

The SDK hands every ended, sampled span to a span processor. The batch processor queues it and exports in the background every second (or every 512 spans); the simple processor exports inline, inside span.End(), on the request’s goroutine. With a slow collector, simple turns into request latency. Setup uses batch, and builds no exporter at all when Endpoint is empty: an exporter aimed at the default localhost:4317 retries a refused connection until its timeout every time the process stops.

To read the status code, a middleware wraps the http.ResponseWriter. A wrapper type that embeds http.ResponseWriter exposes only that interface’s three methods: the Flush method of the real writer is hidden, so w.(http.Flusher) fails in the handler and SSE events pile up in a buffer until the handler returns. The wrapper must implement Flush (forwarding it) and Unwrap() http.ResponseWriter (so http.NewResponseController reaches the real writer’s deadlines). On the client side, the response headers arrive long before the last SSE event, so the CLIENT span ends when the body reaches EOF or is closed.

A traced client calls your gateway with the W3C example header:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Parse it (ContextWithTraceparent, the case TestTraceparentHandExample checks): version 00; TT = 4bf92f3577b34da6a3ce929d0e0e4736; caller span SS = 00f067aa0ba902b7; ff = 01, so sampled. Writing the context back (Traceparent) gives the same 55 characters. With flags 00 the string comes back with 00: the decision travels.

The hops. Span ids below are the first 4 bytes; every span is in trace 4bf92f35.

#SpanKindSpan idParentSent onward as traceparent
1POST /v1/chat/completions (gateway, Middleware)SERVERa1b2c3d400f067aa (remote)
2tl.engine.v1.EngineControl/Prefill (gateway, client interceptor)CLIENT51e2f3a4a1b2c3d400-4bf9...-51e2f3a4...-01 in gRPC metadata
3tl.engine.v1.EngineControl/Prefill (prefill engine)SERVER9c8d7e6f51e2f3a4 (remote)
4POST (gateway, Transport)CLIENT77aa88bba1b2c3d400-4bf9...-77aa88bb...-01 in HTTP headers
5POST /v1/chat/completions (decode engine)SERVER0d0e0f1077aa88bb (remote)

Check the rule of 2.1 on each row: every child’s parent is the span that made the call (rows 3 and 5 name the CLIENT spans, not row 1). If the gateway had injected its SERVER span’s id in step 4 (a1b2c3d4), row 5 would hang directly under row 1, beside row 4 instead of under it, and the network time of the call would be unaccounted for. That is mutant s02.

The sampling decision at the gateway, for a root request whose fresh trace id happened to be this one, at r=0.25r = 0.25 and r=0.75r = 0.75:

StepValue
T[8:16]T[8{:}16]a3ce929d0e0e4736
as u6411 803 532 876 627 986 230
shifted right once (xx)5 901 766 438 313 993 115
0.25⋅2630.25 \cdot 2^{63}2 305 843 009 213 693 952, so xx is not below it: dropped
0.75⋅2630.75 \cdot 2^{63}6 917 529 027 641 081 856, so xx is below it: kept

x/263≈0.640x / 2^{63} \approx 0.640: this trace is kept by every ratio above 0.640 and dropped by every ratio below, in every process. Here the request carried -01, so ParentBased skips the ratio entirely and keeps it.

package otelx // go/otelx: provider.go, propagation.go, http.go, grpc.go, log.go
type Config struct {
Endpoint string // [otel].endpoint, OTLP/gRPC, "http://otel-collector.observability:4317"; "" = no exporter
ServiceName string // service.name, "<system>-gateway" (required)
Namespace string // service.namespace, "<system>"
Version string // service.version, [system].version
SampleRatio float64 // [otel].trace_sample_ratio, in [0, 1]
Attributes map[string]string // extra resource attributes, e.g. tl.engine.role
ExportTimeout time.Duration // one export; 0 = 5 s
}
func Setup(ctx context.Context, cfg Config, opts ...sdktrace.TracerProviderOption) (*sdktrace.TracerProvider, error)
func Inject(ctx context.Context, h http.Header)
func Extract(ctx context.Context, h http.Header) context.Context
func Traceparent(ctx context.Context) string // "" without a valid span context
func ContextWithTraceparent(ctx context.Context, s string) context.Context
func IDs(ctx context.Context) (traceID, spanID string, ok bool)
func Middleware(tp trace.TracerProvider, route func(*http.Request) string, next http.Handler) http.Handler
func Transport(tp trace.TracerProvider, base http.RoundTripper) http.RoundTripper
type Span struct{ trace.Span }
func (s Span) SetAttribute(key string, value any)
func StartServer(ctx context.Context, tp trace.TracerProvider, name string) (context.Context, Span)
func UnaryServerInterceptor(tp trace.TracerProvider) grpc.UnaryServerInterceptor
func StreamServerInterceptor(tp trace.TracerProvider) grpc.StreamServerInterceptor
func UnaryClientInterceptor(tp trace.TracerProvider) grpc.UnaryClientInterceptor
func StreamClientInterceptor(tp trace.TracerProvider) grpc.StreamClientInterceptor
func LogHandler(next slog.Handler) slog.Handler // adds trace_id and span_id

Nothing touches the OpenTelemetry globals: each function takes the provider it uses, and propagation is always W3C. The gateway’s chain (gw.01) asks for a server.Tracer; three lines in your entry point adapt StartServer to it:

type tracer struct{ tp trace.TracerProvider }
func (t tracer) Start(ctx context.Context, name string) (context.Context, server.Span) {
return otelx.StartServer(ctx, t.tp, name)
}
TestKINDChecksWhy it matters downstream
TestTraceparentHandExampleunitthe section 3 header parsed, written back, flags kept, a child keeps TTevery hop starts here
TestInvalidTraceparentIsIgnoredboundaryno flags, version ff, zero ids, upper-case hex, 31 digits: no span contexta garbage header must not merge requests
TestMiddlewareContinuesCallerTraceconformancethe SERVER span is the caller’s child; the handler sees it; method, route, statustraced clients (the agent, ol drill evidence)
TestMiddlewareStartsNewTraceWithoutHeaderunitno header gives a root with a valid trace idthe normal case for curl
TestMiddlewareKeepsSSEStreamingunitthe first SSE event reaches the client while the handler still runsTTFT measured through the gateway
TestMiddlewareSupportsResponseControllerunitSetWriteDeadline works through the wrapperwrite deadlines for slow clients
TestMiddlewareStatusAndErrorsboundary200, 404, 499 unset; 500, 503 Error with error.type; nothing written is 200error ratios and red spans mean server faults
TestMiddlewareNamesSpansByRouteunitGET /v1/models/{model} twice, GET without a route, no raw pathspan names stay a small set
TestTransportInjectsItsOwnSpanconformanceCLIENT span under the caller; upstream sees the CLIENT span’s id; request not modifiedthe section 3 table, rows 4 and 5
TestTransportSpanCoversTheStreamedBodyunitthe CLIENT span is still open after the first eventa stream’s duration is its body’s
TestTransportRecordsConnectionErrorsfaultrefused connection: Error and error.typethe failover attempt (gw.05) is visible
TestGRPCUnaryPropagationconformanceCLIENT and SERVER grpc.health.v1.Health/Check, parentage, rpc.*, caller metadata keptEngineControl/Prefill
TestGRPCErrorsMarkSpansboundaryNotFound: both spans Error, code 5, error.type NotFounda prefill pool outage is red
TestGRPCStreamPropagationconformanceWatch stream: SERVER under CLIENTtl.kv.v1 and heartbeat streams
TestServingTreeAcrossHTTPAndGRPCconformancefive spans, one trace, the section 3 treeMS-prod’s trace step
TestStartServerAdapterKeepsAttributeTypesunitint, bool, float, string slice, string, int64 keep their typesthe gateway chain’s attributes
TestSetupResourceAndParentBasedSamplingunitratio 0 drops roots, keeps children of sampled callers; ratio 1 respects unsampled callers; resourcesection 2.4
TestSetupRejectsBadConfigboundaryempty service name, ratio 1.5 or -0.1 rejectedconfig mistakes fail at startup
TestSetupExportsOverOTLPgRPCconformancea fake collector receives the span with service.namethe collector in your cluster
TestSetupWithoutEndpointShutsDownAtOnceboundaryno endpoint: spans still recorded, Shutdown under 1 slocal runs and tests
TestEndingSpansNeverWaitsForTheCollectorfaulta collector that never answers adds no latency to 5 requestsa collector outage is not a gateway outage
TestLogHandlerStampsTraceAndSpanIDsunittrace_id and span_id inside a span, also through With, none outsideobs.02’s kubectl logs | grep
PitfallSymptomCaught by
1. The middleware never calls Extractevery request is a new trace; a traced client’s tree stops at its own spanTestMiddlewareContinuesCallerTrace, TestServingTreeAcrossHTTPAndGRPC (mutant s01)
2. The caller’s context injected instead of the CLIENT span’s, or the caller’s request modifiedthe downstream server is a sibling of the client span; the caller’s http.Request grows a headerTestTransportInjectsItsOwnSpan (mutants s02, s10)
3. The wrapper hides Flush or has no UnwrapSSE arrives in one lump at the end; SetWriteDeadline returns ErrNotSupportedTestMiddlewareKeepsSSEStreaming (mutant s03), TestMiddlewareSupportsResponseController (mutant s14)
4. Raw paths or /-prefixed method names in span namesone span name per model; gRPC names /tl.engine.v1... that match nothing in semconvTestMiddlewareNamesSpansByRoute (mutant s04), TestGRPCUnaryPropagation (mutant s22)
5. Error status on the wrong codes, or none404s page people, or 503s look healthy; failed RPCs and refused connections are greenTestMiddlewareStatusAndErrors (mutants s05, m01, m02), TestGRPCErrorsMarkSpans (mutant s20), TestTransportRecordsConnectionErrors (mutant s25)
6. A ratio sampler without ParentBasedtraces with random holes in the middleTestSetupResourceAndParentBasedSampling (mutant s06)
7. WithSyncer (inline export)every request waits for the collector; a slow collector is a slow gatewayTestEndingSpansNeverWaitsForTheCollector (mutant s07)
8. gRPC metadata not extracted, not injected, or replacedprefill spans start new traces; the caller’s x-request-id disappearsTestGRPCUnaryPropagation (mutants s08, s09, s12), TestGRPCStreamPropagation (mutant s13)
9. Flags always 01, or upper-case hex accepteddownstream records what the caller dropped; ids that other tools rejectTestTraceparentHandExample (mutant s11), TestInvalidTraceparentIsIgnored (mutant s26)
10. Every attribute turned into a stringstatus codes cannot be compared or aggregatedTestStartServerAdapterKeepsAttributeTypes (mutant s15)
11. Endpoint ignored, or an exporter without oneno spans in Tempo; or 5 s hangs at every shutdownTestSetupExportsOverOTLPgRPC (mutant s16), TestSetupWithoutEndpointShutsDownAtOnce (mutant s17)
12. logger.With(...) drops the wrapper, or the ids use OTel’s traceId spellingsome log lines lose their ids; the grep in obs.02 finds nothingTestLogHandlerStampsTraceAndSpanIDs (mutants s18, s23)
13. The CLIENT span ends when the headers arrivea 10 s stream shows as 1 ms; the server span sticks out of its parentTestTransportSpanCoversTheStreamedBody (mutant s19)
14. A thin resource, or no config validationevery service is unknown_service; a 1.5 ratio is accepted silentlyTestSetupResourceAndParentBasedSampling (mutant s21), TestSetupRejectsBadConfig (mutant s24)
DirectionModuleHow it uses this
Backobs.00the tracer’s hand-written export; the same three steps of a hop, by hand
Forwardobs.02your gateway entry point: Setup, the SERVER span through StartServer, LogHandler for JSON logs with ids
Forwarddur.04activity spans from ActivityTask.trace_context via ContextWithTraceparent; TRACEPARENT for Python via Traceparent (dur.09)
Forwardag.03agent.run, agent.llm_call, and agent.tool spans
RelatedL10.7the Rust half: tracing-opentelemetry in the engine joins the same trace from the traceparent your Transport and interceptors send
Your pieceProduction equivalentWhat it addsWhere to look
Middleware, Transportotelhttp (opentelemetry-go-contrib)metrics from the same middleware, span name formatters, filtersinstrumentation/net/http/otelhttp
four gRPC interceptorsotelgrpc stats handlersmessage events, sizes, one handler per connection instead of per callinstrumentation/google.golang.org/grpc/otelgrpc
head sampling with ParentBasedtail sampling in the Collectorkeep every slow or failed trace, sample the fast ones, after the factCollector tailsamplingprocessor
LogHandlerthe OTel log bridge (otelslog)logs exported as OTLP with trace context, into a log backendbridges/otelslog
W3C onlyW3C plus baggagerequest-scoped key-values (tenant, experiment) on every hopW3C Baggage