History

fix(cloud): install DO WebTracerProvider at module scope so deferred spans export

`DoTelemetryLive` previously built a `WebSdk.layer(...)` per request and provided it under the outer `Effect.runPromise`. WebSdk's layer is scoped — when the request Effect resolved, the layer's scope closed and `SimpleSpanProcessor.shutdown()` ran. Engine and runtime spans created from deferred MCP SDK callbacks (which fire after `transport.handleRequest` has already returned an SSE Response) hit a shut-down exporter and silently failed to ship. The DO has been missing every `executor.code.exec.*` and `executor.runtime.*` span since DO telemetry first started working. Install a `WebTracerProvider` once per DO isolate (lazy on first `DoTelemetryLive` provide) and reduce `DoTelemetryLive` to `OtelTracer.layerGlobal` — same shape the worker side uses. The provider lives for the entire isolate lifetime, so any tracer reference held by captured Effect runtimes still finds a live processor + exporter. Skip `provider.register()` — its StackContextManager / W3C propagator setup wires the global OTel context API, but Effect's tracer only needs the global TracerProvider (looked up via `trace.getTracerProvider()`), not the OTel context machinery.

Rhys Sullivan committed 6 months ago

Commit f2f00781 parent 5e1e0543 files · +77 −27Browse files

Unified · Split

3 files
  1. apps/cloud/package.json +1 −0
  2. apps/cloud/src/services/telemetry.ts +75 −27
  3. bun.lock +1 −0
apps/cloud/package.json+1−0
@@ -50,6 +50,7 @@ · 49 unmodified lines
5050 "@opentelemetry/resources": "^2.6.1",
5151 "@opentelemetry/sdk-logs": "^0.214.0",
5252 "@opentelemetry/sdk-trace-base": "^2.6.1",
53+ "@opentelemetry/sdk-trace-web": "^2.6.1",
5354 "@opentelemetry/semantic-conventions": "^1.40.0",
5455 "@sentry/cloudflare": "^10.48.0",
5556 "@sentry/react": "^10.48.0",
apps/cloud/src/services/telemetry.ts+75−27
@@ -8,17 +8,27 @@ · 7 unmodified lines
88 // `@microlabs/otel-cf-workers`' `instrument(...)` installs in `server.ts`.
99 // Flushing is handled by `instrument()` via `ctx.waitUntil` at request end.
1010 //
11−// - `DoTelemetryLive` (Durable Object path): provisions its own
12−// `WebSdk`-backed tracer via `Effect`. The DO runs in a separate isolate
13−// and we deliberately avoid `instrumentDO` (it wraps DO methods in a way
14−// that breaks `this` binding on `WorkerTransport`'s stream primitives —
15−// every MCP request 500s with "Illegal invocation"). The DO uses a
16−// `SimpleSpanProcessor` so spans export immediately; there's no
17−// `ctx.waitUntil` to rely on for batching. Gated inside a
18−// `Layer.unwrapEffect` so the `AXIOM_TOKEN` check happens at
19−// layer-provide time — `server` is now a lazy Proxy, so this is just
20−// defensive against the token genuinely being unset (dev / missing
21−// secret).
11+// - `DoTelemetryLive` (Durable Object path): the DO runs in a separate
12+// isolate and we deliberately avoid `instrumentDO` (it wraps DO methods
13+// in a way that breaks `this` binding on `WorkerTransport`'s stream
14+// primitives — every MCP request 500s with "Illegal invocation").
15+//
16+// We install a `WebTracerProvider` once per isolate as the global
17+// provider (lazy on first `DoTelemetryLive` provide, not at module
18+// load — `env` from `cloudflare:workers` is reliably populated at
19+// request time but we keep the lazy gate as a defensive cheap no-op).
20+// Once installed, the provider lives for the entire isolate lifetime,
21+// so deferred MCP SDK callbacks — which fire after the request Effect
22+// has resolved — still hit a live `SimpleSpanProcessor` + exporter.
23+//
24+// Previously the WebSdk layer was scoped per-request: when the outer
25+// `Effect.runPromise(...)` resolved, the layer's scope closed and
26+// `processor.shutdown()` ran. Engine / runtime spans created from
27+// deferred SDK callbacks (which captured the old runtime + tracer)
28+// then silently failed to export, even though they showed up in
29+// `Effect.currentSpan` traces during execution. The DO has been
30+// missing every `executor.code.exec.*` and `executor.runtime.*` span
31+// since `DoTelemetryLive` first started shipping spans.
2232 // ---------------------------------------------------------------------------
2333
2434 // Subpath imports — the barrel `@effect/opentelemetry` re-exports `NodeSdk`,
@@ -28,7 +38,7 @@ · 3 unmodified lines
2838 // unused NodeSdk; vitest does not.
2939 import * as Resource from "@effect/opentelemetry/Resource";
3040 import * as OtelTracer from "@effect/opentelemetry/Tracer";
31−import * as WebSdk from "@effect/opentelemetry/WebSdk";
41+import { trace } from "@opentelemetry/api";
3242 // Force the browser platform entry — the package's conditional export would
3343 // otherwise resolve to the Node build, which uses `https.request` / `node:http`.
3444 // Under workerd + unenv's nodejs_compat, `https.request` isn't implemented
@@ -36,28 +46,66 @@ import * as WebSdk from "@effect/opentelemetry/WebSdk"; · 1 unmodified line
3646 // time) and every DO span fails to ship. The browser build uses `fetch()`,
3747 // which workerd does support.
3848 import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http/build/esm/platform/browser/index.js";
49+import { resourceFromAttributes } from "@opentelemetry/resources";
3950 import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
51+import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
52+import {
53+ ATTR_SERVICE_NAME,
54+ ATTR_SERVICE_VERSION,
55+} from "@opentelemetry/semantic-conventions";
4056 import { Effect, Layer } from "effect";
4157
4258 import { server } from "../env";
4359
60+const SERVICE_NAME = "executor-cloud";
61+const SERVICE_VERSION = "1.0.0";
62+
4463 export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe(
45− Layer.provide(Resource.layer({ serviceName: "executor-cloud", serviceVersion: "1.0.0" })),
64+ Layer.provide(Resource.layer({ serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION })),
4665 );
4766
67+// Module-scope: one provider per DO isolate, never shut down. The provider
68+// holds the SimpleSpanProcessor + OTLP exporter, so any tracer reference
69+// the engine/runtime spans hold (via captured Effect runtimes) keeps
70+// finding a live exporter even after the request Effect has resolved.
71+let installed = false;
72+const ensureGlobalTracerProvider = (): boolean => {
73+ if (installed) return true;
74+ if (!server.AXIOM_TOKEN) return false;
75+ const provider = new WebTracerProvider({
76+ resource: resourceFromAttributes({
77+ [ATTR_SERVICE_NAME]: SERVICE_NAME,
78+ [ATTR_SERVICE_VERSION]: SERVICE_VERSION,
79+ }),
80+ spanProcessors: [
81+ new SimpleSpanProcessor(
82+ new OTLPTraceExporter({
83+ url: server.AXIOM_TRACES_URL,
84+ headers: {
85+ Authorization: `Bearer ${server.AXIOM_TOKEN}`,
86+ "X-Axiom-Dataset": server.AXIOM_DATASET,
87+ },
88+ }),
89+ ),
90+ ],
91+ });
92+ // Skip `provider.register()` — its StackContextManager / W3C propagator
93+ // setup wires the global OTel context API, but Effect's tracer goes
94+ // through `OtelTracer.layerGlobal` which only needs the global provider,
95+ // not the OTel context machinery.
96+ trace.setGlobalTracerProvider(provider);
97+ installed = true;
98+ return true;
99+};
100+
48101 export const DoTelemetryLive: Layer.Layer<never> = Layer.unwrapEffect(
49− Effect.sync(() => {
50− if (!server.AXIOM_TOKEN) return Layer.empty;
51− const exporter = new OTLPTraceExporter({
52− url: server.AXIOM_TRACES_URL,
53− headers: {
54− Authorization: `Bearer ${server.AXIOM_TOKEN}`,
55− "X-Axiom-Dataset": server.AXIOM_DATASET,
56− },
57− });
58− return WebSdk.layer(() => ({
59− resource: { serviceName: "executor-cloud", serviceVersion: "1.0.0" },
60− spanProcessor: new SimpleSpanProcessor(exporter),
61− }));
62− }),
102+ Effect.sync(() =>
103+ ensureGlobalTracerProvider()
104+ ? OtelTracer.layerGlobal.pipe(
105+ Layer.provide(
106+ Resource.layer({ serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }),
107+ ),
108+ )
109+ : Layer.empty,
110+ ),
63111 );
bun.lock+1−0
@@ -73,6 +73,7 @@ · 72 unmodified lines
7373 "@opentelemetry/resources": "^2.6.1",
7474 "@opentelemetry/sdk-logs": "^0.214.0",
7575 "@opentelemetry/sdk-trace-base": "^2.6.1",
76+ "@opentelemetry/sdk-trace-web": "^2.6.1",
7677 "@opentelemetry/semantic-conventions": "^1.40.0",
7778 "@sentry/cloudflare": "^10.48.0",
7879 "@sentry/react": "^10.48.0",