History

feat(cloud): wire Sentry error capture for HTTP + MCP DO

HTTP path: TanStack Start + our Effect error handlers swallow throws and return 500 responses, so `Sentry.withSentry` (which only sees uncaught exceptions) never captures them. Adds explicit `Sentry.captureException` in the places that turn errors into 500s: - `toErrorServerResponse` / `toErrorResponse` for the Effect API paths - `handleMcpRequest_POST`'s catch for the MCP proxy path DO path: wraps McpSessionDO with `Sentry.instrumentDurableObjectWithSentry`. Unlike otel-cf-workers' `instrumentDO` (which broke `this` binding on WorkerTransport's stream primitives), Sentry's wrapper uses `Reflect.apply` and is safe. Sets `instrumentPrototypeMethods: true` because the DO's `init` / `handleRequest` live on the prototype — the default auto-wrap only visits own properties, which misses them, and errors thrown inside `init()` never reach Sentry without this flag. Verified end-to-end: debug smoke tests captured both an explicit `captureException` call (HTTP path) and a DO init() throw, both landing in Sentry issues on the node-cloudflare-workers project. Debug endpoints removed before merge.

Rhys Sullivan committed 7 months ago

Commit ef5eb1b1 parent c0e64f93 files · +48 −30Browse files

Unified · Split

3 files
  1. apps/cloud/src/api/error-response.ts +6 −1
  2. apps/cloud/src/mcp.ts +2 −0
  3. apps/cloud/src/server.ts +40 −29
apps/cloud/src/api/error-response.ts+6−1
@@ -1,3 +1,4 @@
1+import * as Sentry from "@sentry/cloudflare";
12 import { Data } from "effect";
23 import { HttpServerResponse } from "@effect/platform";
34
@@ -20,12 +21,16 @@ export const isServerError = (error: unknown): boolean => toHttpResponseError(er · 16 unmodified lines
2021
2122 export const toErrorResponse = (error: unknown): Response => {
2223 const mapped = toHttpResponseError(error);
24+ if (mapped.status >= 500) Sentry.captureException(error);
2325 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });
2426 };
2527
2628 export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {
27− console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error);
2829 const mapped = toHttpResponseError(error);
30+ if (mapped.status >= 500) {
31+ console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error);
32+ Sentry.captureException(error);
33+ }
2934 return HttpServerResponse.unsafeJson(
3035 { error: mapped.message, code: mapped.code },
3136 { status: mapped.status },
apps/cloud/src/mcp.ts+2−0
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 // ---------------------------------------------------------------------------
44
55 import { env } from "cloudflare:workers";
6+import * as Sentry from "@sentry/cloudflare";
67 import { createRemoteJWKSet, jwtVerify } from "jose";
78
89 import { server } from "./env";
@@ -130,6 +131,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 121 unmodified lines
130131 return await stub.handleRequest(request);
131132 } catch (err) {
132133 console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err);
134+ Sentry.captureException(err);
133135 return jsonRpcError(500, -32603, "Internal server error");
134136 }
135137 };
apps/cloud/src/server.ts+40−29
@@ -2,25 +2,16 @@ import * as Sentry from "@sentry/cloudflare"; · 1 unmodified line
22 import handler from "@tanstack/react-start/server-entry";
33 import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
44
5−// ---------------------------------------------------------------------------
6−// DO export
7−// ---------------------------------------------------------------------------
8−// Telemetry for the DO is handled from inside the DO via a self-contained
9−// WebSdk layer (see `services/telemetry.ts#DoTelemetryLive`). We deliberately
10−// do NOT wrap the DO class with `instrumentDO` from `otel-cf-workers` — it
11−// breaks `this` binding on `WorkerTransport`'s streaming primitives and the
12−// whole MCP session 500s with "Illegal invocation" DOMExceptions.
13−//
14−// Sentry's DO wrapper has the same failure mode in our setup, so DO errors
15−// are captured manually via `Sentry.captureException` inside the DO's catch
16−// blocks rather than by class wrapping.
17−// ---------------------------------------------------------------------------
18−
19−export { McpSessionDO } from "./mcp-session";
5+import { McpSessionDO as McpSessionDOBase } from "./mcp-session";
206
217 // ---------------------------------------------------------------------------
228 // OTEL config for the main fetch handler — `otel-cf-workers` owns the global
239 // TracerProvider and flushes via `ctx.waitUntil` at the end of each request.
10+// The DO runs in a separate isolate and uses its own self-contained WebSdk
11+// (see `services/telemetry.ts#DoTelemetryLive`); `instrumentDO` from
12+// otel-cf-workers is NOT used because it breaks `this` binding on
13+// `WorkerTransport`'s stream primitives and crashes every MCP request with
14+// DOMException "Illegal invocation".
2415 // ---------------------------------------------------------------------------
2516
2617 type OtelEnv = {
@@ -39,19 +30,39 @@ const resolveOtelConfig: ResolveConfigFn<OtelEnv> = (env) => ({ · 12 unmodified lines
3930 },
4031 });
4132
42−const instrumentedHandler = instrument({ fetch: handler.fetch }, resolveOtelConfig);
33+// otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim
34+// registers a ProxyTracerProvider of its own, which prevents otel-cf-workers
35+// from finding its WorkerTracer and breaks the whole request path with
36+// "global tracer is not of type WorkerTracer".
37+const sentryOptions = (env: Env) => ({
38+ dsn: (env as unknown as { SENTRY_DSN?: string }).SENTRY_DSN,
39+ tracesSampleRate: 0,
40+ enableLogs: true,
41+ sendDefaultPii: true,
42+ skipOpenTelemetrySetup: true,
43+ // Our DO methods (init/handleRequest/alarm) live on the prototype, not on
44+ // the instance. Sentry's default DO auto-wrap only visits own properties,
45+ // which misses prototype methods — so errors thrown inside init() never
46+ // reach Sentry. This flag opts into prototype-method instrumentation.
47+ instrumentPrototypeMethods: true,
48+});
4349
44−export default Sentry.withSentry(
45− (env: Record<string, string>) => ({
46− dsn: env.SENTRY_DSN,
47− tracesSampleRate: 0,
48− enableLogs: true,
49− sendDefaultPii: true,
50− // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat
51− // shim registers a ProxyTracerProvider of its own, which prevents
52− // otel-cf-workers from finding its WorkerTracer and breaks the whole
53− // request path with "global tracer is not of type WorkerTracer".
54− skipOpenTelemetrySetup: true,
55− }),
56− instrumentedHandler,
50+// ---------------------------------------------------------------------------
51+// Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the
52+// client inside the DO isolate, which plain `Sentry.captureException` cannot
53+// do on its own). We deliberately do NOT wrap with otel-cf-workers'
54+// `instrumentDO` (see note above).
55+// ---------------------------------------------------------------------------
56+
57+export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry(
58+ sentryOptions,
59+ McpSessionDOBase,
5760 );
61+
62+// ---------------------------------------------------------------------------
63+// Worker fetch handler
64+// ---------------------------------------------------------------------------
65+
66+const instrumentedHandler = instrument({ fetch: handler.fetch }, resolveOtelConfig);
67+
68+export default Sentry.withSentry(sentryOptions, instrumentedHandler);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t