History

Correlate Sentry errors with OTel traces (#1320)

* Correlate Sentry errors with OTel traces * Default the Sentry OTel verification flags on in the e2e cloud stack

Rhys Sullivan committed 4 months ago

Commit 4a84b0d1 parent dfbc10413 files · +382 −40Browse files

Unified · Split

13 files
  1. apps/cloud/src/api/error-response.ts +21 −8
  2. apps/cloud/src/engine/execution-gate.ts +7 −5
  3. apps/cloud/src/engine/execution-rate-limit.ts +7 −5
  4. apps/cloud/src/env-augment.d.ts +2 −0
  5. apps/cloud/src/extensions/billing/route.ts +6 −2
  6. apps/cloud/src/extensions/billing/service.ts +4 −3
  7. apps/cloud/src/mcp/session-durable-object.ts +15 −1
  8. apps/cloud/src/observability/index.ts +114 −3
  9. apps/cloud/src/observability/observability.test.ts +54 −2
  10. apps/cloud/src/server.ts +30 −0
  11. e2e/cloud/sentry-otel-correlation.test.ts +70 −0
  12. e2e/setup/cloud.globalsetup.ts +16 −1
  13. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +36 −10
apps/cloud/src/api/error-response.ts+21−8
@@ -1,7 +1,7 @@
11 import { Cause, Data, Effect, Predicate, Result } from "effect";
22 import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
33
4−import { captureCause } from "../observability";
4+import { captureCause, captureCauseEffect } from "../observability";
55
66 // Implements `Respondable` so the framework's default cause→response
77 // pipeline (`HttpServerRespondable.toResponseOrElse`) renders this as the
@@ -13,13 +13,14 @@ export class HttpResponseError extends Data.TaggedError("HttpResponseError")<{ · 5 unmodified lines
1313 readonly message: string;
1414 }> {
1515 [HttpServerRespondable.symbol](): Effect.Effect<HttpServerResponse.HttpServerResponse> {
16− if (this.status >= 500) captureCause(this);
17− return Effect.succeed(
18− HttpServerResponse.jsonUnsafe(
19− { error: this.message, code: this.code },
20− { status: this.status },
21− ),
22− );
16+ const self = this;
17+ return Effect.gen(function* () {
18+ if (self.status >= 500) yield* captureCauseEffect(self);
19+ return HttpServerResponse.jsonUnsafe(
20+ { error: self.message, code: self.code },
21+ { status: self.status },
22+ );
23+ });
2324 }
2425 }
2526
@@ -65,3 +66,15 @@ export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpSe · 39 unmodified lines
6566 { status: mapped.status },
6667 );
6768 };
69+
70+export const toErrorServerResponseEffect = (
71+ error: unknown,
72+): Effect.Effect<HttpServerResponse.HttpServerResponse> =>
73+ Effect.gen(function* () {
74+ const mapped = toHttpResponseError(error);
75+ if (mapped.status >= 500) yield* captureCauseEffect(mapped);
76+ return HttpServerResponse.jsonUnsafe(
77+ { error: mapped.message, code: mapped.code },
78+ { status: mapped.status },
79+ );
80+ });
apps/cloud/src/engine/execution-gate.ts+7−5
@@ -24,12 +24,12 @@ · 23 unmodified lines
2424 // `CodeCompilationError` / `SandboxRuntimeError` into `ExecuteResult.error`.
2525 // ---------------------------------------------------------------------------
2626
27−import * as Sentry from "@sentry/cloudflare";
2827 import { Data, Effect } from "effect";
2928 import type * as Cause from "effect/Cause";
3029
3130 import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
3231
32+import { captureCauseEffect } from "../observability";
3333 import { EXECUTION_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages";
3434
3535 // The engine's completed-result payload (`ExecuteResult` in codemode-core),
@@ -166,10 +166,12 @@ export const makeExecutionLimitGate = (checkBalance: ExecutionBalanceCheck) => { · 130 unmodified lines
166166 // must never block executions. Reported like `trackExecution` so a
167167 // billing outage still pages; the error outcome is never cached.
168168 Effect.catch((error: unknown) =>
169− Effect.sync((): GateDecision => {
170− console.warn("[billing] execution balance check failed open:", error);
171− Sentry.captureException(error);
172− return { blocked: false };
169+ Effect.gen(function* () {
170+ yield* Effect.sync(() => {
171+ console.warn("[billing] execution balance check failed open:", error);
172+ });
173+ yield* captureCauseEffect(error);
174+ return { blocked: false } as const satisfies GateDecision;
173175 }),
174176 ),
175177 );
apps/cloud/src/engine/execution-rate-limit.ts+7−5
@@ -14,12 +14,12 @@ · 13 unmodified lines
1414 // ---------------------------------------------------------------------------
1515
1616 import { DurableObject, env } from "cloudflare:workers";
17−import * as Sentry from "@sentry/cloudflare";
1817 import { Data, Effect } from "effect";
1918 import type * as Cause from "effect/Cause";
2019
2120 import type { ExecutionEngine } from "@executor-js/execution";
2221
22+import { captureCauseEffect } from "../observability";
2323 import { withPreExecutionGate, type GateDecision } from "./execution-gate";
2424 import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages";
2525
@@ -149,10 +149,12 @@ export const makeExecutionRateLimiter = ( · 123 unmodified lines
149149 // FAIL OPEN: the backstop must never block executions because its
150150 // counter is unreachable or slow.
151151 Effect.catch((error: unknown) =>
152− Effect.sync((): GateDecision => {
153− console.warn("[rate-limit] execution rate limit check failed open:", error);
154− Sentry.captureException(error);
155− return { blocked: false };
152+ Effect.gen(function* () {
153+ yield* Effect.sync(() => {
154+ console.warn("[rate-limit] execution rate limit check failed open:", error);
155+ });
156+ yield* captureCauseEffect(error);
157+ return { blocked: false } as const satisfies GateDecision;
156158 }),
157159 ),
158160 );
apps/cloud/src/env-augment.d.ts+2−0
@@ -11,6 +11,8 @@ declare global { · 10 unmodified lines
1111 AXIOM_TRACES_URL?: string;
1212 AXIOM_TRACES_SAMPLE_RATIO?: string;
1313 SENTRY_DSN?: string;
14+ SENTRY_OTEL_LOG_PAYLOAD?: string;
15+ SENTRY_OTEL_VERIFY?: string;
1416 VITE_PUBLIC_SENTRY_DSN?: string;
1517 VITE_PUBLIC_POSTHOG_KEY?: string;
1618 VITE_PUBLIC_POSTHOG_HOST?: string;
apps/cloud/src/extensions/billing/route.ts+6−2
@@ -5,7 +5,11 @@ import { autumnHandler } from "autumn-js/backend"; · 4 unmodified lines
55
66 import { WorkOSClient } from "../../auth/workos";
77 import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../../auth/organization";
8−import { HttpResponseError, isServerError, toErrorServerResponse } from "../../api/error-response";
8+import {
9+ HttpResponseError,
10+ isServerError,
11+ toErrorServerResponseEffect,
12+} from "../../api/error-response";
913
1014 type BillingSession = {
1115 readonly userId: string;
@@ -111,7 +115,7 @@ const handler = Effect.gen(function* () { · 99 unmodified lines
111115 if (isServerError(err)) {
112116 console.error("[autumn] request failed:", Cause.pretty(err));
113117 }
114− return Effect.succeed(toErrorServerResponse(err));
118+ return toErrorServerResponseEffect(err);
115119 }),
116120 );
117121
apps/cloud/src/extensions/billing/service.ts+4−3
@@ -3,10 +3,11 @@ · 2 unmodified lines
33 // ---------------------------------------------------------------------------
44
55 import { env } from "cloudflare:workers";
6−import * as Sentry from "@sentry/cloudflare";
76 import { Autumn } from "autumn-js";
87 import { Context, Data, Effect, Layer } from "effect";
98
9+import { captureCauseEffect } from "../../observability";
10+
1011 // ---------------------------------------------------------------------------
1112 // Errors
1213 // ---------------------------------------------------------------------------
@@ -72,12 +73,12 @@ const make = Effect.sync(() => { · 59 unmodified lines
7273 ).pipe(
7374 Effect.catchTag("AutumnError", (error) =>
7475 Effect.gen(function* () {
75− // Silent billing data loss is worth paging on — autumn.trackExecution
76+ // Silent billing data loss is worth paging on: autumn.trackExecution
7677 // is fire-and-forget so the caller doesn't handle it themselves.
7778 yield* Effect.sync(() => {
7879 console.error("[billing] track failed:", error);
79− Sentry.captureException(error);
8080 });
81+ yield* captureCauseEffect(error);
8182 yield* Effect.annotateCurrentSpan({ "autumn.track.failed": true });
8283 }),
8384 ),
apps/cloud/src/mcp/session-durable-object.ts+15−1
@@ -68,7 +68,11 @@ import { makeExecutionStack } from "../engine/execution-stack"; · 67 unmodified lines
6868 import { CloudMeteredExecutionStackLayer } from "../engine/execution-stack-metered";
6969 import { AutumnService } from "../extensions/billing/service";
7070 import { DoTelemetryLive, flushTracerProvider } from "../observability/telemetry";
71−import { captureCause as reportCause } from "../observability";
71+import {
72+ captureCause as reportCause,
73+ captureCauseEffect as reportCauseEffect,
74+ tagCurrentSentryScopeWithCurrentOtelSpan,
75+} from "../observability";
7276
7377 // Re-export the shared types so existing cloud importers
7478 // (`auth/handlers.ts`, etc.) keep their `../mcp/session-durable-object` path.
@@ -314,6 +318,16 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 239 unmodified lines
314318 reportCause(cause);
315319 }
316320
321+ protected override captureCauseEffect(
322+ cause: Cause.Cause<unknown>,
323+ ): Effect.Effect<string | undefined> {
324+ return reportCauseEffect(cause);
325+ }
326+
327+ protected override prepareErrorCaptureScope(): Effect.Effect<void> {
328+ return Effect.asVoid(tagCurrentSentryScopeWithCurrentOtelSpan);
329+ }
330+
317331 // Best-effort export the DO isolate's buffered spans after the RPC settles,
318332 // so a dying init/handleRequest can ship its own spans (and the exception +
319333 // stack recorded on them) — not just the worker-side `mcp.do.*` span. Keep it
apps/cloud/src/observability/index.ts+114−3
@@ -11,7 +11,9 @@ · 10 unmodified lines
1111 // ---------------------------------------------------------------------------
1212
1313 import * as Sentry from "@sentry/cloudflare";
14+import type { ErrorEvent, Scope } from "@sentry/cloudflare";
1415 import { Cause, Effect, Layer } from "effect";
16+import type * as Tracer from "effect/Tracer";
1517
1618 import { ErrorCapture } from "@executor-js/api";
1719
@@ -21,12 +23,106 @@ import { ErrorCapture } from "@executor-js/api"; · 3 unmodified lines
2123 // error. Sentry still receives the full, untruncated cause via
2224 // `setExtra`; only the dev-console mirror is capped.
2325 const MAX_CONSOLE_CAUSE_CHARS = 4_000;
26+const OTEL_TRACE_ID_PATTERN = /^[0-9a-f]{32}$/;
27+const OTEL_SPAN_ID_PATTERN = /^[0-9a-f]{16}$/;
28+
29+export const OTEL_TRACE_ID_TAG = "otel_trace_id";
30+export const OTEL_SPAN_ID_TAG = "otel_span_id";
31+export const SENTRY_EVENT_ID_ATTRIBUTE = "sentry.event_id";
32+
33+export type OtelCorrelationContext = {
34+ readonly traceId: string;
35+ readonly spanId: string;
36+};
2437
2538 const truncate = (s: string): string =>
2639 s.length <= MAX_CONSOLE_CAUSE_CHARS
2740 ? s
2841 : `${s.slice(0, MAX_CONSOLE_CAUSE_CHARS)}\n…[truncated ${s.length - MAX_CONSOLE_CAUSE_CHARS} chars]`;
2942
43+const validOtelContext = (context: OtelCorrelationContext): boolean =>
44+ OTEL_TRACE_ID_PATTERN.test(context.traceId) && OTEL_SPAN_ID_PATTERN.test(context.spanId);
45+
46+export const otelCorrelationContextFromEffectSpan = (
47+ span: Tracer.Span,
48+): OtelCorrelationContext | null => {
49+ const context = { traceId: span.traceId, spanId: span.spanId };
50+ return validOtelContext(context) ? context : null;
51+};
52+
53+export const otelCorrelationContextFromOpenTelemetrySpan = (span: {
54+ readonly spanContext: () => { readonly traceId: string; readonly spanId: string };
55+}): OtelCorrelationContext | null => {
56+ const { traceId, spanId } = span.spanContext();
57+ const context = { traceId, spanId };
58+ return validOtelContext(context) ? context : null;
59+};
60+
61+export const addOtelCorrelationTags = <T extends { readonly tags?: Record<string, unknown> }>(
62+ event: T,
63+ context: OtelCorrelationContext | null,
64+): T => {
65+ if (!context) return event;
66+ return {
67+ ...event,
68+ tags: {
69+ ...event.tags,
70+ [OTEL_TRACE_ID_TAG]: context.traceId,
71+ [OTEL_SPAN_ID_TAG]: context.spanId,
72+ },
73+ };
74+};
75+
76+export const tagSentryScopeWithOtelContext = (
77+ scope: Scope,
78+ context: OtelCorrelationContext | null,
79+): void => {
80+ if (!context) return;
81+ scope.setTag(OTEL_TRACE_ID_TAG, context.traceId);
82+ scope.setTag(OTEL_SPAN_ID_TAG, context.spanId);
83+};
84+
85+export const tagCurrentSentryScopeWithOtelContext = (
86+ context: OtelCorrelationContext | null,
87+): void => {
88+ tagSentryScopeWithOtelContext(Sentry.getCurrentScope(), context);
89+};
90+
91+const currentOtelContext = Effect.map(
92+ Effect.currentSpan,
93+ otelCorrelationContextFromEffectSpan,
94+).pipe(Effect.orElseSucceed(() => null));
95+
96+export const tagCurrentSentryScopeWithCurrentOtelSpan: Effect.Effect<OtelCorrelationContext | null> =
97+ Effect.map(currentOtelContext, (context) => {
98+ tagCurrentSentryScopeWithOtelContext(context);
99+ return context;
100+ });
101+
102+export const beforeSendWithOtelCorrelation = (
103+ event: ErrorEvent,
104+ options?: { readonly logPayload?: boolean },
105+): ErrorEvent => {
106+ if (options?.logPayload) {
107+ console.info(
108+ JSON.stringify({
109+ event: "sentry_before_send_otel_correlation",
110+ sentry_event_id: event.event_id ?? "",
111+ otel_trace_id: String(event.tags?.[OTEL_TRACE_ID_TAG] ?? ""),
112+ otel_span_id: String(event.tags?.[OTEL_SPAN_ID_TAG] ?? ""),
113+ }),
114+ );
115+ }
116+ return event;
117+};
118+
119+export const addCurrentOtelCorrelationTags = <
120+ T extends { readonly tags?: Record<string, unknown> },
121+>(
122+ event: T,
123+): Effect.Effect<T> =>
124+ Effect.map(currentOtelContext, (context) => addOtelCorrelationTags(event, context));
125+
30126 // Sentry's `captureException` can't serialize Effect's `CauseImpl` (it logs
31127 // `'CauseImpl' captured as exception with keys: reasons, ~effect/Cause` and
32128 // drops the real failure). `Cause.squash` isn't enough on its own: when an
@@ -48,21 +144,36 @@ export const sentryPayloadForCause = ( · 15 unmodified lines
48144 return { primary: input, pretty: null };
49145 };
50146
51−export const captureCause = (input: unknown): string | undefined => {
147+export const captureCause = (
148+ input: unknown,
149+ context: OtelCorrelationContext | null = null,
150+): string | undefined => {
52151 const { primary, pretty } = sentryPayloadForCause(input);
152+ tagCurrentSentryScopeWithOtelContext(context);
53153 return Sentry.captureException(primary, (scope) => {
154+ tagSentryScopeWithOtelContext(scope, context);
54155 if (pretty !== null) scope.setExtra("cause", pretty);
55156 return scope;
56157 });
57158 };
58159
160+export const captureCauseEffect = (input: unknown): Effect.Effect<string | undefined> =>
161+ Effect.gen(function* () {
162+ const context = yield* tagCurrentSentryScopeWithCurrentOtelSpan;
163+ const eventId = yield* Effect.sync(() => captureCause(input, context));
164+ if (eventId && context) {
165+ yield* Effect.annotateCurrentSpan(SENTRY_EVENT_ID_ATTRIBUTE, eventId);
166+ }
167+ return eventId;
168+ });
169+
59170 export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
60171 ErrorCapture,
61172 ErrorCapture.of({
62173 captureException: (cause) =>
63− Effect.sync(() => {
174+ Effect.gen(function* () {
64175 console.error("[api] unhandled cause:", truncate(Cause.pretty(cause)));
65− return captureCause(cause) ?? "";
176+ return (yield* captureCauseEffect(cause)) ?? "";
66177 }),
67178 }),
68179 );
apps/cloud/src/observability/observability.test.ts+54−2
@@ -1,7 +1,13 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Cause } from "effect";
2+import { Cause, Effect } from "effect";
3+import type * as Tracer from "effect/Tracer";
34
4−import { sentryPayloadForCause } from "./index";
5+import {
6+ addCurrentOtelCorrelationTags,
7+ OTEL_SPAN_ID_TAG,
8+ OTEL_TRACE_ID_TAG,
9+ sentryPayloadForCause,
10+} from "./index";
511
612 // Mirrors Sentry core's `is.isError`: it picks the proper-Error path iff
713 // `Object.prototype.toString.call(x) === "[object Error]"`. Anything that
@@ -10,6 +16,39 @@ import { sentryPayloadForCause } from "./index"; · 2 unmodified lines
1016 const looksLikeErrorToSentry = (value: unknown): boolean =>
1117 Object.prototype.toString.call(value) === "[object Error]";
1218
19+const traceId = "4268a606000000000000000000000000";
20+const spanId = "1234567890abcdef";
21+
22+const makeFixedTracer = (): Tracer.Tracer => ({
23+ span: (options) => {
24+ const attributes = new Map<string, unknown>();
25+ let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
26+ return {
27+ _tag: "Span",
28+ name: options.name,
29+ spanId,
30+ traceId,
31+ parent: options.parent,
32+ annotations: options.annotations,
33+ get status() {
34+ return status;
35+ },
36+ attributes,
37+ links: options.links,
38+ sampled: options.sampled,
39+ kind: options.kind,
40+ end: (endTime, exit) => {
41+ status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
42+ },
43+ attribute: (key, value) => {
44+ attributes.set(key, value);
45+ },
46+ event: () => undefined,
47+ addLinks: () => undefined,
48+ };
49+ },
50+});
51+
1352 describe("sentryPayloadForCause", () => {
1453 it("hands Sentry a real Error when the defect is itself a Cause", () => {
1554 // Reproduces the production chain: an inner runPromise rejects with a
@@ -39,3 +78,16 @@ describe("sentryPayloadForCause", () => { · 23 unmodified lines
3978 expect(pretty).toBeNull();
4079 });
4180 });
81+
82+describe("Sentry OTel correlation", () => {
83+ it.effect("adds tags from the active Effect span", () =>
84+ Effect.gen(function* () {
85+ const baseEvent: { readonly tags: Record<string, unknown> } = { tags: { existing: "tag" } };
86+ const event = yield* addCurrentOtelCorrelationTags(baseEvent);
87+
88+ expect(event.tags.existing).toBe("tag");
89+ expect(event.tags[OTEL_TRACE_ID_TAG]).toBe(traceId);
90+ expect(event.tags[OTEL_SPAN_ID_TAG]).toBe(spanId);
91+ }).pipe(Effect.withSpan("test.sentry_capture"), Effect.withTracer(makeFixedTracer())),
92+ );
93+});
apps/cloud/src/server.ts+30−0
@@ -1,5 +1,6 @@
11 import { DurableObject } from "cloudflare:workers";
22 import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";
3+import type { ErrorEvent } from "@sentry/cloudflare";
34 import {
45 ATTR_HTTP_REQUEST_METHOD,
56 ATTR_HTTP_RESPONSE_STATUS_CODE,
@@ -14,6 +15,13 @@ import { isAppOwnedPath } from "./app-paths"; · 8 unmodified lines
1415 import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";
1516 import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
1617 import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";
18+import {
19+ beforeSendWithOtelCorrelation,
20+ captureCause,
21+ otelCorrelationContextFromOpenTelemetrySpan,
22+ SENTRY_EVENT_ID_ATTRIBUTE,
23+ tagCurrentSentryScopeWithOtelContext,
24+} from "./observability";
1725 import { browserTracesResponse } from "./observability/browser-traces";
1826 import { flushTracerProvider, installTracerProvider } from "./observability/telemetry";
1927
@@ -27,6 +35,10 @@ const sentryOptions = (env: Env) => ({ · 7 unmodified lines
2735 enableLogs: true,
2836 sendDefaultPii: true,
2937 skipOpenTelemetrySetup: true,
38+ beforeSend: (event: ErrorEvent) =>
39+ beforeSendWithOtelCorrelation(event, {
40+ logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true",
41+ }),
3042 // NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype
3143 // and reads every property — including accessors — to find methods to wrap,
3244 // which invokes the `sessionId` getter with `this` bound to the prototype
@@ -158,6 +170,8 @@ const cloudflareHandler: ExportedHandler<Env> = { · 125 unmodified lines
158170 { kind: SpanKind.SERVER },
159171 parentContext,
160172 async (span) => {
173+ const otelContext = otelCorrelationContextFromOpenTelemetrySpan(span);
174+ tagCurrentSentryScopeWithOtelContext(otelContext);
161175 span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method);
162176 span.setAttribute(ATTR_URL_FULL, request.url);
163177 span.setAttribute(ATTR_URL_PATH, url.pathname);
@@ -168,6 +182,22 @@ const cloudflareHandler: ExportedHandler<Env> = { · 4 unmodified lines
168182 // outer wrapper still captures the exception; we only mark span status.
169183 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary
170184 try {
185+ if (env.SENTRY_OTEL_VERIFY === "true" && url.pathname === "/__sentry-otel-verify") {
186+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: synthetic verification needs an Error payload for Sentry grouping
187+ const eventId = captureCause(new Error("sentry otel verification"), otelContext) ?? "";
188+ if (eventId) span.setAttribute(SENTRY_EVENT_ID_ATTRIBUTE, eventId);
189+ span.setAttribute("sentry_otel.verify", true);
190+ span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, 500);
191+ span.setStatus({ code: SpanStatusCode.ERROR });
192+ return Response.json(
193+ {
194+ sentryEventId: eventId,
195+ otelTraceId: otelContext?.traceId ?? "",
196+ otelSpanId: otelContext?.spanId ?? "",
197+ },
198+ { status: 500 },
199+ );
200+ }
171201 const response = await fetchHandler(request, env, ctx);
172202 span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
173203 if (response.status >= 500) {
e2e/cloud/sentry-otel-correlation.test.ts+70−0
@@ -0,0 +1,70 @@
1+import { readFileSync } from "node:fs";
2+import { resolve } from "node:path";
3+
4+import { expect } from "@effect/vitest";
5+import { Effect, Schedule } from "effect";
6+
7+import { RUNS_DIR, scenario } from "../src/scenario";
8+import { Target, Telemetry } from "../src/services";
9+
10+type VerificationResponse = {
11+ readonly sentryEventId: string;
12+ readonly otelTraceId: string;
13+ readonly otelSpanId: string;
14+};
15+
16+const beforeSendLogFile = resolve(RUNS_DIR, "cloud", "server-logs", "boot.log");
17+
18+const hasCorrelationPayload = (line: string, body: VerificationResponse): boolean =>
19+ line.includes('"event":"sentry_before_send_otel_correlation"') &&
20+ line.includes(`"sentry_event_id":"${body.sentryEventId}"`) &&
21+ line.includes(`"otel_trace_id":"${body.otelTraceId}"`) &&
22+ line.includes(`"otel_span_id":"${body.otelSpanId}"`);
23+
24+const findBeforeSendPayload = (body: VerificationResponse): Effect.Effect<string, string> =>
25+ Effect.sync(() => {
26+ const text = readFileSync(beforeSendLogFile, "utf8");
27+ return text
28+ .split("\n")
29+ .slice()
30+ .reverse()
31+ .find((line) => hasCorrelationPayload(line, body));
32+ }).pipe(
33+ Effect.filterOrFail(
34+ (line): line is string => line !== undefined,
35+ () =>
36+ `no Sentry beforeSend correlation payload matched ${body.sentryEventId} in ${beforeSendLogFile}`,
37+ ),
38+ Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(20))),
39+ );
40+
41+scenario(
42+ "Telemetry · Sentry error payload joins to exported OTel span",
43+ { timeout: 120_000 },
44+ Effect.gen(function* () {
45+ const target = yield* Target;
46+ const telemetry = yield* Telemetry;
47+
48+ const response = yield* Effect.promise(() =>
49+ fetch(new URL("/__sentry-otel-verify", target.baseUrl)),
50+ );
51+ expect(response.status).toBe(500);
52+
53+ const body = (yield* Effect.promise(() => response.json())) as VerificationResponse;
54+ expect(body.sentryEventId).toMatch(/^[0-9a-f]{32}$/);
55+ expect(body.otelTraceId).toMatch(/^[0-9a-f]{32}$/);
56+ expect(body.otelSpanId).toMatch(/^[0-9a-f]{16}$/);
57+
58+ const beforeSendLine = yield* findBeforeSendPayload(body);
59+ expect(beforeSendLine).toContain(body.otelTraceId);
60+
61+ const span = yield* telemetry.expectSpan({
62+ traceId: body.otelTraceId,
63+ attributes: {
64+ "sentry.event_id": body.sentryEventId,
65+ "sentry_otel.verify": "true",
66+ },
67+ });
68+ expect(span.span.spanId).toBe(body.otelSpanId);
69+ }),
70+);
e2e/setup/cloud.globalsetup.ts+16−1
@@ -25,6 +25,21 @@ const bootLogFile = process.env.E2E_VERBOSE · 24 unmodified lines
2525 ? undefined
2626 : resolve(RUNS_DIR, "cloud", "server-logs", "boot.log");
2727
28+const optionalCloudEnv = (): Record<string, string> => {
29+ // The Sentry/OTel correlation scenario always runs in this suite, so the
30+ // verification route and beforeSend payload logging default on here; the
31+ // env vars remain overridable for local runs against a different setup.
32+ const env: Record<string, string> = {
33+ SENTRY_OTEL_VERIFY: "true",
34+ SENTRY_OTEL_LOG_PAYLOAD: "true",
35+ };
36+ for (const key of ["SENTRY_DSN", "SENTRY_OTEL_LOG_PAYLOAD", "SENTRY_OTEL_VERIFY"]) {
37+ const value = process.env[key];
38+ if (value) env[key] = value;
39+ }
40+ return env;
41+};
42+
2843 export default async function setup(): Promise<(() => Promise<void>) | void> {
2944 if (process.env.E2E_CLOUD_URL) {
3045 await waitForHttp(process.env.E2E_CLOUD_URL);
@@ -74,7 +89,7 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 43 unmodified lines
7489 // endpoint-agnostic, so the same layer that ships prod traces to
7590 // Axiom ships e2e traces to the suite store — "why was that page
7691 // slow" gets a span waterfall, not a guess.
77− extraEnv: motelExporterEnv(motel, publicUrl),
92+ extraEnv: { ...motelExporterEnv(motel, publicUrl), ...optionalCloudEnv() },
7893 });
7994 return { teardown: cloud.teardown, value: cloud };
8095 },
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+36−10
@@ -236,6 +236,17 @@ export abstract class McpAgentSessionDOBase< · 235 unmodified lines
236236
237237 protected captureCause(_cause: Cause.Cause<unknown>): void {}
238238
239+ protected captureCauseEffect(cause: Cause.Cause<unknown>): Effect.Effect<string | undefined> {
240+ return Effect.sync(() => {
241+ this.captureCause(cause);
242+ return undefined;
243+ });
244+ }
245+
246+ protected prepareErrorCaptureScope(): Effect.Effect<void> {
247+ return Effect.void;
248+ }
249+
239250 protected flushTelemetry(): Promise<void> {
240251 return Promise.resolve();
241252 }
@@ -604,6 +615,7 @@ export abstract class McpAgentSessionDOBase< · 362 unmodified lines
604615 }
605616 const self = this;
606617 const program = Effect.gen(function* () {
618+ yield* self.prepareErrorCaptureScope();
607619 const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session);
608620 const dbHandle = yield* self.openSessionDbHandle();
609621 const { mcpServer, engine } = yield* self.buildRuntime(sessionMeta, dbHandle);
@@ -618,7 +630,7 @@ export abstract class McpAgentSessionDOBase< · 8 unmodified lines
618630 Effect.tapCause((cause) =>
619631 Effect.gen(function* () {
620632 console.error("[mcp-session] init failed:", Cause.pretty(cause));
621− self.captureCause(cause);
633+ yield* self.captureCauseEffect(cause);
622634 yield* self.recordCauseOnSpan(cause);
623635 }),
624636 ),
@@ -650,6 +662,7 @@ export abstract class McpAgentSessionDOBase< · 25 unmodified lines
650662 const self = this;
651663 return Effect.runPromise(
652664 Effect.gen(function* () {
665+ yield* self.prepareErrorCaptureScope();
653666 const sessionMeta = yield* self.loadSessionMeta();
654667 if (!sessionMeta) return "not_found" as const;
655668 if (self.initialized) {
@@ -681,6 +694,7 @@ export abstract class McpAgentSessionDOBase< · 25 unmodified lines
681694 const self = this;
682695 return Effect.runPromise(
683696 Effect.gen(function* () {
697+ yield* self.prepareErrorCaptureScope();
684698 const owner = yield* self.validateApprovalIdentity(identity);
685699 if (owner !== "ok") return { status: owner } as const;
686700
@@ -718,6 +732,7 @@ export abstract class McpAgentSessionDOBase< · 31 unmodified lines
718732 const self = this;
719733 return Effect.runPromise(
720734 Effect.gen(function* () {
735+ yield* self.prepareErrorCaptureScope();
721736 const owner = yield* self.validateApprovalIdentity(identity);
722737 if (owner === "forbidden") return { status: "execution_forbidden" } as const;
723738 if (owner === "not_found") {
@@ -775,6 +790,7 @@ export abstract class McpAgentSessionDOBase< · 51 unmodified lines
775790 const self = this;
776791 return Effect.runPromise(
777792 Effect.gen(function* () {
793+ yield* self.prepareErrorCaptureScope();
778794 const owner = yield* self.validateApprovalIdentity(identity);
779795 if (owner !== "ok") return { status: owner } as const;
780796
@@ -1015,6 +1031,7 @@ export abstract class McpAgentSessionDOBase< · 234 unmodified lines
10151031 ): Effect.Effect<void> {
10161032 const self = this;
10171033 return Effect.gen(function* () {
1034+ yield* self.prepareErrorCaptureScope();
10181035 if (self.pendingApprovalLeases.has(executionId)) return;
10191036
10201037 yield* Effect.promise(() => self.markActivity()).pipe(
@@ -1031,9 +1048,14 @@ export abstract class McpAgentSessionDOBase< · 10 unmodified lines
10311048 attributes: { "mcp.execution.id": executionId },
10321049 }),
10331050 Effect.tapCause((cause) =>
1034− Effect.sync(() => {
1035− console.error("[mcp-session] pending approval lease start failed:", Cause.pretty(cause));
1036− self.captureCause(cause);
1051+ Effect.gen(function* () {
1052+ yield* Effect.sync(() => {
1053+ console.error(
1054+ "[mcp-session] pending approval lease start failed:",
1055+ Cause.pretty(cause),
1056+ );
1057+ });
1058+ yield* self.captureCauseEffect(cause);
10371059 }),
10381060 ),
10391061 Effect.ignore,
@@ -1048,16 +1070,19 @@ export abstract class McpAgentSessionDOBase< · 8 unmodified lines
10481070 }
10491071
10501072 private queuePendingApprovalLeaseExpiration(executionId: string): void {
1073+ const self = this;
10511074 this.ctx.waitUntil(
10521075 Effect.runPromise(
10531076 this.expirePendingApproval(executionId).pipe(
10541077 Effect.tapCause((cause) =>
1055− Effect.sync(() => {
1056− console.error(
1057− "[mcp-session] pending approval lease expiration failed:",
1058− Cause.pretty(cause),
1059− );
1060− this.captureCause(cause);
1078+ Effect.gen(function* () {
1079+ yield* Effect.sync(() => {
1080+ console.error(
1081+ "[mcp-session] pending approval lease expiration failed:",
1082+ Cause.pretty(cause),
1083+ );
1084+ });
1085+ yield* self.captureCauseEffect(cause);
10611086 }),
10621087 ),
10631088 Effect.ignore,
@@ -1134,6 +1159,7 @@ export abstract class McpAgentSessionDOBase< · 70 unmodified lines
11341159 private expirePendingApproval(executionId: string): Effect.Effect<void> {
11351160 const self = this;
11361161 return Effect.gen(function* () {
1162+ yield* self.prepareErrorCaptureScope();
11371163 const lease = self.pendingApprovalLeases.get(executionId);
11381164 if (!lease || lease.expiring) return;
11391165 lease.expiring = true;