-
Notifications
You must be signed in to change notification settings - Fork 930
Description
OpenTelemetry: startActiveSpan
silently fails when callback returns void
This document describes an observed issue where OpenTelemetry spans may be lost when using startActiveSpan
callbacks that do not explicitly return a value. It includes reproduction steps, environment details, impact, root cause analysis, and suggested mitigations.
Summary
When the callback passed to startActiveSpan
returns void
/undefined
(commonly in async Express handlers that don’t return
a response), spans may not be exported, and no warnings or errors are emitted. Returning a value (or properly awaiting and ending the span) avoids the issue.
Environment
- Runtime: Node.js 24.7.0, ESM (package.json:
"type": "module"
) - OpenTelemetry packages (representative versions):
@opentelemetry/api
: ^1.9.0@opentelemetry/sdk-node
: ^0.200.0@opentelemetry/auto-instrumentations-node
: ^0.57.0@opentelemetry/instrumentation-winston
: ^0.48.1@opentelemetry/sdk-metrics
: ^2.0.0
- Platform: Google Cloud Platform (staging)
Expected behavior
One of the following should be true:
- Spans are exported regardless of the callback’s return value; or
- Documentation clearly states callbacks MUST return a value; or
- A runtime warning is emitted when the callback returns
undefined
.
Actual behavior
- Spans can be silently lost when the callback returns
void
/undefined
. - No warnings or errors are logged.
Reproduction
Working (spans appear):
app.get("/working", (req, res) => {
const tracer = trace.getTracer("test-service");
return tracer.startActiveSpan("test-operation", (span) => {
span.setAttributes({ "http.method": req.method });
span.end();
return res.json({ success: true }); // ✅ RETURN here
});
});
Broken (spans missing):
app.get("/old", async (req, res) => {
const tracer = trace.getTracer("dice-service-arthike");
await tracer.startActiveSpan("roll-dice-arthike", async (span) => {
try {
span.setAttributes({
"dice.endpoint": "/roll-dice-new",
"http.method": req.method,
"http.url": req.url,
"arthike.custom": "debugging-span"
});
const result = [1, 2, 5];
span.setAttributes({
"dice.result_count": result.length,
"dice.result_sum": result.reduce((sum, val) => sum + val, 0),
"http.status_code": 200
});
span.setStatus({ code: SpanStatusCode.OK });
res.json(result);
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: "Internal error"
});
res.status(500).send("Internal server error");
} finally {
span.end();
}
});
});
Notes:
- In the “broken” example, the async callback implicitly returns
Promise<void>
. - Depending on the processor and exporter, spans may be dropped or not flushed as expected.
Impact
- Silent failure: Application behaves correctly but observability data is missing.
- Difficult debugging: No warnings/errors indicate the problem.
- Common pattern: Many Express handlers don’t explicitly
return
a response. - Production risk: Missing traces in production environments.
Root cause analysis (high-level)
startActiveSpan
manages context and span lifecycle around a callback. Tooling and type inference may treatPromise<void>
callbacks in a way that ends or flushes spans differently if the return value isn’t propagated or awaited as expected.- If the callback returns
undefined
, some span lifecycle steps may not be properly coordinated with the surrounding context, resulting in spans that are not exported or are dropped.
Suggested solutions (short and long term)
Short-term mitigations you can apply now:
- Always
return
a value from thestartActiveSpan
callback, even in async handlers:await tracer.startActiveSpan("op", async (span) => { try { // ... work ... return true; // or res.json(...) } finally { span.end(); } });
- Prefer
try/finally
to ensurespan.end()
always runs. - Ensure the returned promise is awaited by the caller so the span lifecycle is tied to completion.
Medium/long-term improvement ideas for the codebase or upstream:
- Documentation: Clearly state that callbacks SHOULD return a value and be awaited.
- Runtime warning: Detect
undefined
return and log a warning in development. - API robustness: Make span export independent of callback return value.
- TypeScript types: Tighten callback signatures to encourage/require a return type.
Workarounds and best practices for this repository
- In Express routes, always either:
return res.json(...)
(orreturn next(...)
) from inside thestartActiveSpan
callback; or- Return a value/promise that the caller awaits; and
- Use
try/finally
to end the span reliably.
Example pattern:
app.get("/example", async (req, res) => {
const tracer = trace.getTracer("service");
return tracer.startActiveSpan("handler", async (span) => {
try {
span.setAttribute("http.method", req.method);
// ... your logic ...
return res.json({ ok: true });
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: 2, message: String(err) });
throw err;
} finally {
span.end();
}
});
});
Additional context
- Issue reproduced with both SimpleSpanProcessor and other processors.
- Observed primarily in Express-style handlers, but pattern can affect any callback usage.
- ESM environment, modern Node.js.
Status
This document records the observed behavior and recommended mitigations. If upstream changes or improved types/warnings become available, we should update this file and reference the relevant package versions.