From f14cc4f8f6544f85ac90279ce6ce57324f65acf8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 19:03:58 +0200 Subject: [PATCH] fix(errors): capture browser exceptions, and stop dumping whole causes into logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of error handling across every app, package and lib turned up five things. The architecture itself is sound — 172 `Schema.TaggedError` declarations, zero `Effect.catchAll`, two symmetric HTTP error boundaries, and no `unwrap()` outside test modules in the Rust ingest gateway — so these are gaps in it rather than a rethink of it. **Browser exceptions were never captured at all.** Neither SDK had a `window.onerror` or `unhandledrejection` handler, and nothing anywhere called anything like `captureException`. Both only ever emitted an `exception` event when an Effect *span* failed, which in a browser is the minority of failures: a React render crash, a throw in an event handler and a floating rejected promise all bypass Effect entirely. The dashboard painted its crash screen and the crash was lost, and customers on the browser SDK got no browser error tracking despite the product shipping an error fingerprint hub. Both SDKs now record these as spans with status `Error` carrying an `exception` event — the shape `error_events_mv` fingerprints on — so browser crashes group beside server-side errors instead of in a silo. The effect-sdk routes them through a `Die` cause so they take the same road as every other failure, which also keeps them clear of `anticipatedErrorIdentifiers`; a caller cannot silence a real crash by listing a tag. Opaque cross-origin "Script error." events are dropped rather than recorded: they carry no stack and no filename, and would collapse into one contentless issue that buries the real ones. An error a boundary *catches* never reaches those handlers, because catching it is what stops it — so both SDKs expose `captureException` and the web app's two boundaries call it. The route boundary reports only unclassifiable errors: a recognized API or network failure already has a failed client span, and reporting it again would fingerprint the same outage twice. **`Cause.pretty` was rendering whole causes into log annotations** at 34 sites. It walks `Error.cause` chains inline, so a `DatabaseError` — whose cause is the raw postgres.js error — dragged the driver's options into the annotation, and a warehouse failure dragged in the statement that failed. `summarizeToolFailure` already fixed this for the model transcript; this is the same reasoning applied to the logging path, where most of the calls actually were. These logs are Maple's own (they resolve `MAPLE_INGEST_KEY`), so this is about volume, cost and groupability rather than confidentiality. The new `summarizeCause` is a thin wrapper over `Cause.prettyErrors` — the same normalizer `Cause.pretty` builds on, stopped one step earlier. It hands back an `Error` per reason with `name` resolved to the tag and `message` resolved through Effect's own `toString`/JSON fallbacks; reading only those two leaves the stack and the nested cause behind. It is the primitive the SDK's tracer already uses, so a log line and its span now agree on what a failure is called. `summarizeToolFailure` drops its duplicate hand-rolled narrowing for the same call, keeping its deliberate typed-failures-only filter. **`DatabaseError` declared `cause: Schema.Unknown`**, the only such site in the repo against 27 that use `Schema.Defect()`. `Unknown` has no encoded form, so anything serializing a `DatabaseError` serialized the raw driver object. Now `Schema.Defect({ excludeCause: true })`. **Three state-mutating writes in `MobilePushService` used a bare `Effect.ignore`**, against the file's own docstring promising that every failure is a log line. A dropped `markPushed` buzzes the same phone about the same incident again with nothing saying why; a dropped `disable` keeps pushing a token Apple already called dead. They now log, staying quiet on interrupt so a torn-down cron isolate is not reported as a failure. **The last 13 legacy `Data.TaggedError` classes** are converted, including one in the API's own error path. The repo is now at zero in production code. Verified end-to-end against the running dashboard, not only in tests: both global handlers dispatched and the actual OTLP payload inspected — status code 2 with `exception.type`/`message`/`stacktrace` populated. Full repo typecheck and lint pass; api, web, effect-sdk, browser, auth, alerting and unitflow suites are green. --- apps/alerting/src/worker.ts | 3 +- apps/api/src/alerting.ts | 1 + apps/api/src/chat/turn-runner.ts | 5 +- apps/api/src/http/server-error-span.ts | 15 ++- apps/api/src/mcp/lib/inspect-widget.ts | 7 +- apps/api/src/mcp/tools/llm-tools.ts | 21 ++-- apps/api/src/platform/DatabaseLive.ts | 13 ++- apps/api/src/platform/EmailService.ts | 9 +- apps/api/src/platform/Env.ts | 9 +- apps/api/src/platform/describe-cause.test.ts | 77 ++++++++++++ apps/api/src/platform/describe-cause.ts | 36 ++++++ apps/api/src/routes/internal/chat.http.ts | 3 +- apps/api/src/routes/v1/integrations.http.ts | 5 +- apps/api/src/services/alerts/AlertsService.ts | 4 +- .../alerts/AnomalyDetectionService.ts | 7 +- .../src/services/alerts/EscalationService.ts | 3 +- .../src/services/alerts/alert-chart-series.ts | 5 +- .../dashboards/ServiceMapRollupService.ts | 5 +- apps/api/src/services/digest/DigestService.ts | 9 +- .../src/services/errors/ErrorActorsService.ts | 5 +- apps/api/src/services/errors/ErrorsService.ts | 7 +- .../services/errors/InvestigationService.ts | 7 +- .../src/services/errors/ai-triage-enqueue.ts | 7 +- apps/api/src/services/errors/issue-hub.ts | 5 +- .../CloudflareAnalyticsService.ts | 5 +- .../integrations/PlanetScaleService.ts | 7 +- .../integrations/ScrapeTargetsService.ts | 3 +- .../integrations/vcs/VcsSyncService.ts | 5 +- .../src/services/push/MobilePushService.ts | 75 ++++++++++-- apps/api/src/vcs-sync-runtime.ts | 5 +- apps/api/src/workflows/agent-pass.ts | 3 +- .../web/src/components/app-error-boundary.tsx | 24 +++- apps/web/src/components/route-error.tsx | 16 ++- .../web/src/lib/services/common/otel-layer.ts | 10 ++ apps/web/src/lib/services/common/telemetry.ts | 10 +- .../src/lib/services/common/v2-pagination.ts | 13 ++- lib/effect-cloudflare/src/kv-namespace.ts | 13 ++- lib/effect-cloudflare/src/r2-bucket.ts | 10 +- lib/effect-cloudflare/src/rpc.ts | 33 +++--- lib/unitflow/src/db/index.ts | 10 +- packages/auth/src/index.ts | 10 +- packages/browser/README.md | 30 ++++- packages/browser/src/config.ts | 8 ++ packages/browser/src/errors.test.ts | 93 +++++++++++++++ packages/browser/src/errors.ts | 110 ++++++++++++++++++ packages/browser/src/index.ts | 22 +++- packages/browser/src/init.ts | 10 ++ packages/browser/src/tracing.test.ts | 1 + packages/effect-sdk/README.md | 29 +++++ packages/effect-sdk/src/client/flushable.ts | 82 ++++++++++++- .../src/shared/flushable-tracer.test.ts | 57 +++++++++ .../effect-sdk/src/shared/flushable-tracer.ts | 47 +++++++- 52 files changed, 877 insertions(+), 132 deletions(-) create mode 100644 apps/api/src/platform/describe-cause.test.ts create mode 100644 packages/browser/src/errors.test.ts create mode 100644 packages/browser/src/errors.ts diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index a1fbab227..75cbbedb2 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -32,6 +32,7 @@ import { ServiceMapRollupService, TinybirdOrgTokenService, WarehouseQueryService, + summarizeCause, withPgConnectionScope, } from "@maple/api/alerting" import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" @@ -230,7 +231,7 @@ export const catchTickFailure = (label: string) => ? Effect.interrupt : Effect.logError("Alerting tick failed").pipe( Effect.annotateLogs({ - "error.message": Cause.pretty(cause), + "error.message": summarizeCause(cause), "maple.alerting.tick": label, }), ), diff --git a/apps/api/src/alerting.ts b/apps/api/src/alerting.ts index c65ff698f..9a9e1f66a 100644 --- a/apps/api/src/alerting.ts +++ b/apps/api/src/alerting.ts @@ -14,6 +14,7 @@ export { ErrorIssueWorkflowService } from "./services/errors/ErrorIssueWorkflowS export { ErrorPolicyService } from "./services/errors/ErrorPolicyService" export { ErrorsService } from "./services/errors/ErrorsService" export { NotificationDispatcher } from "./services/alerts/NotificationDispatcher" +export { summarizeCause } from "@/platform/describe-cause" export { Database } from "@/platform/DatabaseLive" export { layerPg } from "@/platform/DatabasePgLive" export { withPgConnectionScope } from "@/platform/pg-connection-scope" diff --git a/apps/api/src/chat/turn-runner.ts b/apps/api/src/chat/turn-runner.ts index 8c1def24a..12d2407d3 100644 --- a/apps/api/src/chat/turn-runner.ts +++ b/apps/api/src/chat/turn-runner.ts @@ -31,6 +31,7 @@ import { LLM, Message, type Model } from "@maple/llm" import { Cause, Effect, Layer, ManagedRuntime, Stream } from "effect" import type { ChatSession } from "./ChatSession" import type { TenantContext } from "@/services/auth/tenant-context" +import { summarizeCause } from "@/platform/describe-cause" const telemetry = MapleCloudflareSDK.make({ serviceName: "maple-api", @@ -190,7 +191,7 @@ const compactIfNeeded = ( Effect.annotateLogs({ sessionId: input.sessionId, messageId: input.messageId, - cause: Cause.pretty(cause), + cause: summarizeCause(cause), }), ), ), @@ -332,7 +333,7 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis Effect.annotateLogs({ sessionId: input.sessionId, messageId: input.messageId, - cause: Cause.pretty(cause), + cause: summarizeCause(cause), }), ), ), diff --git a/apps/api/src/http/server-error-span.ts b/apps/api/src/http/server-error-span.ts index 5c0c6d3f1..c990a7a8c 100644 --- a/apps/api/src/http/server-error-span.ts +++ b/apps/api/src/http/server-error-span.ts @@ -1,4 +1,4 @@ -import { Cause, Context, Data, Effect } from "effect" +import { Cause, Context, Effect, Schema } from "effect" import { HttpMiddleware } from "effect/unstable/http" import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest" @@ -39,11 +39,14 @@ import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest" * would receive the raw two-reason cause; the `Die(response)` reason is * skipped by reporters via `HttpServerResponse`'s `ErrorReporter.ignore` flag. */ -export class Http5xxResponseError extends Data.TaggedError("@maple/api/http/Http5xxResponseError")<{ - readonly status: number - readonly method: string - readonly path: string -}> { +export class Http5xxResponseError extends Schema.TaggedError()( + "@maple/api/http/Http5xxResponseError", + { + status: Schema.Number, + method: Schema.String, + path: Schema.String, + }, +) { override get message(): string { return `HTTP ${this.status} (${this.method} ${this.path})` } diff --git a/apps/api/src/mcp/lib/inspect-widget.ts b/apps/api/src/mcp/lib/inspect-widget.ts index f186f3fb2..2f626d668 100644 --- a/apps/api/src/mcp/lib/inspect-widget.ts +++ b/apps/api/src/mcp/lib/inspect-widget.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit, Option, Result, Schema } from "effect" +import { Effect, Exit, Option, Result, Schema } from "effect" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { @@ -45,6 +45,7 @@ import { RAW_SQL_ENDPOINT, } from "@maple/widgets/dashboard" import type { TenantContext } from "@/services/auth/tenant-context" +import { summarizeCause } from "@/platform/describe-cause" // `RAW_SQL_ENDPOINT` and `QUERY_SHAPE_ENDPOINTS` are used here as LABELS, not as // dispatch keys — dispatch goes through `dataSourceRawSql` / `dataSourceQuerySet`, @@ -531,7 +532,7 @@ export const inspectWidget = Effect.fn("inspectWidget")( // carries a `message`. A defect (no typed failure) falls back to the // pretty-printed cause. const failure = Option.getOrUndefined(Exit.findErrorOption(exit)) - const errorMessage = failure ? failure.message : Cause.pretty(exit.cause) + const errorMessage = failure ? failure.message : summarizeCause(exit.cause) return { queryId: draft.id, queryName: draft.name, @@ -765,7 +766,7 @@ export const inspectWidget = Effect.fn("inspectWidget")( Effect.catchCause((cause) => Effect.succeed({ kind: "inspection_error", - message: Cause.pretty(cause), + message: summarizeCause(cause), }), ), ) diff --git a/apps/api/src/mcp/tools/llm-tools.ts b/apps/api/src/mcp/tools/llm-tools.ts index 69ab7a755..d0942ed21 100644 --- a/apps/api/src/mcp/tools/llm-tools.ts +++ b/apps/api/src/mcp/tools/llm-tools.ts @@ -48,15 +48,20 @@ const cap = (message: string): string => ? message : `${message.slice(0, MAX_FAILURE_MESSAGE_CHARS)}…[truncated]` +/** + * Typed failures only. A defect is an internal breakage the model can do + * nothing with, and its message is the kind of thing this function exists to + * keep out of the transcript — so `Die` reasons are filtered out before + * rendering rather than summarized. + * + * `Cause.prettyErrors` does the narrowing: it resolves `message` through the + * same `toString`/JSON fallbacks Effect uses everywhere, so a failure that is + * not an `Error` still reads as something rather than "the tool failed". + */ export const summarizeToolFailure = (cause: Cause.Cause): string => { - const failure = cause.reasons.find(Cause.isFailReason) - const error: unknown = failure?.error - if (error instanceof Error) return cap(error.message) - if (error && typeof error === "object" && "message" in error) { - const message = (error as { message?: unknown }).message - if (typeof message === "string") return cap(message) - } - return "the tool failed" + const failures = Cause.prettyErrors(Cause.fromReasons(cause.reasons.filter(Cause.isFailReason))) + const first = failures[0] + return first === undefined ? "the tool failed" : cap(first.message) } /** diff --git a/apps/api/src/platform/DatabaseLive.ts b/apps/api/src/platform/DatabaseLive.ts index 01b268f2f..f46d86689 100644 --- a/apps/api/src/platform/DatabaseLive.ts +++ b/apps/api/src/platform/DatabaseLive.ts @@ -6,9 +6,20 @@ import { updateCurrentSpanName } from "./span-name" export type DatabaseClient = MaplePgClient +/** + * `cause` is the driver's own error, kept for `postgres-errors.ts` to read the + * `code`/SQLSTATE off. It is `Schema.Defect()` rather than `Schema.Unknown` for + * the reason the convention gives: `Unknown` has no encoded form, so anything + * that serialized a `DatabaseError` serialized the raw postgres.js object — + * host, port, driver options and all. `Defect()` encodes an `Error` to its + * `name` and `message`, and `excludeCause: true` stops at the driver error + * instead of walking into the socket error underneath it. `toDatabaseError` + * already lifts the root cause's message into `message`, so the diagnostic half + * survives the narrowing. + */ export class DatabaseError extends Schema.TaggedError()("@maple/api/lib/DatabaseError", { message: Schema.String, - cause: Schema.Unknown, + cause: Schema.Defect({ excludeCause: true }), }) {} export interface DatabaseApi { diff --git a/apps/api/src/platform/EmailService.ts b/apps/api/src/platform/EmailService.ts index 46da9796e..80ada586c 100644 --- a/apps/api/src/platform/EmailService.ts +++ b/apps/api/src/platform/EmailService.ts @@ -1,10 +1,11 @@ import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" -import { Context, Data, Duration, Effect, Layer } from "effect" +import { Context, Duration, Effect, Layer, Schema } from "effect" import { Env } from "./Env" -class EmailDeliveryError extends Data.TaggedError("@maple/api/platform/EmailDeliveryError")<{ - readonly message: string -}> {} +class EmailDeliveryError extends Schema.TaggedError()( + "@maple/api/platform/EmailDeliveryError", + { message: Schema.String }, +) {} export interface EmailServiceApi { readonly isConfigured: boolean diff --git a/apps/api/src/platform/Env.ts b/apps/api/src/platform/Env.ts index dc265c876..06e3e5479 100644 --- a/apps/api/src/platform/Env.ts +++ b/apps/api/src/platform/Env.ts @@ -1,10 +1,11 @@ import { optionalRedacted, optionalString, stringWithDefault } from "@maple/effect-cloudflare/config-helpers" -import { Config, Context, Data, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Config, Context, Effect, Layer, Option, Redacted, Schema } from "effect" /** Fatal misconfiguration discovered at startup — surfaces as a tagged defect in the Cause. */ -class EnvValidationError extends Data.TaggedError("@maple/api/lib/EnvValidationError")<{ - readonly message: string -}> {} +class EnvValidationError extends Schema.TaggedError()( + "@maple/api/lib/EnvValidationError", + { message: Schema.String }, +) {} export interface EnvConfig { readonly PORT: number diff --git a/apps/api/src/platform/describe-cause.test.ts b/apps/api/src/platform/describe-cause.test.ts new file mode 100644 index 000000000..cad89f997 --- /dev/null +++ b/apps/api/src/platform/describe-cause.test.ts @@ -0,0 +1,77 @@ +import { assert, describe, it } from "@effect/vitest" +import { Cause } from "effect" +import { toDatabaseError } from "./DatabaseLive" +import { summarizeCause } from "./describe-cause" + +/** + * A postgres.js connection failure, shaped as the driver actually raises it: + * the machine-readable class on `code`, and the socket error hung off `cause` + * with the host and port it was dialing. + */ +const driverConnectionError = (): Error => + Object.assign(new Error("write CONNECT_TIMEOUT"), { + code: "CONNECT_TIMEOUT", + cause: Object.assign(new Error("connect ETIMEDOUT 10.0.4.19:5432"), { + code: "ETIMEDOUT", + address: "10.0.4.19", + port: 5432, + }), + }) + +describe("summarizeCause", () => { + it("names a tagged failure by its tag", () => { + const summary = summarizeCause(Cause.fail(toDatabaseError(new Error("relation does not exist")))) + assert.strictEqual(summary, "@maple/api/lib/DatabaseError: relation does not exist") + }) + + it("keeps the raw driver object out of the annotation", () => { + const summary = summarizeCause(Cause.fail(toDatabaseError(driverConnectionError()))) + // `toDatabaseError` deliberately lifts the root cause's *message* into its + // own, and that stays: which host timed out is the whole diagnostic. What + // must not follow it is the driver object itself — its `code`, `address` + // and `port` properties, and every frame of its stack. + assert.include(summary, "write CONNECT_TIMEOUT") + assert.include(summary, "connect ETIMEDOUT") + assert.notInclude(summary, "address") + assert.notInclude(summary, "[cause]") + }) + + it("carries no stack frames", () => { + const summary = summarizeCause(Cause.fail(new Error("boom"))) + assert.strictEqual(summary, "Error: boom") + }) + + it("names a defect by its constructor", () => { + assert.strictEqual( + summarizeCause(Cause.die(new TypeError("x is not a function"))), + "TypeError: x is not a function", + ) + }) + + it("reports every reason of a multi-failure cause", () => { + const cause = Cause.fromReasons([ + ...Cause.fail(new Error("first")).reasons, + ...Cause.die(new Error("second")).reasons, + ]) + assert.strictEqual(summarizeCause(cause), "Error: first; Error: second") + }) + + it("caps a statement a warehouse error inlined whole", () => { + const summary = summarizeCause(Cause.fail(new Error(`syntax error near ${"SELECT x, ".repeat(200)}`))) + assert.isBelow(summary.length, 550) + assert.isTrue(summary.endsWith("…[truncated]")) + }) + + it("stays legible for a failure that is not an Error", () => { + // Effect's own normalizer covers these, which is the reason this module + // does not hand-roll the narrowing: a thrown string, number, or bare + // object all still produce something groupable. + assert.strictEqual(summarizeCause(Cause.fail("plain string failure")), "Error: plain string failure") + assert.strictEqual(summarizeCause(Cause.fail(42)), "Error: 42") + assert.strictEqual(summarizeCause(Cause.fail({ message: "objish" })), "Error: objish") + }) + + it("says something rather than nothing for an empty cause", () => { + assert.strictEqual(summarizeCause(Cause.fromReasons([])), "empty cause") + }) +}) diff --git a/apps/api/src/platform/describe-cause.ts b/apps/api/src/platform/describe-cause.ts index 26c92ad8b..8dc3fa90c 100644 --- a/apps/api/src/platform/describe-cause.ts +++ b/apps/api/src/platform/describe-cause.ts @@ -1,3 +1,5 @@ +import { Cause } from "effect" + /** Render an unknown nested cause for logs and serialized persistence errors. */ export const describeCause = (cause: unknown): string | undefined => { if (cause == null) return undefined @@ -9,3 +11,37 @@ export const describeCause = (cause: unknown): string | undefined => { return String(cause) } } + +/** + * "One line" is a convention no error's author agreed to: a ClickHouse syntax + * error arrives with the whole offending statement inlined, and a retry loop + * writes it once per attempt. + */ +const MAX_CAUSE_CHARS = 500 + +const cap = (message: string): string => + message.length <= MAX_CAUSE_CHARS ? message : `${message.slice(0, MAX_CAUSE_CHARS)}…[truncated]` + +/** + * A bounded, one-line rendering of a cause, safe to hand `Effect.annotateLogs`. + * + * `Cause.pretty` is the reflex here and it is the wrong tool for a log line: it + * renders every reason's stack frames and walks `Error.cause` chains inline, so + * a `DatabaseError` — whose `cause` is the raw postgres.js error — drags the + * driver's options into the annotation, and a warehouse failure drags in the + * statement that failed. Neither told an operator anything the tag and the + * span's `error.type` did not, and both are billed by the byte. + * + * `Cause.prettyErrors` is the same normalizer `Cause.pretty` builds on, stopped + * one step earlier: it hands back an `Error` per reason with `name` resolved to + * the tag (`@maple/api/lib/DatabaseError`) and `message` resolved through the + * same fallbacks — `toString`, then JSON — that make a thrown string, number or + * bare object legible. Reading only those two leaves the stack and the nested + * cause behind. The SDK's tracer builds its `exception` events off the same + * call, so a log line and its span agree on what the failure was called. + */ +export const summarizeCause = (cause: Cause.Cause): string => { + const errors = Cause.prettyErrors(cause) + if (errors.length === 0) return "empty cause" + return cap(errors.map((error) => `${error.name}: ${error.message}`).join("; ")) +} diff --git a/apps/api/src/routes/internal/chat.http.ts b/apps/api/src/routes/internal/chat.http.ts index f71b36d56..13e8c0cdd 100644 --- a/apps/api/src/routes/internal/chat.http.ts +++ b/apps/api/src/routes/internal/chat.http.ts @@ -16,6 +16,7 @@ import { mapleToolCatalog } from "@/mcp/tools/registry" import { MUTATING_TOOL_NAMES } from "@/mcp/tools/mutating" import { McpToolExecutor } from "@/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" +import { summarizeCause } from "@/platform/describe-cause" const executionDefect = (tool: string, defect: unknown) => Effect.logError("Chat approval tool execution defect").pipe( @@ -147,7 +148,7 @@ export const HttpChatLive = HttpApiBuilder.group(MapleInternalApi, "chat", (hand sessionId: payload.sessionId ?? "(none)", messageId: payload.messageId ?? "(none)", toolCallId: payload.toolCallId ?? "(none)", - cause: Cause.pretty(cause), + cause: summarizeCause(cause), }), ), ), diff --git a/apps/api/src/routes/v1/integrations.http.ts b/apps/api/src/routes/v1/integrations.http.ts index 780b77ff9..6cd17131f 100644 --- a/apps/api/src/routes/v1/integrations.http.ts +++ b/apps/api/src/routes/v1/integrations.http.ts @@ -41,7 +41,7 @@ import { import { cloudflareAnalyticsState } from "@maple/db" import { EdgeCacheService } from "@maple/cache" import { and, eq } from "drizzle-orm" -import { Cause, Effect, Option, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" import { graphqlQuery } from "@/services/integrations/CloudflareApi" @@ -63,6 +63,7 @@ import { GithubConnectService } from "@/services/integrations/vcs/vendor/github/ import { VcsCommitService } from "@/services/integrations/vcs/VcsCommitService" import { HazelOAuthService } from "@/services/auth/HazelOAuthService" import { requireAdmin as requireAdminRole } from "@/services/auth/auth" +import { summarizeCause } from "@/platform/describe-cause" const asExternalUserId = Schema.decodeUnknownSync(ExternalUserId) const asUserId = Schema.decodeUnknownSync(UserId) @@ -1034,7 +1035,7 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => Effect.catchCause((cause) => Effect.logWarning("cloudflare post-connect state reset failed", { orgId: result.orgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ), ), diff --git a/apps/api/src/services/alerts/AlertsService.ts b/apps/api/src/services/alerts/AlertsService.ts index 1746a5c49..de51b720d 100644 --- a/apps/api/src/services/alerts/AlertsService.ts +++ b/apps/api/src/services/alerts/AlertsService.ts @@ -59,7 +59,6 @@ import { import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm" import { Array as Arr, - Cause, Chunk, Effect, HashSet, @@ -116,6 +115,7 @@ import { type NormalizedRule, } from "./AlertRuleModel" import { mapSignalUnit, resolveSignalDisplay } from "./alert-signal-display" +import { summarizeCause } from "@/platform/describe-cause" export { AlertRuntime, type AlertRuntimeApi } from "./AlertRuntime" @@ -3355,7 +3355,7 @@ export class AlertsService extends Context.Service>( ANOMALY_ACTIVE_ORGS_CACHE_BUCKET, @@ -2109,14 +2110,14 @@ const make = Effect.gen(function* () { ).pipe( Effect.annotateLogs({ orgId: org, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } else { yield* Effect.logError("Anomaly tick failed for org").pipe( Effect.annotateLogs({ orgId: org, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } diff --git a/apps/api/src/services/alerts/EscalationService.ts b/apps/api/src/services/alerts/EscalationService.ts index f74783a1f..875b9f7ef 100644 --- a/apps/api/src/services/alerts/EscalationService.ts +++ b/apps/api/src/services/alerts/EscalationService.ts @@ -33,6 +33,7 @@ import { Env } from "@/platform/Env" import { evaluateEscalationPolicy } from "@/services/alerts/escalation-policy" import { makePersistenceError } from "./alert-persistence" import { NotificationDispatcher, type NotificationRequest } from "./NotificationDispatcher" +import { summarizeCause } from "@/platform/describe-cause" const ESCALATIONS_PER_TICK = 50 const MAX_ATTEMPTS = 3 @@ -297,7 +298,7 @@ const make: Effect.Effect()("@maple/api/ Cause.hasInterruptsOnly(cause) ? Effect.interrupt : Effect.logWarning("Failed to seed digest subscriptions").pipe( - Effect.annotateLogs({ error: Cause.pretty(cause) }), + Effect.annotateLogs({ error: summarizeCause(cause) }), ), ), ) @@ -818,7 +819,7 @@ export class DigestService extends Context.Service()("@maple/api/ Effect.annotateLogs({ subscriptionId: sub.id, orgId: rawOrgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ), ), @@ -850,14 +851,14 @@ export class DigestService extends Context.Service()("@maple/api/ ).pipe( Effect.annotateLogs({ orgId: rawOrgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } else { yield* Effect.logError("Digest failed for org").pipe( Effect.annotateLogs({ orgId: rawOrgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } diff --git a/apps/api/src/services/errors/ErrorActorsService.ts b/apps/api/src/services/errors/ErrorActorsService.ts index d916f9cd5..afa555247 100644 --- a/apps/api/src/services/errors/ErrorActorsService.ts +++ b/apps/api/src/services/errors/ErrorActorsService.ts @@ -12,11 +12,12 @@ import { } from "@maple/domain/http" import { actors, type ActorInsert, type ActorRow } from "@maple/db" import { and, desc, eq, inArray } from "drizzle-orm" -import { Cause, Clock, Context, Effect, Layer, Option, Schema } from "effect" +import { Clock, Context, Effect, Layer, Option, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" import { msToDate } from "@/platform/time" import { isReservedAgentName, SYSTEM_ERRORS_AGENT_NAME } from "@/services/auth/system-actors" import { makeErrorDatabaseExecute } from "./error-persistence" +import { summarizeCause } from "@/platform/describe-cause" const decodeActorIdSync = Schema.decodeUnknownSync(ActorIdSchema) const decodeActorDateTimeSync = Schema.decodeUnknownSync(ActorDocument.fields.lastActiveAt) @@ -117,7 +118,7 @@ const make: Effect.Effect = Effect.gen(f ).pipe( Effect.tapCause((cause) => Effect.logWarning("ErrorsService.touchActor failed to update lastActiveAt").pipe( - Effect.annotateLogs({ orgId, actorId, cause: Cause.pretty(cause) }), + Effect.annotateLogs({ orgId, actorId, cause: summarizeCause(cause) }), ), ), Effect.ignore, diff --git a/apps/api/src/services/errors/ErrorsService.ts b/apps/api/src/services/errors/ErrorsService.ts index 6b02537b8..c854fba71 100644 --- a/apps/api/src/services/errors/ErrorsService.ts +++ b/apps/api/src/services/errors/ErrorsService.ts @@ -59,6 +59,7 @@ import { import { ErrorIssueWorkflowService, type ErrorIssueWorkflowPublicApi } from "./ErrorIssueWorkflowService" import { ErrorPolicyService, type ErrorPolicyPublicApi } from "./ErrorPolicyService" import { makeErrorDatabaseExecute, makePersistenceError } from "./error-persistence" +import { summarizeCause } from "@/platform/describe-cause" export { describeCause, makePersistenceError } from "./error-persistence" @@ -329,7 +330,7 @@ const make: Effect.Effect< : Effect.gen(function* () { yield* Effect.logWarning( "Error active-org discovery failed; reusing last-known active set", - ).pipe(Effect.annotateLogs({ error: Cause.pretty(cause) })) + ).pipe(Effect.annotateLogs({ error: summarizeCause(cause) })) const cached = yield* edgeCache .rawGet>( ACTIVE_ORGS_CACHE_BUCKET, @@ -1363,13 +1364,13 @@ const make: Effect.Effect< yield* Effect.logInfo( "Org warehouse rejected queries with a config-class error; quarantined", ).pipe( - Effect.annotateLogs({ orgId: org, error: Cause.pretty(cause) }), + Effect.annotateLogs({ orgId: org, error: summarizeCause(cause) }), ) } else { yield* Effect.logError("Error tick failed for org").pipe( Effect.annotateLogs({ orgId: org, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) } diff --git a/apps/api/src/services/errors/InvestigationService.ts b/apps/api/src/services/errors/InvestigationService.ts index 75d93993d..1b7818670 100644 --- a/apps/api/src/services/errors/InvestigationService.ts +++ b/apps/api/src/services/errors/InvestigationService.ts @@ -36,7 +36,7 @@ import { } from "@maple/db" import { WorkerEnvironment } from "@maple/effect-cloudflare/worker-environment" import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm" -import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Option, Redacted, Schema } from "effect" +import { Clock, Context, Duration, Effect, Exit, Layer, Option, Redacted, Schema } from "effect" import { trackTokenUsage } from "@/services/billing/autumn-tracker" import { applyDiagnosisWrites } from "@/services/errors/apply-diagnosis" import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "@/workflows/incident-context" @@ -50,6 +50,7 @@ import { import { Database } from "@/platform/DatabaseLive" import { makeDbExecute, makePersistenceErrorMapper } from "@/platform/db-execute" import { Env } from "@/platform/Env" +import { summarizeCause } from "@/platform/describe-cause" /** * Cloudflare Workflow binding that runs a fan-out. Named here rather than read @@ -542,7 +543,7 @@ export class InvestigationService extends Context.Service Effect.logWarning("token usage tracking failed").pipe( - Effect.annotateLogs({ investigationId: id, cause: Cause.pretty(cause) }), + Effect.annotateLogs({ investigationId: id, cause: summarizeCause(cause) }), ), ), ) diff --git a/apps/api/src/services/errors/ai-triage-enqueue.ts b/apps/api/src/services/errors/ai-triage-enqueue.ts index b8bba3919..20ec7fe86 100644 --- a/apps/api/src/services/errors/ai-triage-enqueue.ts +++ b/apps/api/src/services/errors/ai-triage-enqueue.ts @@ -12,7 +12,7 @@ import { import { InvestigationId, IsoDateTimeString } from "@maple/domain/primitives" import { aiTriageSettings, investigations } from "@maple/db" import { and, eq, lt } from "drizzle-orm" -import { Cause, Clock, Duration, Effect, Exit, Option, Redacted, Schema } from "effect" +import { Clock, Duration, Effect, Exit, Option, Redacted, Schema } from "effect" import { encodeChatTurnTenant } from "@maple/domain/chat-session" import { Database } from "@/platform/DatabaseLive" import { isChatSessionNamespace } from "@/chat/session" @@ -26,6 +26,7 @@ import { staleTimeoutMessage, } from "@/services/errors/investigation-stale" import { UserId } from "@maple/domain/primitives" +import { summarizeCause } from "@/platform/describe-cause" /** Identity an autonomous investigation turn runs as — the same one the internal MCP RPC uses. */ const internalServiceUserId = Schema.decodeSync(UserId)("internal-service") @@ -387,7 +388,7 @@ export const maybeEnqueueTriage: ( Effect.annotateLogs({ orgId: input.orgId, investigationId, - error: Cause.pretty(created.cause), + error: summarizeCause(created.cause), }), ) yield* markFailed("start_failed: the investigation fan-out could not be started; retry") @@ -408,7 +409,7 @@ export const maybeEnqueueTriage: ( orgId: input.orgId, incidentKind: input.incidentKind, incidentId: input.incidentId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), Effect.as({ enqueued: false, reason: "error" as const }), ), diff --git a/apps/api/src/services/errors/issue-hub.ts b/apps/api/src/services/errors/issue-hub.ts index a6931708e..fb9ca3b35 100644 --- a/apps/api/src/services/errors/issue-hub.ts +++ b/apps/api/src/services/errors/issue-hub.ts @@ -9,11 +9,12 @@ import { } from "@maple/domain/primitives" import { actors, alertIncidents, errorIssues, errorIssueEvents, type ErrorIssueRow } from "@maple/db" import { and, eq, sql } from "drizzle-orm" -import { Cause, Clock, Effect, Option, Redacted, Schema } from "effect" +import { Clock, Effect, Option, Redacted, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" import { maybeEnqueueTriage } from "./ai-triage-enqueue" import { issueSeverityFromAlert } from "./severity-map" import { SYSTEM_ALERTS_AGENT_NAME } from "@/services/auth/system-actors" +import { summarizeCause } from "@/platform/describe-cause" /** * Issue-hub glue: alert incidents create/refresh `error_issues` rows @@ -344,7 +345,7 @@ export const upsertAlertIssue: ( orgId: input.orgId, ruleId: input.ruleId, incidentId: input.incidentId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ) return { issueId: null, action: "error" as const } diff --git a/apps/api/src/services/integrations/CloudflareAnalyticsService.ts b/apps/api/src/services/integrations/CloudflareAnalyticsService.ts index cef4bed58..fac1388e0 100644 --- a/apps/api/src/services/integrations/CloudflareAnalyticsService.ts +++ b/apps/api/src/services/integrations/CloudflareAnalyticsService.ts @@ -136,6 +136,7 @@ import { type SettingsResponseContract, } from "./cloudflare-analytics/queries" import * as Integrations from "@maple/query-engine-integrations" +import { summarizeCause } from "@/platform/describe-cause" /** * OAuth scopes the poller needs (space-delimited ids in `oauth_connections.scope`). Kept next to @@ -2124,7 +2125,7 @@ export class CloudflareAnalyticsService extends Context.Service< Effect.catchCause((cause) => Effect.logWarning("cloudflare-analytics lease release failed", { orgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }), ), ), @@ -2181,7 +2182,7 @@ export class CloudflareAnalyticsService extends Context.Service< ? Effect.interrupt : Effect.logWarning("cloudflare-analytics org poll failed", { orgId: row.orgId, - error: Cause.pretty(cause), + error: summarizeCause(cause), }).pipe( // A crashed org must still appear in the rollup — otherwise perOrg // silently omits it, `skipped` undercounts, and the zero-rows warning diff --git a/apps/api/src/services/integrations/PlanetScaleService.ts b/apps/api/src/services/integrations/PlanetScaleService.ts index 958e58ed1..d6e10f479 100644 --- a/apps/api/src/services/integrations/PlanetScaleService.ts +++ b/apps/api/src/services/integrations/PlanetScaleService.ts @@ -27,6 +27,7 @@ import { Database } from "@/platform/DatabaseLive" import { Env } from "@/platform/Env" import { PlanetScaleOAuthService, planetScaleBearerHeader } from "@/services/auth/PlanetScaleOAuthService" import { insertPlanetScaleEvent } from "./planetscale/webhook-events" +import { summarizeCause } from "@/platform/describe-cause" /** * PlanetScale management-API poller: keeps the org's database/branch inventory @@ -908,7 +909,7 @@ export class PlanetScaleService extends Context.Service 0), ), @@ -936,7 +937,7 @@ export class PlanetScaleService extends Context.Service 0), ), @@ -1032,7 +1033,7 @@ export class PlanetScaleService extends Context.Service { failures++ diff --git a/apps/api/src/services/integrations/ScrapeTargetsService.ts b/apps/api/src/services/integrations/ScrapeTargetsService.ts index 771973103..53d76ecc2 100644 --- a/apps/api/src/services/integrations/ScrapeTargetsService.ts +++ b/apps/api/src/services/integrations/ScrapeTargetsService.ts @@ -42,6 +42,7 @@ import { planetScaleBearerHeader, type PlanetScaleAccessTokenError, } from "@/services/auth/PlanetScaleOAuthService" +import { summarizeCause } from "@/platform/describe-cause" type ScrapeTargetRow = typeof scrapeTargets.$inferSelect @@ -782,7 +783,7 @@ export class ScrapeTargetsService extends Context.Service) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.tapCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.void + : Effect.logWarning(message).pipe( + Effect.annotateLogs({ ...annotations, cause: summarizeCause(cause) }), + ), + ), + Effect.ignore, + ) + export interface IncidentPushEvent { readonly orgId: OrgId readonly eventType: AlertEventType @@ -417,7 +446,12 @@ export class MobilePushService extends Context.Service Effect.logError("[VCS] scheduled sync tick failed").pipe( - Effect.annotateLogs({ error: Cause.pretty(cause) }), + Effect.annotateLogs({ error: summarizeCause(cause) }), ), ), ), @@ -175,7 +176,7 @@ export const processBatch = (batch: MessageBatch) => Effect.flatMap(() => Effect.logError("[VCS] sync message failed").pipe( Effect.annotateLogs({ - error: Cause.pretty(cause), + error: summarizeCause(cause), attempt: message.attempts, outcome, ...(isFinalAttempt ? { exhausted: true } : undefined), diff --git a/apps/api/src/workflows/agent-pass.ts b/apps/api/src/workflows/agent-pass.ts index 58bfcf2b1..c97b951b6 100644 --- a/apps/api/src/workflows/agent-pass.ts +++ b/apps/api/src/workflows/agent-pass.ts @@ -31,6 +31,7 @@ import { runChatTurn, makeTurnUsage, type TurnCompletion, type TurnUsage } from import type { AgentDefinition } from "@/chat/agents" import { McpToolExecutor } from "@/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" +import { summarizeCause } from "@/platform/describe-cause" export interface AgentPassInput { /** Correlation id; becomes the turn's `messageId`. */ @@ -135,7 +136,7 @@ export const runAgentPass = ( messageId: input.id, submitted: Option.isSome(answer), toolCallCount: toolCalls, - cause: Cause.pretty(cause), + cause: summarizeCause(cause), }), Effect.tap(() => Effect.annotateCurrentSpan("maple.agent.recovered_failure", true), diff --git a/apps/web/src/components/app-error-boundary.tsx b/apps/web/src/components/app-error-boundary.tsx index a2decb360..c2e2bb913 100644 --- a/apps/web/src/components/app-error-boundary.tsx +++ b/apps/web/src/components/app-error-boundary.tsx @@ -2,11 +2,12 @@ * Last-resort boundary for errors outside router boundaries. It has no router * context; crash artwork geometry is coupled to `.boot-*`/`.crash-*` in styles.css. */ -import { Component, type ReactNode } from "react" +import { Component, type ErrorInfo, type ReactNode } from "react" import { buttonVariants } from "@maple/ui/components/ui/button" import { isChunkLoadError, shouldAttemptChunkReload } from "@/lib/chunk-reload" import { displayError } from "@/lib/error-messages" +import { captureException } from "@/lib/services/common/otel-layer" interface AppErrorBoundaryProps { children: ReactNode @@ -23,10 +24,25 @@ export class AppErrorBoundary extends Component { + if (!shouldReport) return + captureException(error, { + name: "browser.route_error", + attributes: { "maple.exception.source": "route_error_boundary" }, + }) + }) + const autoRetrying = useNetworkAutoRetry(isAutomaticRetryError(formatted) && !isStaleChunk, retry) const description = autoRetrying ? `${formatted.message} Retrying automatically…` : formatted.message const canRetry = formatted.recovery === "retry" || formatted.recovery === "refresh" diff --git a/apps/web/src/lib/services/common/otel-layer.ts b/apps/web/src/lib/services/common/otel-layer.ts index 22b5226a0..e21b7de9e 100644 --- a/apps/web/src/lib/services/common/otel-layer.ts +++ b/apps/web/src/lib/services/common/otel-layer.ts @@ -47,3 +47,13 @@ const telemetry = MapleFlush.make({ }) export const mapleOtelLayer = telemetry.layer + +/** + * Report an error that never went through an Effect span. + * + * The SDK's global handlers already cover uncaught throws and unhandled + * rejections. This is for the one case they cannot see: React error boundaries + * catch a render crash and, in production, swallow it — so the dashboard would + * paint its crash screen and Maple would never hear about its own outage. + */ +export const captureException = telemetry.captureException diff --git a/apps/web/src/lib/services/common/telemetry.ts b/apps/web/src/lib/services/common/telemetry.ts index d9324f897..a4800fb77 100644 --- a/apps/web/src/lib/services/common/telemetry.ts +++ b/apps/web/src/lib/services/common/telemetry.ts @@ -1,4 +1,4 @@ -import { Data, Effect } from "effect" +import { Effect, Schema } from "effect" import { runtime } from "./runtime" const requestUrl = (input: RequestInfo | URL): string => @@ -78,10 +78,10 @@ export const describeFetchFailure = (input: { return `Fetch failed: ${where}${status}${detail.length > 0 ? ` — ${detail}` : ""}` } -class TracedFetchError extends Data.TaggedError("@maple/web/TracedFetchError")<{ - readonly message: string - readonly cause: unknown -}> {} +class TracedFetchError extends Schema.TaggedError()("@maple/web/TracedFetchError", { + message: Schema.String, + cause: Schema.Defect(), +}) {} export const tracedFetch = ( peerService: string, diff --git a/apps/web/src/lib/services/common/v2-pagination.ts b/apps/web/src/lib/services/common/v2-pagination.ts index 50f6ff74e..d34a01d62 100644 --- a/apps/web/src/lib/services/common/v2-pagination.ts +++ b/apps/web/src/lib/services/common/v2-pagination.ts @@ -1,4 +1,4 @@ -import { Data, Effect } from "effect" +import { Effect, Schema } from "effect" export interface V2Page { readonly data: ReadonlyArray @@ -6,12 +6,13 @@ export interface V2Page { readonly next_cursor: string | null } -export class V2PaginationCursorLoopError extends Data.TaggedError( +export class V2PaginationCursorLoopError extends Schema.TaggedError()( "@maple/web/services/V2PaginationCursorLoopError", -)<{ - readonly cursor: string - readonly message: string -}> { + { + cursor: Schema.String, + message: Schema.String, + }, +) { static repeated(cursor: string): V2PaginationCursorLoopError { return new V2PaginationCursorLoopError({ cursor, diff --git a/lib/effect-cloudflare/src/kv-namespace.ts b/lib/effect-cloudflare/src/kv-namespace.ts index dc9c68716..254ce40c3 100644 --- a/lib/effect-cloudflare/src/kv-namespace.ts +++ b/lib/effect-cloudflare/src/kv-namespace.ts @@ -10,14 +10,17 @@ // API surface matches upstream so `yield* KVNamespace.bind(MY_KV)` is a // source-compatible call. import type * as runtime from "@cloudflare/workers-types" -import * as Data from "effect/Data" +import * as Schema from "effect/Schema" import * as Effect from "effect/Effect" import { WorkerEnvironment } from "./worker-environment.ts" -export class KVNamespaceError extends Data.TaggedError("@maple/effect-cloudflare/KVNamespaceError")<{ - message: string - cause: unknown -}> {} +export class KVNamespaceError extends Schema.TaggedError()( + "@maple/effect-cloudflare/KVNamespaceError", + { + message: Schema.String, + cause: Schema.Defect(), + }, +) {} /** * A reference to a KV namespace binding declared in wrangler.jsonc. diff --git a/lib/effect-cloudflare/src/r2-bucket.ts b/lib/effect-cloudflare/src/r2-bucket.ts index 01e400000..1c269cf25 100644 --- a/lib/effect-cloudflare/src/r2-bucket.ts +++ b/lib/effect-cloudflare/src/r2-bucket.ts @@ -5,16 +5,16 @@ // Account API) and keep the runtime half. `R2Bucket("MY_BUCKET")` is a // lightweight token; `R2Bucket.bind(token)` yields the client. import type * as runtime from "@cloudflare/workers-types" -import * as Data from "effect/Data" +import * as Schema from "effect/Schema" import * as Effect from "effect/Effect" import * as Option from "effect/Option" import * as Stream from "effect/Stream" import { WorkerEnvironment } from "./worker-environment.ts" -export class R2Error extends Data.TaggedError("@maple/effect-cloudflare/R2Error")<{ - message: string - cause: unknown -}> {} +export class R2Error extends Schema.TaggedError()("@maple/effect-cloudflare/R2Error", { + message: Schema.String, + cause: Schema.Defect(), +}) {} export interface R2BucketToken { readonly Type: "Cloudflare.R2Bucket" diff --git a/lib/effect-cloudflare/src/rpc.ts b/lib/effect-cloudflare/src/rpc.ts index ad541b531..d5907f7c8 100644 --- a/lib/effect-cloudflare/src/rpc.ts +++ b/lib/effect-cloudflare/src/rpc.ts @@ -9,9 +9,9 @@ import type * as cf from "@cloudflare/workers-types" import * as Cause from "effect/Cause" -import * as Data from "effect/Data" import * as Effect from "effect/Effect" import * as Option from "effect/Option" +import * as Schema from "effect/Schema" import * as Sink from "effect/Sink" import * as Stream from "effect/Stream" import * as Socket from "effect/unstable/socket/Socket" @@ -41,18 +41,22 @@ export type RpcStreamEnvelope = { body: ReadableStream } -export class RpcDecodeError extends Data.TaggedError("@maple/effect-cloudflare/RpcDecodeError")<{ - readonly cause: unknown -}> { +export class RpcDecodeError extends Schema.TaggedError()( + "@maple/effect-cloudflare/RpcDecodeError", + { cause: Schema.Defect() }, +) { override get message() { return this.cause instanceof Error ? this.cause.message : String(this.cause) } } -export class RpcCallError extends Data.TaggedError("@maple/effect-cloudflare/RpcCallError")<{ - readonly method: string - readonly cause: unknown -}> { +export class RpcCallError extends Schema.TaggedError()( + "@maple/effect-cloudflare/RpcCallError", + { + method: Schema.String, + cause: Schema.Defect(), + }, +) { override get message() { return `RPC call to "${this.method}" failed: ${ this.cause instanceof Error ? this.cause.message : String(this.cause) @@ -60,17 +64,18 @@ export class RpcCallError extends Data.TaggedError("@maple/effect-cloudflare/Rpc } } -class RpcRemoteError extends Data.TaggedError("@maple/effect-cloudflare/RpcRemoteError")<{ - readonly error: unknown -}> { +class RpcRemoteError extends Schema.TaggedError()("@maple/effect-cloudflare/RpcRemoteError", { + error: Schema.Defect(), +}) { override get message() { return remoteErrorMessage(this.error, "Remote RPC call failed") } } -export class RpcRemoteStreamError extends Data.TaggedError("@maple/effect-cloudflare/RpcRemoteStreamError")<{ - readonly error: unknown -}> { +export class RpcRemoteStreamError extends Schema.TaggedError()( + "@maple/effect-cloudflare/RpcRemoteStreamError", + { error: Schema.Defect() }, +) { override get message() { return remoteErrorMessage(this.error, "Remote RPC stream failed") } diff --git a/lib/unitflow/src/db/index.ts b/lib/unitflow/src/db/index.ts index 3d2e28fbc..7c140fa81 100644 --- a/lib/unitflow/src/db/index.ts +++ b/lib/unitflow/src/db/index.ts @@ -22,7 +22,7 @@ import { type InitialQueryBuilder, type QueryBuilder, } from "@tanstack/db" -import * as Data from "effect/Data" +import * as Schema from "effect/Schema" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" import * as Queue from "effect/Queue" @@ -38,10 +38,10 @@ import * as Store from "../core/store.js" * - `load-timeout` — the collection sat in `loading` with no emissions for the * configured `stuckTimeoutMs` (see {@link CollectionWatchOptions}). */ -export class CollectionError extends Data.TaggedError("@unitflow/db/CollectionError")<{ - readonly reason: "load-failed" | "cleaned-up" | "load-timeout" - readonly message: string -}> {} +export class CollectionError extends Schema.TaggedError()("@unitflow/db/CollectionError", { + reason: Schema.Literals(["load-failed", "cleaned-up", "load-timeout"]), + message: Schema.String, +}) {} /** The renderable state of a watched collection. */ export type CollectionState = AsyncResult.AsyncResult, CollectionError> diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 8a4de81f5..4fca3a689 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -20,7 +20,7 @@ import { UnauthorizedError, UserId, } from "@maple/domain/http" -import { Clock, Data, Effect, Option, Redacted, Schema, SchemaGetter } from "effect" +import { Clock, Effect, Option, Redacted, Schema, SchemaGetter } from "effect" /** * A self-hosted session is bounded by TWO clocks, because the HMAC key IS the @@ -769,10 +769,10 @@ export const makeResolveMcpTenant = ( type ClerkUser = Awaited["users"]["getUser"]>> -class ClerkLookupError extends Data.TaggedError("@maple/auth/ClerkLookupError")<{ - readonly operation: string - readonly cause: unknown -}> {} +class ClerkLookupError extends Schema.TaggedError()("@maple/auth/ClerkLookupError", { + operation: Schema.String, + cause: Schema.Defect(), +}) {} const clerkLookup = ( spanName: string, diff --git a/packages/browser/README.md b/packages/browser/README.md index 038adcaf4..8e9f584d1 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -35,7 +35,35 @@ That single call: only once a session is sampled in, so a `sampleRate` below 1 costs the unsampled visitors nothing beyond the base SDK (see [Bundle size](#bundle-size)); - writes session metadata at start (`active`) and on page hide (`ended`), - including the trace ids observed during the session. + including the trace ids observed during the session; +- captures uncaught errors and unhandled promise rejections as error spans, so + browser crashes reach Maple's error tracking. + +## Errors + +Every uncaught error and unhandled rejection becomes a span with status `Error` +and an `exception` event, which is the shape Maple fingerprints — so browser +crashes group beside your server-side errors instead of in a silo. + +Errors your app _catches_ never reach the global handlers, because catching them +is what stops them. Report those explicitly: + +```ts +try { + render() +} catch (error) { + MapleBrowser.captureException(error, { name: "browser.render_error" }) +} +``` + +Opt out of the global handlers with `tracing: { captureErrors: false }` — worth +doing only when another tracker already owns them, or the same crash is recorded +twice. + +A cross-origin script reports to the browser as a bare `"Script error."` with no +stack and no filename. Those are dropped rather than recorded: they all +fingerprint to one contentless issue that buries the real ones. Add +`crossorigin` to the script tag to get the real error instead. ## Bundle size diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 2d858a84e..cf3ff3530 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -41,6 +41,12 @@ export interface MapleBrowserConfig { * sink, and disabling this avoids redundant duplicate network spans. */ readonly instrumentFetch?: boolean + /** + * Capture uncaught errors and unhandled promise rejections as error + * spans. Default true. Turn off only when another tracker already owns + * the page's global error handlers, or the same crash lands twice. + */ + readonly captureErrors?: boolean } readonly replay?: { /** Default true. */ @@ -95,6 +101,7 @@ export interface ResolvedConfig { identity: ResolvedIdentity | undefined readonly tracingEnabled: boolean readonly tracingInstrumentFetch: boolean + readonly tracingCaptureErrors: boolean readonly replayEnabled: boolean readonly replaySampleRate: number readonly maskAllInputs: boolean @@ -131,6 +138,7 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { identity: resolveIdentity(config), tracingEnabled: config.tracing?.enabled ?? true, tracingInstrumentFetch: config.tracing?.instrumentFetch ?? true, + tracingCaptureErrors: config.tracing?.captureErrors ?? true, replayEnabled: config.replay?.enabled ?? true, replaySampleRate: config.replay?.sampleRate ?? 1, maskAllInputs: config.privacy?.maskAllInputs ?? true, diff --git a/packages/browser/src/errors.test.ts b/packages/browser/src/errors.test.ts new file mode 100644 index 000000000..6c63de945 --- /dev/null +++ b/packages/browser/src/errors.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { assert, beforeEach, describe, it } from "vitest" +import { SpanStatusCode, trace } from "@opentelemetry/api" +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base" +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { captureException, setupErrorCapture } from "./errors" + +const exporter = new InMemorySpanExporter() + +const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], +}) +trace.setGlobalTracerProvider(provider) + +const exceptionEventOf = (span: ReadableSpan) => span.events.find((event) => event.name === "exception") + +beforeEach(() => { + exporter.reset() +}) + +describe("captureException", () => { + it("records an Error span with an exception event", () => { + captureException(new TypeError("x is not a function")) + + const [span] = exporter.getFinishedSpans() + assert.strictEqual(span?.name, "exception") + assert.strictEqual(span?.status.code, SpanStatusCode.ERROR) + const event = exceptionEventOf(span!) + assert.strictEqual(event?.attributes?.["exception.type"], "TypeError") + assert.strictEqual(event?.attributes?.["exception.message"], "x is not a function") + }) + + it("normalizes a non-Error rejection reason into something groupable", () => { + // A rejected promise can carry anything. It still has to produce one + // fingerprintable issue rather than throwing inside the handler. + captureException({ message: "plain object failure" }) + captureException("string failure") + + const [fromObject, fromString] = exporter.getFinishedSpans() + assert.strictEqual( + exceptionEventOf(fromObject!)?.attributes?.["exception.message"], + "plain object failure", + ) + assert.strictEqual(exceptionEventOf(fromString!)?.attributes?.["exception.message"], "string failure") + }) + + it("carries a custom name and caller attributes", () => { + captureException(new Error("boom"), { + name: "browser.uncaught_error", + attributes: { "maple.exception.source": "window.onerror" }, + }) + + const [span] = exporter.getFinishedSpans() + assert.strictEqual(span?.name, "browser.uncaught_error") + assert.strictEqual(span?.attributes["maple.exception.source"], "window.onerror") + }) +}) + +describe("setupErrorCapture", () => { + it("captures an unhandled rejection once and stops on teardown", () => { + const stop = setupErrorCapture() + const reason = new Error("rejected") + + const dispatch = () => + window.dispatchEvent( + Object.assign(new Event("unhandledrejection"), { reason, promise: Promise.resolve() }), + ) + dispatch() + // The same error object reaching a handler twice is one issue, not two. + dispatch() + assert.strictEqual(exporter.getFinishedSpans().length, 1) + assert.strictEqual(exporter.getFinishedSpans()[0]?.name, "browser.unhandled_rejection") + + stop() + exporter.reset() + window.dispatchEvent( + Object.assign(new Event("unhandledrejection"), { + reason: new Error("after teardown"), + promise: Promise.resolve(), + }), + ) + assert.strictEqual(exporter.getFinishedSpans().length, 0) + }) + + it("drops an opaque cross-origin script error", () => { + const stop = setupErrorCapture() + // No error object, no filename — "Script error." carries nothing + // actionable and would fingerprint into one issue that buries the rest. + window.dispatchEvent(new ErrorEvent("error", { message: "Script error.", filename: "" })) + assert.strictEqual(exporter.getFinishedSpans().length, 0) + stop() + }) +}) diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts new file mode 100644 index 000000000..b25903b01 --- /dev/null +++ b/packages/browser/src/errors.ts @@ -0,0 +1,110 @@ +// Uncaught-error capture. +// +// Everything else this SDK exports traces something it was asked to trace: a +// fetch, a session, a custom event. An error thrown outside all of that — a +// framework render crash, a throw in an event handler, a floating rejected +// promise — had no path into Maple at all, which left the one signal a customer +// most wants from a browser SDK missing. +// +// Each error becomes a one-off span carrying an `exception` event and status +// Error. That is the shape `error_events_mv` fingerprints on, so these arrive in +// error tracking beside server-side errors rather than in a separate silo. +import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api" +import { SDK_NAME, SDK_VERSION } from "./version" + +export interface CaptureExceptionOptions { + /** Span name. Default `"exception"`. */ + readonly name?: string | undefined + /** Extra span attributes. */ + readonly attributes?: Record | undefined +} + +const asError = (value: unknown): Error => { + if (value instanceof Error) return value + if (typeof value === "string") return new Error(value) + if (typeof value === "object" && value !== null) { + const message = (value as { readonly message?: unknown }).message + if (typeof message === "string") return new Error(message) + } + // A rejected promise can carry literally anything. `String` keeps a number or + // a boolean legible; an unrenderable object still produces one grouped issue + // rather than throwing inside the error handler. + try { + return new Error(String(value)) + } catch { + return new Error("Unknown error") + } +} + +/** + * Record an error that no span was watching. Safe before `init()` — without a + * registered provider the OTel API hands back a no-op tracer and this does + * nothing. + */ +export function captureException(error: unknown, options: CaptureExceptionOptions = {}): void { + const normalized = asError(error) + const span = trace.getTracer(SDK_NAME, SDK_VERSION).startSpan(options.name ?? "exception", { + kind: SpanKind.INTERNAL, + attributes: { + ...(typeof location !== "undefined" ? { "url.full": location.href } : undefined), + ...options.attributes, + }, + }) + span.recordException(normalized) + span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) + span.end() +} + +/** + * Register global handlers for uncaught errors and unhandled rejections. + * Returns a teardown that removes them. + */ +export function setupErrorCapture(): () => void { + if (typeof window === "undefined" || typeof window.addEventListener !== "function") { + return () => {} + } + + // One error must not become two issues. The same throw can reach both + // handlers (a rejected promise whose reason is later rethrown), and a host + // app's own boundary may report it through `captureException` as well. + const reported = new WeakSet() + const seen = (error: unknown): boolean => { + if (typeof error !== "object" || error === null) return false + if (reported.has(error)) return true + reported.add(error) + return false + } + + const onError = (event: ErrorEvent): void => { + // A cross-origin script surfaces as a bare "Script error." with no error + // object and no usable frames. It fingerprints to one meaningless issue + // that buries the real ones; the fix is `crossorigin` on the script tag, + // not a noisier error tracker. + const error: unknown = + event.error ?? (event.message && event.filename ? new Error(event.message) : undefined) + if (error === undefined || seen(error)) return + captureException(error, { + name: "browser.uncaught_error", + attributes: { + "maple.exception.source": "window.onerror", + ...(event.filename ? { "code.filepath": event.filename } : undefined), + ...(event.lineno ? { "code.lineno": event.lineno } : undefined), + }, + }) + } + + const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + if (seen(event.reason)) return + captureException(event.reason, { + name: "browser.unhandled_rejection", + attributes: { "maple.exception.source": "unhandledrejection" }, + }) + } + + window.addEventListener("error", onError) + window.addEventListener("unhandledrejection", onUnhandledRejection) + return () => { + window.removeEventListener("error", onError) + window.removeEventListener("unhandledrejection", onUnhandledRejection) + } +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 57748c851..0daf7b695 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,8 +1,10 @@ import { type IdentifyInput, setConsent, type TrackProps, track } from "@maple/browser-session" +import { captureException } from "./errors" import { identify, init, type MapleBrowserHandle } from "./init" export type { IdentifyInput, MapleIdentity, TrackProps, TraitValue } from "@maple/browser-session" export type { MapleBrowserConfig } from "./config" +export type { CaptureExceptionOptions } from "./errors" export type { MapleBrowserHandle } from "./init" /** @@ -35,9 +37,18 @@ export const MapleBrowser: { * starts. */ track: (name: string, props?: TrackProps) => void + /** + * Report an error your app already caught — the case the global handlers + * cannot see, because catching it is what stops it reaching them. A + * framework error boundary is the canonical caller. + * + * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw + * anything. `captureException` narrows it before it reaches a span. + */ + captureException: (error: unknown, options?: import("./errors").CaptureExceptionOptions) => void /** Grant or revoke consent when `privacy.requireConsent` is on. */ setConsent: (granted: boolean) => void -} = { init, identify, track, setConsent } satisfies { +} = { init, identify, track, captureException, setConsent } satisfies { init: (config: import("./config").MapleBrowserConfig) => MapleBrowserHandle /** * Attach, replace, or clear the end-user identity on the active session. @@ -50,6 +61,15 @@ export const MapleBrowser: { * starts. */ track: (name: string, props?: TrackProps) => void + /** + * Report an error your app already caught — the case the global handlers + * cannot see, because catching it is what stops it reaching them. A + * framework error boundary is the canonical caller. + * + * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw + * anything. `captureException` narrows it before it reaches a span. + */ + captureException: (error: unknown, options?: import("./errors").CaptureExceptionOptions) => void /** Grant or revoke consent when `privacy.requireConsent` is on. */ setConsent: (granted: boolean) => void } diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 371b33031..d0854ce7e 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -25,6 +25,7 @@ import { import type { ReplaySessionHandle } from "@maple/browser-session/replay" import { trace } from "@opentelemetry/api" import { type MapleBrowserConfig, type ResolvedConfig, resolveConfig } from "./config" +import { setupErrorCapture } from "./errors" import { setupTracing } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -74,6 +75,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { let stopped = false let rotateOnNextStart = false let shutdownTracing: (() => Promise) | undefined + let stopErrorCapture: (() => void) | undefined // Bumped by every start and stop, so a replay chunk that lands after a // consent revoke (or a rotation) never attaches a recorder to a dead runtime. let generation = 0 @@ -95,6 +97,12 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { session.id, ) if (config.tracingEnabled && !shutdownTracing) shutdownTracing = setupTracing(config) + // After `setupTracing`: the handlers span through the global provider it + // registers, so registering them first would drop the errors of the very + // first moments into a no-op tracer. + if (config.tracingEnabled && config.tracingCaptureErrors && !stopErrorCapture) { + stopErrorCapture = setupErrorCapture() + } const shared = { endpoint: config.endpoint, ingestKey: config.ingestKey, @@ -191,6 +199,8 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { stopped = true stopConsentListener() await stopRuntime(true) + stopErrorCapture?.() + stopErrorCapture = undefined await shutdownTracing?.() shutdownTracing = undefined setActiveTraceIdProvider(() => undefined) diff --git a/packages/browser/src/tracing.test.ts b/packages/browser/src/tracing.test.ts index 2c731558d..e71edbd4a 100644 --- a/packages/browser/src/tracing.test.ts +++ b/packages/browser/src/tracing.test.ts @@ -34,6 +34,7 @@ const CONFIG = { identity: undefined, tracingEnabled: true, tracingInstrumentFetch: false, + tracingCaptureErrors: false, replayEnabled: false, replaySampleRate: 0, maskAllInputs: true, diff --git a/packages/effect-sdk/README.md b/packages/effect-sdk/README.md index 382552dd8..1439ba98b 100644 --- a/packages/effect-sdk/README.md +++ b/packages/effect-sdk/README.md @@ -106,6 +106,35 @@ const program = Effect.log("Hello!").pipe(Effect.withSpan("hello")) Effect.runPromise(program.pipe(Effect.provide(TracerLive))) ``` +### Uncaught errors (built in) + +`MapleFlush.make` from `/client` registers `error` and `unhandledrejection` +handlers, so a throw that never went through an Effect span still reaches error +tracking. Each one becomes a span with status `Error` and an `exception` event — +the same shape a failed Effect span produces, so browser crashes group beside +server-side errors rather than in a silo. + +Turn it off with `captureGlobalErrors: false` when another tracker already owns +the page's global handlers. + +An error your app _catches_ never reaches those handlers — catching it is what +stops it. Report those explicitly; a React error boundary is the usual caller, +and without this a boundary-caught crash is invisible in production: + +```typescript +const telemetry = MapleFlush.make({ serviceName: "my-frontend", ... }) + +class ErrorBoundary extends Component { + componentDidCatch(error: unknown, info: ErrorInfo) { + telemetry.captureException(error, { + name: "browser.react_error_boundary", + attributes: { "maple.react.component_stack": info.componentStack ?? "" }, + }) + } + // … +} +``` + ### Session replay & sessions (built in) The browser presets (`Maple.layer` and `MapleFlush.make`) record **rrweb session replays by default** — no separate browser SDK needed. Every span carries a `session.id`, the session appears in Maple's Sessions UI with its linked traces, and the recording is playable next to them. diff --git a/packages/effect-sdk/src/client/flushable.ts b/packages/effect-sdk/src/client/flushable.ts index 035a2136e..df994146e 100644 --- a/packages/effect-sdk/src/client/flushable.ts +++ b/packages/effect-sdk/src/client/flushable.ts @@ -17,7 +17,7 @@ import { } from "../shared/flush-core.js" import { type LogBuffer, makeLogBuffer } from "../shared/flushable-logger.js" import { makeMetricBuffer } from "../shared/flushable-metrics.js" -import { makeSpanBuffer, type SpanBuffer } from "../shared/flushable-tracer.js" +import { type CaptureExceptionOptions, makeSpanBuffer, type SpanBuffer } from "../shared/flushable-tracer.js" import { browserDocument, browserNavigator } from "./browser-globals.js" import { type ClientReplayConfig, startClientSession } from "./replay-loader.js" import { withSessionLink } from "./session-link.js" @@ -92,6 +92,16 @@ export interface MapleClientFlushableConfig { * sampleRate 1 and inputs masked — set `{ enabled: false }` to opt out. */ readonly replay?: ClientReplayConfig | undefined + /** + * Capture uncaught errors and unhandled promise rejections from the page and + * record them as error spans. Default `true`. + * + * Without this the SDK only ever sees failures that happened *inside* an + * Effect span, which in a browser is the minority of them — a React render + * crash, a throw in an event handler and a floating rejected promise all + * bypass Effect entirely and would otherwise never reach Maple. + */ + readonly captureGlobalErrors?: boolean | undefined /** * Consent gating, persistent-visitor-id storage, and whether `identify()`'s * email reaches the warehouse. Defaults capture everything except where a @@ -108,6 +118,16 @@ export interface FlushableTelemetry { * instrumented code. */ readonly layer: Layer.Layer + /** + * Record an error that never passed through an Effect span — the escape + * hatch for the places a browser throws outside Effect. The canonical caller + * is a React error boundary, which catches the error and, unless it reports + * it here, is the reason nobody ever hears about the crash. + * + * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw + * anything. It is narrowed on the way into the exception event. + */ + readonly captureException: (error: unknown, options?: CaptureExceptionOptions) => void /** Drain the buffers and POST them now (keepalive). Never rejects. */ readonly flush: () => Promise /** Remove unload listeners, stop the auto-flush timer, then do one final flush. */ @@ -260,6 +280,60 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => ;(timer as { unref?: () => void }).unref?.() } + /** + * One error reaching two paths must still be one issue. React rethrows a + * boundary-caught error in development, so a boundary that reports it *and* + * `window.onerror` would otherwise fingerprint the same crash twice. + */ + const reported = new WeakSet() + const captureException = (error: unknown, options: CaptureExceptionOptions = {}): void => { + if (typeof error === "object" && error !== null) { + if (reported.has(error)) return + reported.add(error) + } + const page = globalThis.location?.href + spans.captureException(error, { + ...options, + attributes: { + ...(page !== undefined ? { "url.full": page } : undefined), + ...options.attributes, + }, + }) + } + + const onWindowError = (event: ErrorEvent): void => { + // A cross-origin script reports as a bare "Script error." with no error + // object, no usable frames and no filename. It fingerprints to a single + // meaningless issue that buries the real ones, so it is dropped rather + // than recorded — the fix for those is CORS on the script tag, not a + // louder error tracker. + const error: unknown = + event.error ?? (event.message && event.filename ? new Error(event.message) : undefined) + if (error === undefined) return + captureException(error, { + name: "browser.uncaught_error", + attributes: { + "maple.exception.source": "window.onerror", + ...(event.filename ? { "code.filepath": event.filename } : undefined), + ...(event.lineno ? { "code.lineno": event.lineno } : undefined), + }, + }) + } + + const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + captureException(event.reason, { + name: "browser.unhandled_rejection", + attributes: { "maple.exception.source": "unhandledrejection" }, + }) + } + + const canCaptureGlobals = + (config.captureGlobalErrors ?? true) && typeof globalThis.addEventListener === "function" + if (canCaptureGlobals) { + globalThis.addEventListener("error", onWindowError) + globalThis.addEventListener("unhandledrejection", onUnhandledRejection) + } + const onPageHide = (): void => { void flush() } @@ -281,10 +355,14 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => globalThis.removeEventListener("pagehide", onPageHide) globalThis.removeEventListener("visibilitychange", onVisibilityChange) } + if (canCaptureGlobals) { + globalThis.removeEventListener("error", onWindowError) + globalThis.removeEventListener("unhandledrejection", onUnhandledRejection) + } stopConsentListener() await flush() await clientSession.stop() } - return { layer, flush, dispose } + return { layer, captureException, flush, dispose } } diff --git a/packages/effect-sdk/src/shared/flushable-tracer.test.ts b/packages/effect-sdk/src/shared/flushable-tracer.test.ts index 6a098c00c..19ecf3523 100644 --- a/packages/effect-sdk/src/shared/flushable-tracer.test.ts +++ b/packages/effect-sdk/src/shared/flushable-tracer.test.ts @@ -185,3 +185,60 @@ describe("makeSpanBuffer restore", () => { }), ) }) + +const attributeOf = (span: { attributes: ReadonlyArray<{ key: string; value: unknown }> }, key: string) => + span.attributes.find((attribute) => attribute.key === key)?.value + +describe("makeSpanBuffer captureException", () => { + it("records a thrown error as an Error span with an exception event", () => { + const buffer = makeSpanBuffer() + buffer.captureException(new TypeError("Cannot read properties of undefined (reading 'spans')")) + + const [span] = buffer.drain() + assert.isDefined(span) + assert.strictEqual(span?.name, "exception") + // StatusCode 2 is Error — `error_events_mv` keys off exactly this. + assert.strictEqual(span?.status.code, 2) + + const event = span?.events.find((candidate) => candidate.name === "exception") + assert.isDefined(event) + const attribute = (key: string) => event?.attributes.find((candidate) => candidate.key === key)?.value + assert.deepStrictEqual(attribute("exception.type"), { stringValue: "TypeError" }) + assert.deepStrictEqual(attribute("exception.message"), { + stringValue: "Cannot read properties of undefined (reading 'spans')", + }) + }) + + it("carries caller attributes and a custom span name", () => { + const buffer = makeSpanBuffer() + buffer.captureException(new Error("boom"), { + name: "browser.uncaught_error", + attributes: { "maple.exception.source": "window.onerror" }, + }) + + const [span] = buffer.drain() + assert.strictEqual(span?.name, "browser.uncaught_error") + assert.deepStrictEqual(attributeOf(span!, "maple.exception.source"), { + stringValue: "window.onerror", + }) + }) + + it("is not silenceable through anticipatedErrorIdentifiers", () => { + // An uncaught throw is never an anticipated 4xx. Recording it as a defect + // keeps it clear of that filter, so a caller cannot accidentally suppress + // real crashes by listing a tag. + const buffer = makeSpanBuffer({ anticipatedErrorIdentifiers: new Set(["Error", "TypeError"]) }) + buffer.captureException(new TypeError("still an error")) + + const [span] = buffer.drain() + assert.strictEqual(span?.status.code, 2) + assert.isDefined(span?.events.find((candidate) => candidate.name === "exception")) + }) + + it("stays silent while capture is disabled by consent", () => { + const buffer = makeSpanBuffer() + buffer.setDisabled(true) + buffer.captureException(new Error("boom")) + assert.strictEqual(buffer.size(), 0) + }) +}) diff --git a/packages/effect-sdk/src/shared/flushable-tracer.ts b/packages/effect-sdk/src/shared/flushable-tracer.ts index 89d393df3..cab1d594f 100644 --- a/packages/effect-sdk/src/shared/flushable-tracer.ts +++ b/packages/effect-sdk/src/shared/flushable-tracer.ts @@ -4,13 +4,40 @@ // resource, and headers are NOT baked in here — the caller (the Cloudflare, // server, or client flushable preset) resolves them and POSTs the drained // buffer on `flush`, so the layer itself can be constructed without I/O. -import { Cause, type Context, Layer, type Option, Predicate, Tracer } from "effect" +import { Cause, Context, Exit, Layer, Option, Predicate, Tracer } from "effect" import * as ErrorReporter from "effect/ErrorReporter" import * as OtlpResource from "effect/unstable/observability/OtlpResource" import type { ExtractTag } from "effect/Types" +export interface CaptureExceptionOptions { + /** Span name. Default `"exception"`. */ + readonly name?: string | undefined + /** Extra span attributes (e.g. the URL the error happened on). */ + readonly attributes?: Record | undefined +} + export interface SpanBuffer { readonly tracerLayer: Layer.Layer + /** + * Record a thrown value that never passed through an Effect span. + * + * Everything else in this buffer arrives because an Effect *span* failed — + * which means an error thrown outside Effect had no path here at all. In a + * browser that is most of them: a React render crash caught by an error + * boundary, a throw in an event handler, a rejected promise nobody awaited. + * + * The error is recorded as a one-off span carrying a `Die` cause, so it takes + * the same road as every other failure: `makeOtlpSpan` gives it status + * `Error` and an `exception` event with type/message/stacktrace, which is + * exactly the shape `error_events_mv` fingerprints on. A `Die` (not a `Fail`) + * because an uncaught throw is by definition not an anticipated failure — + * that also keeps it clear of the `anticipatedErrorIdentifiers` filter, which + * would otherwise let a caller silence real crashes by tag. + * + * BOUNDARY: a thrown value is unparsed by definition — JavaScript can throw + * anything. `Cause.prettyErrors` narrows it on the way into the event. + */ + readonly captureException: (error: unknown, options?: CaptureExceptionOptions) => void readonly drain: () => Array readonly restore: (items: ReadonlyArray) => void readonly setDisabled: (value: boolean) => void @@ -89,8 +116,26 @@ export const makeSpanBuffer = (options: SpanBufferOptions = {}): SpanBuffer => { }, }) + const captureException = (error: unknown, captureOptions: CaptureExceptionOptions = {}): void => { + if (disabled) return + const now = BigInt(Date.now()) * 1_000_000n + const span = makeSpan({ + name: captureOptions.name ?? "exception", + parent: Option.none(), + annotations: Context.empty(), + status: { _tag: "Started", startTime: now }, + attributes: new Map(Object.entries(captureOptions.attributes ?? {})), + links: [], + sampled: true, + kind: "internal", + export: exportFn, + }) + span.end(now, Exit.failCause(Cause.die(error))) + } + return { tracerLayer: Layer.succeed(Tracer.Tracer, tracer), + captureException, drain: () => { const items = buffer buffer = []