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 | 2 | import { Data } from "effect"; |
| 2 | 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 | 22 | export const toErrorResponse = (error: unknown): Response => { |
| 22 | 23 | const mapped = toHttpResponseError(error); |
| 24 | + if (mapped.status >= 500) Sentry.captureException(error); | |
| 23 | 25 | return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status }); |
| 24 | 26 | }; |
| 25 | 27 | |
| 26 | 28 | export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => { |
| 27 | − console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error); | |
| 28 | 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 | 34 | return HttpServerResponse.unsafeJson( |
| 30 | 35 | { error: mapped.message, code: mapped.code }, |
| 31 | 36 | { status: mapped.status }, |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | 5 | import { env } from "cloudflare:workers"; |
| 6 | +import * as Sentry from "@sentry/cloudflare"; | |
| 6 | 7 | import { createRemoteJWKSet, jwtVerify } from "jose"; |
| 7 | 8 | |
| 8 | 9 | import { server } from "./env"; |
| @@ -130,6 +131,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 121 unmodified lines | ||
| 130 | 131 | return await stub.handleRequest(request); |
| 131 | 132 | } catch (err) { |
| 132 | 133 | console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err); |
| 134 | + Sentry.captureException(err); | |
| 133 | 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 | 2 | import handler from "@tanstack/react-start/server-entry"; |
| 3 | 3 | import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers"; |
| 4 | 4 | |
| 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"; | |
| 20 | 6 | |
| 21 | 7 | // --------------------------------------------------------------------------- |
| 22 | 8 | // OTEL config for the main fetch handler — `otel-cf-workers` owns the global |
| 23 | 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 | 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( | |
| 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, | |
| 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); | |