What happens
When plain JavaScript code is already inside an OpenTelemetry span, and it runs an Effect program that calls Effect.withSpan, the Effect span starts a new trace. It should become a child of the OpenTelemetry span that is already active.
The OtelTracer docs say parentage "is taken from Effect spans first and can also attach to the active OpenTelemetry context". That fallback exists, but it never runs from normal code.
This matters when Effect runs inside code instrumented by something else, for example a library that creates its own OpenTelemetry spans and then calls into Effect.
Reproduction
effect@4.0.0-rc.117, @effect/opentelemetry@4.0.0-rc.117, @opentelemetry/* 2.11.0, Node 24.
import { context, trace } from "@opentelemetry/api"
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
import { Effect, Layer } from "effect"
import * as OtelTracer from "@effect/opentelemetry/OtelTracer"
import * as Resource from "@effect/opentelemetry/Resource"
const exporter = new InMemorySpanExporter()
context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable())
trace.setGlobalTracerProvider(new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }))
const Live = Layer.provide(OtelTracer.layerGlobal, Resource.layer({ serviceName: "t" }))
const outer = trace.getTracer("plain").startSpan("otel-outer")
await context.with(trace.setSpan(context.active(), outer), () =>
Effect.runPromise(Effect.void.pipe(Effect.withSpan("child"), Effect.provide(Live))))
outer.end()
const child = exporter.getFinishedSpans().find((s) => s.name === "child")
console.log("same trace:", child.spanContext().traceId === outer.spanContext().traceId)
console.log("parent:", child.parentSpanContext?.spanId, "expected:", outer.spanContext().spanId)
Output:
same trace: false
parent: undefined expected: 5f23378c868c8821
Expected: same trace: true, and the parent is otel-outer.
Why
packages/effect/src/internal/effect.ts (line 5961 at rc.117) creates spans with root: options?.root ?? Option.isNone(parent). With no Effect parent span, root is always true.
packages/opentelemetry/src/OtelTracer.ts (lines 421-424) only looks at the active OpenTelemetry context when root !== true, so it never does.
- The existing test (
packages/opentelemetry/test/OtelTracer.test.ts, around line 165) reaches the fallback only by building OtelSpan directly with root: false, not through Effect.withSpan.
In Effect 3 (checked on 3.19.0) this worked by default, because core did not set root itself. So it looks like a regression in v4.
Workarounds that work today
Effect.withSpan("child", { root: false }) on the outermost Effect span. Same setup as above, and the span then has the right parent.
OtelTracer.withSpanContext(outer.spanContext()), or Effect.withParentSpan(OtelTracer.makeExternalSpan(outer.spanContext())). Both need the OpenTelemetry span passed in by hand.
Possible fix
Only set root: true when the caller asks for it (for example root: options?.root === true), and add a test that goes through Effect.withSpan inside context.with(...).
Related: #6343 (a similar symptom with annotateCurrentSpan), and #6902, which fixed other parts of this same fallback.
Context
Found while building mastra-effect, an Effect v4 HTTP server adapter for Mastra. Mastra's code runs inside plain JavaScript under an Effect request, which is exactly this situation.
What happens
When plain JavaScript code is already inside an OpenTelemetry span, and it runs an Effect program that calls
Effect.withSpan, the Effect span starts a new trace. It should become a child of the OpenTelemetry span that is already active.The
OtelTracerdocs say parentage "is taken from Effect spans first and can also attach to the active OpenTelemetry context". That fallback exists, but it never runs from normal code.This matters when Effect runs inside code instrumented by something else, for example a library that creates its own OpenTelemetry spans and then calls into Effect.
Reproduction
effect@4.0.0-rc.117,@effect/opentelemetry@4.0.0-rc.117,@opentelemetry/*2.11.0, Node 24.Output:
Expected:
same trace: true, and the parent isotel-outer.Why
packages/effect/src/internal/effect.ts(line 5961 at rc.117) creates spans withroot: options?.root ?? Option.isNone(parent). With no Effect parent span,rootis alwaystrue.packages/opentelemetry/src/OtelTracer.ts(lines 421-424) only looks at the active OpenTelemetry context whenroot !== true, so it never does.packages/opentelemetry/test/OtelTracer.test.ts, around line 165) reaches the fallback only by buildingOtelSpandirectly withroot: false, not throughEffect.withSpan.In Effect 3 (checked on 3.19.0) this worked by default, because core did not set
rootitself. So it looks like a regression in v4.Workarounds that work today
Effect.withSpan("child", { root: false })on the outermost Effect span. Same setup as above, and the span then has the right parent.OtelTracer.withSpanContext(outer.spanContext()), orEffect.withParentSpan(OtelTracer.makeExternalSpan(outer.spanContext())). Both need the OpenTelemetry span passed in by hand.Possible fix
Only set
root: truewhen the caller asks for it (for exampleroot: options?.root === true), and add a test that goes throughEffect.withSpaninsidecontext.with(...).Related: #6343 (a similar symptom with
annotateCurrentSpan), and #6902, which fixed other parts of this same fallback.Context
Found while building mastra-effect, an Effect v4 HTTP server adapter for Mastra. Mastra's code runs inside plain JavaScript under an Effect request, which is exactly this situation.