import { DurableObject } from "cloudflare:workers";import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";import type { ErrorEvent } from "@sentry/cloudflare";import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_URL_FULL, ATTR_URL_PATH, ATTR_URL_SCHEME,} from "@opentelemetry/semantic-conventions";import * as Sentry from "@sentry/cloudflare";import handler from "@tanstack/react-start/server-entry"; import { isAppOwnedPath } from "./app-paths";import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";import { parseTraceparent } from "./mcp/traceparent";import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";import { beforeSendWithOtelCorrelation, captureCause, otelCorrelationContextFromOpenTelemetrySpan, SENTRY_EVENT_ID_ATTRIBUTE, tagCurrentSentryScopeWithOtelContext,} from "./observability";import { browserTracesResponse } from "./observability/browser-traces";import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; // ---------------------------------------------------------------------------// Sentry config// --------------------------------------------------------------------------- const sentryOptions = (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, enableLogs: true, sendDefaultPii: true, skipOpenTelemetrySetup: true, beforeSend: (event: ErrorEvent) => beforeSendWithOtelCorrelation(event, { logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true", }), // NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype // and reads every property — including accessors — to find methods to wrap, // which invokes the `sessionId` getter with `this` bound to the prototype // (where `ctx` is undefined) and throws during construction, 500ing every // session create / cold restore. The DO captures its own errors via the // `captureCause` seam (→ Sentry) instead.}); // ---------------------------------------------------------------------------// Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the// client inside the DO isolate, which plain `Sentry.captureException` cannot// do on its own). OTEL is installed through Effect layers (observability/telemetry),// not a global fetch wrapper.// --------------------------------------------------------------------------- export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( sentryOptions, McpSessionDOBase,); // Orphaned placeholder for the original key-value `McpSessionDO` class (migration// v1). The live MCP session DO is now `McpSessionDOSqlite` (SQLite); the// `MCP_SESSION` binding moved to it. Cloudflare won't delete `McpSessionDO` in the// same deploy that moves its binding, so the class is left unbound and is kept// exported here only to satisfy the migration. It can be removed in a later deploy// (with a `deleted_classes: ["McpSessionDO"]` migration) now that nothing binds it.export class McpSessionDO extends DurableObject {} // Per-org execution rate-limit counter DO (abuse backstop; migration v3,// `EXECUTION_RATE_LIMITER` binding). Plain counter, no Sentry wrapper needed:// its callers already fail open and report errors themselves.export { ExecutionRateLimiterDO } from "./engine/execution-rate-limit"; export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execution-owner-directory"; // ---------------------------------------------------------------------------// Worker fetch handler//// We open a single `http.server <METHOD>` span at the worker boundary using// the same WebTracerProvider that `observability/telemetry.ts` already installs for// Effect-driven spans. This restores the per-request envelope span that was// previously emitted by `@microlabs/otel-cf-workers` and lost in the alchemy// 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// until the in-flight export resolves.// --------------------------------------------------------------------------- const fetchHandler = handler.fetch as ( request: Request, env: Env, ctx: ExecutionContext,) => Response | Promise<Response>; const tracer = trace.getTracer("executor-cloud-worker");const mcpAgentHandler = makeCloudMcpAgentHandler(); const cloudflareHandler: ExportedHandler<Env> = { fetch: async (request, env, ctx) => { // Browser OTLP ingress — before the server span opens: exporter traffic // must never trace itself (the browser already excludes /v1/traces from // its own tracing for the same reason). const browserTraces = browserTracesResponse(request, env); if (browserTraces) return browserTraces; // The MCP dispatch is classified up front, independent of whether // telemetry installs — an unset `AXIOM_TOKEN` (tracer not installed) must // never take /mcp requests down with it. See `installTracerProvider`'s // early return below: it only governs the tracing envelope for // non-MCP paths. const url = new URL(request.url); const mcpRoute = classifyMcpPath(url.pathname); const tracingInstalled = installTracerProvider(); // 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 // in session-durable-object.ts. const inbound = parseTraceparent(request.headers.get("traceparent"), null); const parentContext = inbound ? trace.setSpanContext(context.active(), { traceId: inbound.traceId, spanId: inbound.spanId, traceFlags: inbound.traceFlags, isRemote: true, }) : context.active(); if (mcpRoute?.kind === "mcp") { // The Cloudflare Agents MCP bridge needs the platform ExecutionContext // to pass authenticated session props into the hibernatable DO. // Discovery docs still flow through the app-level MCP envelope. const forwarded = prepareMcpOrgScope(request); if (!tracingInstalled) { return mcpAgentHandler(forwarded, env, ctx); } // /mcp left the Effect app in the Agents-bridge migration, so no // downstream HttpMiddleware.tracer opens the request envelope anymore — // this worker span is now THE `http.server` span for MCP traffic. Its // context is stamped onto the forwarded request's traceparent so the // agent handler's Effect programs (mcp.request and children) and the // session DO parent under it instead of exporting orphaned roots. return tracer.startActiveSpan( `http.server ${request.method}`, { kind: SpanKind.SERVER }, parentContext, async (span) => { span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); span.setAttribute(ATTR_URL_FULL, request.url); span.setAttribute(ATTR_URL_PATH, url.pathname); span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); const spanContext = span.spanContext(); const headers = new Headers(forwarded.headers); headers.set( "traceparent", `00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`, ); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep trace export alive after the Agents bridge resolves or rejects try { const response = await mcpAgentHandler(new Request(forwarded, { headers }), env, ctx); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); if (response.status >= 500) { span.setStatus({ code: SpanStatusCode.ERROR }); } return response; } catch (err) { span.setStatus({ code: SpanStatusCode.ERROR }); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime throw err; } finally { span.end(); ctx.waitUntil(flushTracerProvider()); } }, ); } if (!tracingInstalled) { return fetchHandler(request, env, ctx); } // 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()); } } return tracer.startActiveSpan( `http.server ${request.method}`, { kind: SpanKind.SERVER }, parentContext, async (span) => { const otelContext = otelCorrelationContextFromOpenTelemetrySpan(span); tagCurrentSentryScopeWithOtelContext(otelContext); span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); span.setAttribute(ATTR_URL_FULL, request.url); span.setAttribute(ATTR_URL_PATH, url.pathname); span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); // Adapter boundary: Cloudflare's fetch handler is a Promise-based // callback and the OTel span lifecycle needs to observe both the // resolved response and any thrown error before `span.end()`. Sentry's // outer wrapper still captures the exception; we only mark span status. // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary try { if (env.SENTRY_OTEL_VERIFY === "true" && url.pathname === "/__sentry-otel-verify") { // oxlint-disable-next-line executor/no-error-constructor -- boundary: synthetic verification needs an Error payload for Sentry grouping const eventId = captureCause(new Error("sentry otel verification"), otelContext) ?? ""; if (eventId) span.setAttribute(SENTRY_EVENT_ID_ATTRIBUTE, eventId); span.setAttribute("sentry_otel.verify", true); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, 500); span.setStatus({ code: SpanStatusCode.ERROR }); return Response.json( { sentryEventId: eventId, otelTraceId: otelContext?.traceId ?? "", otelSpanId: otelContext?.spanId ?? "", }, { status: 500 }, ); } const response = await fetchHandler(request, env, ctx); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); if (response.status >= 500) { span.setStatus({ code: SpanStatusCode.ERROR }); } return response; } catch (err) { span.setStatus({ code: SpanStatusCode.ERROR }); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime throw err; } finally { span.end(); ctx.waitUntil(flushTracerProvider()); } }, ); },}; export default Sentry.withSentry(sentryOptions, cloudflareHandler);
import { DurableObject } from "cloudflare:workers";
import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";
import type { ErrorEvent } from "@sentry/cloudflare";
import {
ATTR_HTTP_REQUEST_METHOD,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_URL_FULL,
ATTR_URL_PATH,
ATTR_URL_SCHEME,
} from "@opentelemetry/semantic-conventions";
import * as Sentry from "@sentry/cloudflare";
import handler from "@tanstack/react-start/server-entry";
import { isAppOwnedPath } from "./app-paths";
import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";
import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
import { parseTraceparent } from "./mcp/traceparent";
import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";
import {
beforeSendWithOtelCorrelation,
captureCause,
otelCorrelationContextFromOpenTelemetrySpan,
SENTRY_EVENT_ID_ATTRIBUTE,
tagCurrentSentryScopeWithOtelContext,
} from "./observability";
import { browserTracesResponse } from "./observability/browser-traces";
import { flushTracerProvider, installTracerProvider } from "./observability/telemetry";
// ---------------------------------------------------------------------------
// Sentry config
// ---------------------------------------------------------------------------
const sentryOptions = (env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 0,
enableLogs: true,
sendDefaultPii: true,
skipOpenTelemetrySetup: true,
beforeSend: (event: ErrorEvent) =>
beforeSendWithOtelCorrelation(event, {
logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true",
}),
// NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype
// and reads every property — including accessors — to find methods to wrap,
// which invokes the `sessionId` getter with `this` bound to the prototype
// (where `ctx` is undefined) and throws during construction, 500ing every
// session create / cold restore. The DO captures its own errors via the
// `captureCause` seam (→ Sentry) instead.
});
// ---------------------------------------------------------------------------
// Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the
// client inside the DO isolate, which plain `Sentry.captureException` cannot
// do on its own). OTEL is installed through Effect layers (observability/telemetry),
// not a global fetch wrapper.
// ---------------------------------------------------------------------------
export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry(
sentryOptions,
McpSessionDOBase,
);
// Orphaned placeholder for the original key-value `McpSessionDO` class (migration
// v1). The live MCP session DO is now `McpSessionDOSqlite` (SQLite); the
// `MCP_SESSION` binding moved to it. Cloudflare won't delete `McpSessionDO` in the
// same deploy that moves its binding, so the class is left unbound and is kept
// exported here only to satisfy the migration. It can be removed in a later deploy
// (with a `deleted_classes: ["McpSessionDO"]` migration) now that nothing binds it.
export class McpSessionDO extends DurableObject {}
// Per-org execution rate-limit counter DO (abuse backstop; migration v3,
// `EXECUTION_RATE_LIMITER` binding). Plain counter, no Sentry wrapper needed:
// its callers already fail open and report errors themselves.
export { ExecutionRateLimiterDO } from "./engine/execution-rate-limit";
export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execution-owner-directory";
// ---------------------------------------------------------------------------
// Worker fetch handler
//
// We open a single `http.server <METHOD>` span at the worker boundary using
// the same WebTracerProvider that `observability/telemetry.ts` already installs for
// Effect-driven spans. This restores the per-request envelope span that was
// previously emitted by `@microlabs/otel-cf-workers` and lost in the alchemy
// 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
// until the in-flight export resolves.
// ---------------------------------------------------------------------------
const fetchHandler = handler.fetch as (
request: Request,
env: Env,
ctx: ExecutionContext,
) => Response | Promise<Response>;
const tracer = trace.getTracer("executor-cloud-worker");
const mcpAgentHandler = makeCloudMcpAgentHandler();
const cloudflareHandler: ExportedHandler<Env> = {
fetch: async (request, env, ctx) => {
// Browser OTLP ingress — before the server span opens: exporter traffic
// must never trace itself (the browser already excludes /v1/traces from
// its own tracing for the same reason).
const browserTraces = browserTracesResponse(request, env);
if (browserTraces) return browserTraces;
// The MCP dispatch is classified up front, independent of whether
// telemetry installs — an unset `AXIOM_TOKEN` (tracer not installed) must
// never take /mcp requests down with it. See `installTracerProvider`'s
// early return below: it only governs the tracing envelope for
// non-MCP paths.
const url = new URL(request.url);
const mcpRoute = classifyMcpPath(url.pathname);
const tracingInstalled = installTracerProvider();
// 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
// in session-durable-object.ts.
const inbound = parseTraceparent(request.headers.get("traceparent"), null);
const parentContext = inbound
? trace.setSpanContext(context.active(), {
traceId: inbound.traceId,
spanId: inbound.spanId,
traceFlags: inbound.traceFlags,
isRemote: true,
})
: context.active();
if (mcpRoute?.kind === "mcp") {
// The Cloudflare Agents MCP bridge needs the platform ExecutionContext
// to pass authenticated session props into the hibernatable DO.
// Discovery docs still flow through the app-level MCP envelope.
const forwarded = prepareMcpOrgScope(request);
if (!tracingInstalled) {
return mcpAgentHandler(forwarded, env, ctx);
}
// /mcp left the Effect app in the Agents-bridge migration, so no
// downstream HttpMiddleware.tracer opens the request envelope anymore —
// this worker span is now THE `http.server` span for MCP traffic. Its
// context is stamped onto the forwarded request's traceparent so the
// agent handler's Effect programs (mcp.request and children) and the
// session DO parent under it instead of exporting orphaned roots.
return tracer.startActiveSpan(
`http.server ${request.method}`,
{ kind: SpanKind.SERVER },
parentContext,
async (span) => {
span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method);
span.setAttribute(ATTR_URL_FULL, request.url);
span.setAttribute(ATTR_URL_PATH, url.pathname);
span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, ""));
const spanContext = span.spanContext();
const headers = new Headers(forwarded.headers);
headers.set(
"traceparent",
`00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`,
);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep trace export alive after the Agents bridge resolves or rejects
try {
const response = await mcpAgentHandler(new Request(forwarded, { headers }), env, ctx);
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
if (response.status >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
return response;
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR });
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime
throw err;
} finally {
span.end();
ctx.waitUntil(flushTracerProvider());
}
},
);
}
if (!tracingInstalled) {
return fetchHandler(request, env, ctx);
}
// 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());
}
}
return tracer.startActiveSpan(
`http.server ${request.method}`,
{ kind: SpanKind.SERVER },
parentContext,
async (span) => {
const otelContext = otelCorrelationContextFromOpenTelemetrySpan(span);
tagCurrentSentryScopeWithOtelContext(otelContext);
span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method);
span.setAttribute(ATTR_URL_FULL, request.url);
span.setAttribute(ATTR_URL_PATH, url.pathname);
span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, ""));
// Adapter boundary: Cloudflare's fetch handler is a Promise-based
// callback and the OTel span lifecycle needs to observe both the
// resolved response and any thrown error before `span.end()`. Sentry's
// outer wrapper still captures the exception; we only mark span status.
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary
try {
if (env.SENTRY_OTEL_VERIFY === "true" && url.pathname === "/__sentry-otel-verify") {
// oxlint-disable-next-line executor/no-error-constructor -- boundary: synthetic verification needs an Error payload for Sentry grouping
const eventId = captureCause(new Error("sentry otel verification"), otelContext) ?? "";
if (eventId) span.setAttribute(SENTRY_EVENT_ID_ATTRIBUTE, eventId);
span.setAttribute("sentry_otel.verify", true);
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, 500);
span.setStatus({ code: SpanStatusCode.ERROR });
return Response.json(
{
sentryEventId: eventId,
otelTraceId: otelContext?.traceId ?? "",
otelSpanId: otelContext?.spanId ?? "",
},
{ status: 500 },
);
}
const response = await fetchHandler(request, env, ctx);
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
if (response.status >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
return response;
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR });
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime
throw err;
} finally {
span.end();
ctx.waitUntil(flushTracerProvider());
}
},
);
},
};
export default Sentry.withSentry(sentryOptions, cloudflareHandler);