Tracing SSE Events with OpenTelemetry Permalink to this section
Part of Observability & Metrics for SSE, under Backend Stream Generation & Connection Management.
Auto-instrumentation turns an SSE endpoint into one span that stays open for the life of the connection. It is technically a trace and practically useless: it tells you a stream existed, not whether the event a customer is asking about was ever written to their socket.
Symptom & Developer Intent Permalink to this section
- Traces for the stream endpoint show a single span lasting minutes or hours with no children.
- The trace waterfall for a business action stops at “published to bus” — delivery is invisible.
- A customer reports a missing update and there is no way to tell whether it was produced, fanned out, or written.
- Span exporters warn about long-running spans or drop them for exceeding a duration limit.
- Sampling produces either no useful traces or an unaffordable volume.
The intent is one trace per delivered event, rooted in the producer’s trace, ending at the socket write.
Root Cause Analysis Permalink to this section
Distributed tracing models a request as a span tree with a bounded lifetime. SSE breaks two of those assumptions at once.
First, the connection outlives everything that happens on it. A span that covers the whole connection cannot be exported until the connection closes, so it is invisible while it matters and enormous when it finally arrives.
Second, the causal parent of a delivered event is not the request that opened the stream. It is whatever produced the event — an API call, a job, a database change — which happened later and in a different process. Modelling delivery as a child of the connection span records the wrong causality: it says “this stream produced this event”, when the truth is “this API call produced this event, which happened to be delivered on this stream”.
The correct model uses both relationships. The producer’s context is the parent, because that is the causal chain. The connection span is a link, because the stream is context rather than cause. OpenTelemetry supports exactly this distinction, and using it is what makes the resulting traces readable.
Step-by-Step Resolution Permalink to this section
Step 1 — Keep the connection span short Permalink to this section
Cover setup — authentication, subscription, replay — and end the span once the stream is live. Do not hold it open for the connection’s lifetime.
const { trace, context, propagation, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('sse');
app.get('/events', (req, res) => {
const span = tracer.startSpan('sse.connect', {
attributes: {
'sse.topic': req.query.topic,
'sse.resumed': Boolean(req.headers['last-event-id']),
'http.version': req.httpVersion,
},
});
// Keep the span context so delivery spans can LINK to it later.
const connectionCtx = span.spanContext();
try {
authorise(req);
writeHeaders(res);
replayMissed(res, req.headers['last-event-id']);
subscribe(req.query.topic, (event) => deliver(res, event, connectionCtx));
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
} finally {
span.end(); // ends in milliseconds, not hours
}
});
Step 2 — Inject the producer’s context into the event envelope Permalink to this section
The trace context must survive the message bus, so it travels as data alongside the payload.
// Producer: whatever creates the event — an API handler, a job, a change feed.
function publish(topic, payload) {
const carrier = {};
propagation.inject(context.active(), carrier); // W3C traceparent + tracestate
bus.publish(topic, JSON.stringify({
id: nextId(),
createdAt: Date.now(),
trace: carrier, // travels with the event
payload,
}));
}
Step 3 — Start the delivery span as a child of the producer, linked to the connection Permalink to this section
function deliver(res, event, connectionCtx) {
const parentCtx = propagation.extract(context.active(), event.trace);
tracer.startActiveSpan(
'sse.deliver',
{
links: [{ context: connectionCtx }], // the stream is context, not cause
attributes: {
'sse.topic': event.topic,
'sse.event_id': event.id,
'sse.event_type': event.type,
'sse.payload_bytes': Buffer.byteLength(event.json),
'sse.queue_ms': Date.now() - event.createdAt,
},
},
parentCtx, // producer trace continues here
(span) => {
try {
res.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${event.json}\n\n`);
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
} finally {
span.end();
}
},
);
}
Step 4 — Sample so the interesting traces survive Permalink to this section
Head-based sampling at 1% will almost never capture the delivery that failed. Sample low by default and force-keep anything that went wrong.
// Force a sampling decision for the spans that matter regardless of the base rate.
function deliverWithBias(res, event, connectionCtx, sink) {
const forced = sink.queueDepth > sink.capacity * 0.8 || sink.lastWriteFailed;
tracer.startActiveSpan('sse.deliver', {
attributes: { 'sampling.priority': forced ? 1 : 0 },
links: [{ context: connectionCtx }],
}, (span) => { /* … */ });
}
A tail-based sampler in the collector is the cleaner version of the same idea: keep every trace containing an error or a delivery slower than a threshold, sample the rest at a low rate.
Step 5 — Disable auto-instrumentation for the stream route Permalink to this section
Otherwise the framework’s HTTP instrumentation recreates exactly the long span you removed.
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
new HttpInstrumentation({
ignoreIncomingRequestHook: (req) => (req.url ?? '').startsWith('/events'),
});
Validation & Monitoring Permalink to this section
The result to verify is a readable waterfall for a business action, ending at socket writes.
POST /api/orders 42 ms
├─ db.insert order 11 ms
├─ bus.publish orders 2 ms
│ ├─ sse.deliver (event 8821) 1.2 ms ↳ link: sse.connect (conn 4f2a)
│ ├─ sse.deliver (event 8821) 0.9 ms ↳ link: sse.connect (conn 91bd)
│ └─ sse.deliver (event 8821) 1.4 ms ↳ link: sse.connect (conn c30e)
Three deliveries for one publish is the fan-out, visible as siblings. If a subscriber that should have received the event has no delivery span, the gap is between the bus and that node — which is precisely the question that was unanswerable before.
# Confirm the context is actually on the wire in the bus envelope.
redis-cli SUBSCRIBE orders | head -20 | grep -o '"traceparent":"[^"]*"'
# "traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
Worth tracking as derived metrics: delivery span duration p95, the ratio of delivery spans to publish spans (which should equal the subscriber count), and the count of delivery spans ending in error. A ratio below the subscriber count means events are being lost between the bus and some nodes.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why link to the connection span instead of making it the parent?
Because the connection did not cause the event. The API call or job that produced it did, and that is the chain someone debugging a missing update needs to follow. A link records "this delivery happened on that stream" without falsely claiming the stream caused the event — and it keeps the producer's trace intact end to end.
Will one span per delivered event overwhelm my tracing backend?
Yes, at any real volume — which is why the sampling strategy is part of the design rather than an afterthought. Sample delivery spans at a low base rate and force-keep the ones that error or exceed a latency threshold. Metrics carry the volume; traces carry the exceptions.
How does the trace context survive Redis pub/sub or Kafka?
As data. Inject it into the message envelope as a traceparent field before publishing, and extract it on the consuming side. Some Kafka instrumentations do this through headers automatically; Redis pub/sub has no header concept, so the envelope field is the only option.
Should the event payload be a span attribute?
No. Payloads frequently contain personal data, they inflate span size, and most backends truncate long attributes anyway. Record the event id, type and byte size — enough to correlate with your logs and to reason about size, without putting customer data in a tracing system.