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.
Showing the changes to apps/cloud/src (3 of 3 files). Show all
Unified · Split
| @@ -1,3 +1,4 @@ | |||
| 1 | import * as Sentry from "@sentry/cloudflare"; | ||
| 1 | import { Data } from "effect"; | 2 | import { Data } from "effect"; |
| 2 | import { HttpServerResponse } from "@effect/platform"; | 3 | import { HttpServerResponse } from "@effect/platform"; |
| 3 | 4 | ||
| @@ -20,12 +21,16 @@ export const isServerError = (error: unknown): boolean => toHttpResponseError(er · 16 unmodified lines | |||
| 20 | 21 | ||
| 21 | export const toErrorResponse = (error: unknown): Response => { | 22 | export 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 | ||
| 26 | export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => { | 28 | export 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 }, |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { env } from "cloudflare:workers"; | 5 | import { env } from "cloudflare:workers"; |
| 6 | import * as Sentry from "@sentry/cloudflare"; | ||
| 6 | import { createRemoteJWKSet, jwtVerify } from "jose"; | 7 | import { createRemoteJWKSet, jwtVerify } from "jose"; |
| 7 | 8 | ||
| 8 | import { server } from "./env"; | 9 | import { 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 | }; |
| @@ -2,25 +2,16 @@ import * as Sentry from "@sentry/cloudflare"; · 1 unmodified line | |||
| 2 | import handler from "@tanstack/react-start/server-entry"; | 2 | import handler from "@tanstack/react-start/server-entry"; |
| 3 | import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers"; | 3 | import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers"; |
| 4 | 4 | ||
| 5 | // --------------------------------------------------------------------------- | 5 | import { 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 | |||
| 19 | export { McpSessionDO } from "./mcp-session"; | ||
| 20 | 6 | ||
| 21 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 22 | // OTEL config for the main fetch handler — `otel-cf-workers` owns the global | 8 | // 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 | ||
| 26 | type OtelEnv = { | 17 | type OtelEnv = { |
| @@ -39,19 +30,39 @@ const resolveOtelConfig: ResolveConfigFn<OtelEnv> = (env) => ({ · 12 unmodified lines | |||
| 39 | }, | 30 | }, |
| 40 | }); | 31 | }); |
| 41 | 32 | ||
| 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 | }); | ||
| 43 | 49 | ||
| 44 | export 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 compat | 56 | |
| 51 | // shim registers a ProxyTracerProvider of its own, which prevents | 57 | export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( |
| 52 | // otel-cf-workers from finding its WorkerTracer and breaks the whole | 58 | 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 | |||
| 66 | const instrumentedHandler = instrument({ fetch: handler.fetch }, resolveOtelConfig); | ||
| 67 | |||
| 68 | export default Sentry.withSentry(sentryOptions, instrumentedHandler); | ||