Unifying Effect and Sentry with OpenTelemetry
What to do when you having missing spans in your Effect code
I recently debugged an issue where nested Sentry spans were missing from traces when Effect functions were calling non-Effect code. The following traces through the context and solution.
The failure
The Effect docs suggest the following integration:
import { NodeSdk } from "@effect/opentelemetry";
import { SentrySpanProcessor } from "@sentry/opentelemetry";
import * as Sentry from "@sentry/node";
Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 1 });
export const NodeSdkLive = NodeSdk.layer(() => ({
spanProcessor: new SentrySpanProcessor(),
}));
Let’s create a test program to repro the issue:
import { Effect } from "effect";
import * as Sentry from "@sentry/node";
const inner = async () =>
Sentry.startSpan({ name: "inner" }, async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
return "hello";
});
const middle = Effect.tryPromise({
try: () => inner(),
catch: (e) => new Error(`failed: ${e}`),
}).pipe(Effect.withSpan("middle"));
const main = Effect.gen(function* () {
return yield* middle;
}).pipe(Effect.withSpan("main"));
await Effect.runPromise(main.pipe(Effect.provide(NodeSdkLive)));
Expected hierarchy in Sentry:
main
└── middle
└── inner
The actual hierarchy we saw logged:
main
└── middle
main -> middle arrives as one transaction, and inner didn’t show up at all!
What’s going on
Sentry’s @sentry/node v10 is an OpenTelemetry SDK under the hood. Sentry.init() constructs a BasicTracerProvider, attaches SentrySampler, SentrySpanProcessor, and SentryPropagator, then registers the result as the OpenTelemetry global:
trace.setGlobalTracerProvider(provider);
context.setGlobalContextManager(new SentryContextManager()); // AsyncLocalStorage
Sentry.startSpan() reads the global tracer, and the inner non-Effect call uses Sentry’s globally-registered provider.
@effect/opentelemetry’s NodeSdk.layer(...) does something different. It builds a fresh NodeTracerProvider and keeps it scoped to the Effect runtime. It never calls provider.register(). The SentrySpanProcessor we pass into it is a second, independent instance, distinct from the one Sentry built during init. Neither processor sees the other’s spans.
When the Effect program runs:
Effect.withSpan("main")creates an OpenTelemetry span on the Effect-privateNodeTracerProvider. The Effect tracer uses the global OpenTelemetry context API to mark the span as active, so AsyncLocalStorage propagates it into nested code.- The span flows out through the Effect-private
SentrySpanProcessor, which posts it to Sentry as a transaction. - Inside
Effect.tryPromise,Sentry.startSpan({ name: "inner" }, ...)runs. Sentry asks its own provider for a tracer, looks at the active context, sees a span ID it does not recognize (different provider), and starts a span without that span as parent. The span flows out through Sentry’s otherSentrySpanProcessor, which posts a separate transaction.
The fix
The published @effect/opentelemetry API ships a layer for this exact case. Tracer.layerGlobal consumes whatever TracerProvider is currently registered as the OpenTelemetry global, instead of constructing a new one. Replace the NodeSdk.layer call:
import { Resource, Tracer } from "@effect/opentelemetry";
import { Layer } from "effect";
export const NodeSdkLive = Tracer.layerGlobal.pipe(
Layer.provide(Resource.layer({ serviceName: "api" })),
);
Effect.withSpan and Sentry.startSpan now both go through Sentry’s BasicTracerProvider. They share one SentrySampler, one SentrySpanProcessor, and one SentryContextManager, so the trace tree composes correctly:
main
└── middle
└── inner
The only ordering requirement is that Sentry.init() runs before any Effect program reads the global tracer. If Sentry.init() runs at module top-level and the server entry point dynamically imports the Effect runtime after, ordering is automatic.
Why this is hard to find in the docs
Unfortunately, documentation is lacking in this area. Effect’s tracing guide only demonstrates NodeSdk.layer(), but doesn’t address the case where OpenTelemetry has already been initialized externally. The @effect/opentelemetry package’s README only points to a generated API reference.
Sentry’s Using Your Existing OpenTelemetry Setup page documents the inverse direction: how to bring our own OpenTelemetry SDK and have Sentry attach to it via skipOpenTelemetrySetup: true, but it doesn’t have a @effect/opentelemetry example. A careful reader can synthesize the fix from the Sentry side (two providers fight) and the Effect type definitions (a layerGlobal exists), but the synthesis is not obvious.
Conclusion
Distributed tracing pays off only when traces are complete. Swapping NodeSdk.layer for Tracer.layerGlobal restores parent-child links across Effect and non-Effect code, so incident investigations follow one trace instead of two disconnected transactions.