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
50 "@opentelemetry/resources": "^2.6.1",50 "@opentelemetry/resources": "^2.6.1",
51 "@opentelemetry/sdk-logs": "^0.214.0",51 "@opentelemetry/sdk-logs": "^0.214.0",
52 "@opentelemetry/sdk-trace-base": "^2.6.1",52 "@opentelemetry/sdk-trace-base": "^2.6.1",
53 "@opentelemetry/sdk-trace-web": "^2.6.1",
53 "@opentelemetry/semantic-conventions": "^1.40.0",54 "@opentelemetry/semantic-conventions": "^1.40.0",
54 "@sentry/cloudflare": "^10.48.0",55 "@sentry/cloudflare": "^10.48.0",
55 "@sentry/react": "^10.48.0",56 "@sentry/react": "^10.48.0",
apps/cloud/src/services/telemetry.ts+75−27
@@ -8,17 +8,27 @@ · 7 unmodified lines
8// `@microlabs/otel-cf-workers`' `instrument(...)` installs in `server.ts`.8// `@microlabs/otel-cf-workers`' `instrument(...)` installs in `server.ts`.
9// Flushing is handled by `instrument()` via `ctx.waitUntil` at request end.9// Flushing is handled by `instrument()` via `ctx.waitUntil` at request end.
10//10//
11// - `DoTelemetryLive` (Durable Object path): provisions its own11// - `DoTelemetryLive` (Durable Object path): the DO runs in a separate
12// `WebSdk`-backed tracer via `Effect`. The DO runs in a separate isolate12// isolate and we deliberately avoid `instrumentDO` (it wraps DO methods
13// and we deliberately avoid `instrumentDO` (it wraps DO methods in a way13// in a way that breaks `this` binding on `WorkerTransport`'s stream
14// that breaks `this` binding on `WorkerTransport`'s stream primitives —14// primitives — every MCP request 500s with "Illegal invocation").
15// every MCP request 500s with "Illegal invocation"). The DO uses a15//
16// `SimpleSpanProcessor` so spans export immediately; there's no16// We install a `WebTracerProvider` once per isolate as the global
17// `ctx.waitUntil` to rely on for batching. Gated inside a17// provider (lazy on first `DoTelemetryLive` provide, not at module
18// `Layer.unwrapEffect` so the `AXIOM_TOKEN` check happens at18// load — `env` from `cloudflare:workers` is reliably populated at
19// layer-provide time — `server` is now a lazy Proxy, so this is just19// request time but we keep the lazy gate as a defensive cheap no-op).
20// defensive against the token genuinely being unset (dev / missing20// Once installed, the provider lives for the entire isolate lifetime,
21// secret).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.
22// ---------------------------------------------------------------------------32// ---------------------------------------------------------------------------
23 33
24// Subpath imports — the barrel `@effect/opentelemetry` re-exports `NodeSdk`,34// Subpath imports — the barrel `@effect/opentelemetry` re-exports `NodeSdk`,
@@ -28,7 +38,7 @@ · 3 unmodified lines
28// unused NodeSdk; vitest does not.38// unused NodeSdk; vitest does not.
29import * as Resource from "@effect/opentelemetry/Resource";39import * as Resource from "@effect/opentelemetry/Resource";
30import * as OtelTracer from "@effect/opentelemetry/Tracer";40import * as OtelTracer from "@effect/opentelemetry/Tracer";
31import * as WebSdk from "@effect/opentelemetry/WebSdk";41import { trace } from "@opentelemetry/api";
32// Force the browser platform entry — the package's conditional export would42// Force the browser platform entry — the package's conditional export would
33// otherwise resolve to the Node build, which uses `https.request` / `node:http`.43// otherwise resolve to the Node build, which uses `https.request` / `node:http`.
34// Under workerd + unenv's nodejs_compat, `https.request` isn't implemented44// 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
36// time) and every DO span fails to ship. The browser build uses `fetch()`,46// time) and every DO span fails to ship. The browser build uses `fetch()`,
37// which workerd does support.47// which workerd does support.
38import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http/build/esm/platform/browser/index.js";48import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http/build/esm/platform/browser/index.js";
49import { resourceFromAttributes } from "@opentelemetry/resources";
39import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";50import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
51import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
52import {
53 ATTR_SERVICE_NAME,
54 ATTR_SERVICE_VERSION,
55} from "@opentelemetry/semantic-conventions";
40import { Effect, Layer } from "effect";56import { Effect, Layer } from "effect";
41 57
42import { server } from "../env";58import { server } from "../env";
43 59
60const SERVICE_NAME = "executor-cloud";
61const SERVICE_VERSION = "1.0.0";
62
44export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe(63export 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 })),
46);65);
47 66
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.
71let installed = false;
72const 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
48export const DoTelemetryLive: Layer.Layer<never> = Layer.unwrapEffect(101export const DoTelemetryLive: Layer.Layer<never> = Layer.unwrapEffect(
49 Effect.sync(() => {102 Effect.sync(() =>
50 if (!server.AXIOM_TOKEN) return Layer.empty;103 ensureGlobalTracerProvider()
51 const exporter = new OTLPTraceExporter({104 ? OtelTracer.layerGlobal.pipe(
52 url: server.AXIOM_TRACES_URL,105 Layer.provide(
53 headers: {106 Resource.layer({ serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }),
54 Authorization: `Bearer ${server.AXIOM_TOKEN}`,107 ),
55 "X-Axiom-Dataset": server.AXIOM_DATASET,108 )
56 },109 : Layer.empty,
57 });110 ),
58 return WebSdk.layer(() => ({
59 resource: { serviceName: "executor-cloud", serviceVersion: "1.0.0" },
60 spanProcessor: new SimpleSpanProcessor(exporter),
61 }));
62 }),
63);111);
bun.lock+1−0
@@ -73,6 +73,7 @@ · 72 unmodified lines
73 "@opentelemetry/resources": "^2.6.1",73 "@opentelemetry/resources": "^2.6.1",
74 "@opentelemetry/sdk-logs": "^0.214.0",74 "@opentelemetry/sdk-logs": "^0.214.0",
75 "@opentelemetry/sdk-trace-base": "^2.6.1",75 "@opentelemetry/sdk-trace-base": "^2.6.1",
76 "@opentelemetry/sdk-trace-web": "^2.6.1",
76 "@opentelemetry/semantic-conventions": "^1.40.0",77 "@opentelemetry/semantic-conventions": "^1.40.0",
77 "@sentry/cloudflare": "^10.48.0",78 "@sentry/cloudflare": "^10.48.0",
78 "@sentry/react": "^10.48.0",79 "@sentry/react": "^10.48.0",