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 @@
1import * as Sentry from "@sentry/cloudflare";
1import { Data } from "effect";2import { Data } from "effect";
2import { HttpServerResponse } from "@effect/platform";3import { HttpServerResponse } from "@effect/platform";
3 4
@@ -20,12 +21,16 @@ export const isServerError = (error: unknown): boolean => toHttpResponseError(er · 16 unmodified lines
20 21
21export const toErrorResponse = (error: unknown): Response => {22export const toErrorResponse = (error: unknown): Response => {
22 const mapped = toHttpResponseError(error);23 const mapped = toHttpResponseError(error);
24 if (mapped.status >= 500) Sentry.captureException(error);
23 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });25 return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status });
24};26};
25 27
26export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {28export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => {
27 console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error);
28 const mapped = toHttpResponseError(error);29 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 }
29 return HttpServerResponse.unsafeJson(34 return HttpServerResponse.unsafeJson(
30 { error: mapped.message, code: mapped.code },35 { error: mapped.message, code: mapped.code },
31 { status: mapped.status },36 { status: mapped.status },
apps/cloud/src/mcp.ts+2−0
@@ -3,6 +3,7 @@ · 2 unmodified lines
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { env } from "cloudflare:workers";5import { env } from "cloudflare:workers";
6import * as Sentry from "@sentry/cloudflare";
6import { createRemoteJWKSet, jwtVerify } from "jose";7import { createRemoteJWKSet, jwtVerify } from "jose";
7 8
8import { server } from "./env";9import { server } from "./env";
@@ -130,6 +131,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 121 unmodified lines
130 return await stub.handleRequest(request);131 return await stub.handleRequest(request);
131 } catch (err) {132 } catch (err) {
132 console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err);133 console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err);
134 Sentry.captureException(err);
133 return jsonRpcError(500, -32603, "Internal server error");135 return jsonRpcError(500, -32603, "Internal server error");
134 }136 }
135};137};
apps/cloud/src/server.ts+40−29
@@ -2,25 +2,16 @@ import * as Sentry from "@sentry/cloudflare"; · 1 unmodified line
2import handler from "@tanstack/react-start/server-entry";2import handler from "@tanstack/react-start/server-entry";
3import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";3import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
4 4
5// ---------------------------------------------------------------------------5import { McpSessionDO as McpSessionDOBase } from "./mcp-session";
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
19export { McpSessionDO } from "./mcp-session";
20 6
21// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
22// OTEL config for the main fetch handler — `otel-cf-workers` owns the global8// OTEL config for the main fetch handler — `otel-cf-workers` owns the global
23// TracerProvider and flushes via `ctx.waitUntil` at the end of each request.9// 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".
24// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
25 16
26type OtelEnv = {17type OtelEnv = {
@@ -39,19 +30,39 @@ const resolveOtelConfig: ResolveConfigFn<OtelEnv> = (env) => ({ · 12 unmodified lines
39 },30 },
40});31});
41 32
42const 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".
37const 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});
43 49
44export default Sentry.withSentry(50// ---------------------------------------------------------------------------
45 (env: Record<string, string>) => ({51// Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the
46 dsn: env.SENTRY_DSN,52// client inside the DO isolate, which plain `Sentry.captureException` cannot
47 tracesSampleRate: 0,53// do on its own). We deliberately do NOT wrap with otel-cf-workers'
48 enableLogs: true,54// `instrumentDO` (see note above).
49 sendDefaultPii: true,55// ---------------------------------------------------------------------------
50 // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat56
51 // shim registers a ProxyTracerProvider of its own, which prevents57export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry(
52 // otel-cf-workers from finding its WorkerTracer and breaks the whole58 sentryOptions,
53 // request path with "global tracer is not of type WorkerTracer".59 McpSessionDOBase,
54 skipOpenTelemetrySetup: true,
55 }),
56 instrumentedHandler,
57);60);
61
62// ---------------------------------------------------------------------------
63// Worker fetch handler
64// ---------------------------------------------------------------------------
65
66const instrumentedHandler = instrument({ fetch: handler.fetch }, resolveOtelConfig);
67
68export 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