From 60a39bf58e9bf2c5e8907606bf7d6616b989aae9 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Fri, 12 Jun 2026 11:29:24 -0700 Subject: [PATCH] One http.server span per request: the worker defers to Effect's tracer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every API/MCP request produced TWO identical sibling http.server spans: the worker-boundary span server.ts opens (scope executor-cloud-worker) AND the one Effect's HttpMiddleware.tracer opens for app-owned paths (scope executor-cloud) — both joined the caller's traceparent, so the waterfall showed a childless twin next to the real envelope, and server span ingest was doubled (visible in Axiom: 39.7k executor-cloud vs 24.9k executor-cloud-worker spans on /mcp alone). The worker now skips its span for app-owned paths (app-paths.ts: /api/*, /mcp, /.well-known/*) — Effect's middleware already parses traceparent and parents the workos/store/db children there. Non-app paths (Start SSR, marketing proxy, /_astro assets) keep the worker envelope span, and the flush-on-waitUntil still runs on both branches so batched exports survive the isolate. Verified against the suite motel: API scenario + browser scenario runs now record exactly one http.server per trace (worker scope only on / and SSR routes), browser→server join intact (executor-web client span → executor-cloud server span → workos/user_store/fumadb children); cloud observability unit tests green. Found via the prod Axiom dataset right after #981 made browser traces land — the duplicate was previously invisible without the join. --- apps/cloud/src/server.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 7bdd7bace..226f364e6 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -9,6 +9,7 @@ import { import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; +import { isAppOwnedPath } from "./app-paths"; import { McpSessionDO as McpSessionDOBase } from "./mcp/session-durable-object"; import { browserTracesResponse } from "./observability/browser-traces"; import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; @@ -53,6 +54,16 @@ export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( // migration — without the OTel-SDK version-conflict that package would now // drag in (it pins `@opentelemetry/otlp-* ^0.200.0`, we ship ^0.214.0). // +// ONLY for paths the Effect app does not own. App-owned paths (/api/*, /mcp, +// /.well-known/* — see app-paths.ts) get their `http.server` span from +// Effect's own HttpMiddleware.tracer, which parses `traceparent` itself and +// parents the workos/store/db child spans. Wrapping those here too produced +// two identical sibling `http.server` spans per request (scope +// `executor-cloud-worker` next to scope `executor-cloud`) — double ingest, +// and the waterfall showed a childless twin. The worker span remains for +// everything Effect never sees: Start SSR, the marketing proxy, /_astro +// assets. +// // SimpleSpanProcessor exports synchronously at span end but the underlying // `fetch()` to Axiom is fire-and-forget; the Worker may terminate before it // completes. `ctx.waitUntil(flushTracerProvider())` keeps the isolate alive @@ -78,6 +89,18 @@ const cloudflareHandler: ExportedHandler = { return fetchHandler(request, env, ctx); } const url = new URL(request.url); + // Effect-served paths bring their own http.server span (with traceparent + // join) — opening one here too would duplicate it. See the header note. + if (isAppOwnedPath(url.pathname)) { + // The provider is installed (above) and the flush still must outlive + // the request — Effect's BatchSpanProcessor ships on a timer. + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; mirror the traced path's finally + try { + return await fetchHandler(request, env, ctx); + } finally { + ctx.waitUntil(flushTracerProvider()); + } + } // Join the caller's W3C trace when the request carries one — the web UI // sends traceparent on every API fetch, so the browser's spans and this // request share one trace id end to end. Same parsing the DO path does