-
Notifications
You must be signed in to change notification settings - Fork 957
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
returna response. - Production risk: Missing traces in production environments.
Root cause analysis (high-level)
startActiveSpanmanages 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
returna value from thestartActiveSpancallback, even in async handlers:await tracer.startActiveSpan("op", async (span) => { try { // ... work ... return true; // or res.json(...) } finally { span.end(); } });
- Prefer
try/finallyto 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
undefinedreturn 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 thestartActiveSpancallback; or- Return a value/promise that the caller awaits; and
- Use
try/finallyto 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.