Skip to content

Capture browser exceptions, and stop dumping whole causes into logs - #551

Merged
Makisuo merged 1 commit into
mainfrom
fix/browser-error-capture-and-cause-logging
Aug 20, 2026
Merged

Capture browser exceptions, and stop dumping whole causes into logs#551
Makisuo merged 1 commit into
mainfrom
fix/browser-error-capture-and-cause-logging

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

An audit of error handling across every app, package and lib turned up five gaps. The architecture itself is sound — 172 Schema.TaggedError declarations against zero Effect.catchAll, two symmetric HTTP error boundaries, and no unwrap()/expect() 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 at all, 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.

  • @maple-dev/effect-sdk routes them through a Die cause so they take the same road as every other failure. That also keeps them clear of anticipatedErrorIdentifiers: a caller cannot silence a real crash by listing a tag.
  • @maple-dev/browser does the same over the vanilla OTel API, behind tracing.captureErrors.
  • 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.

Two deliberate exclusions:

  • 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; the fix for those is crossorigin on the script tag.
  • The route boundary reports only unclassifiable errors (isUnexpectedError). A recognized API or network failure already has a failed client span from the Effect layer, so reporting it again would fingerprint the same outage twice. Stale-chunk errors are also skipped — that is a deploy artifact, not a bug.

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 whole statement that failed. summarizeToolFailure already fixed exactly this for the model transcript; this is the same reasoning applied to the logging path, which is where most of the calls actually were.

Severity note for reviewers: these logs are Maple's own — apps/api and apps/alerting self-instrument through a single MAPLE_INGEST_KEY into the internal org — so this is about log volume, cost and groupability, not confidentiality. The genuinely customer-facing instance of this bug was the chat transcript, and that was already fixed.

The new summarizeCause is a thin wrapper over Cause.prettyErrors — the same normalizer Cause.pretty is built 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 Effect's own toString/JSON fallbacks, so a thrown string, number or bare object still reads as something. Reading only those two fields is what leaves the stack and the nested cause behind. It is the same primitive the SDK's tracer already uses to build its exception events, so a log line and its span now agree on what a failure is called instead of deriving the name two different ways.

summarizeToolFailure drops its duplicate hand-rolled narrowing for the same call, keeping its deliberate typed-failures-only filter (a defect's message should not reach the model).

Three smaller ones

  • 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 that file's own docstring promising 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 sitting in the API's own error path. Production code is now at zero.

Verification

Verified end-to-end against the running dashboard rather than only in tests: dispatched both global handler paths and inspected the actual OTLP payload — status.code: 2 with exception.type / exception.message / exception.stacktrace populated, plus url.full, maple.exception.source, code.filepath and code.lineno.

Full repo typecheck (40/40) and lint pass. Suites green: api (1447), web (1738), effect-sdk (105), browser (17), auth (68), alerting (8), unitflow (141).

For reviewers

  • lib/effect-cloudflare has no test suite, so its four converted error classes are covered by typecheck alone. Those files carry "stay API-compatible with alchemy-effect for a future migration" comments; I treated them as obsolete since CLAUDE.md records that upstream stopped publishing in April 2026. Easy to revert if you disagree.
  • toDatabaseError bakes the driver's root-cause message — including the host and port it was dialing — into DatabaseError.message itself. I left that: it is the actual diagnostic, and it is staff-only per the severity note above. A test now asserts the behavior so it is a decision rather than an accident.
  • Two intentional behavior changes in summarizeCause versus a first draft: interrupts render as InterruptError: … (every call site guards with Cause.hasInterruptsOnly first, so they do not arrive), and there is no defect prefix — the name already carries it, since a defect shows as TypeError/Error while an expected failure shows a @maple/… tag.
  • packages/effect-sdk is consumed as built dist, so it was rebuilt for the web typecheck; dist is gitignored and not in this diff.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…s into logs

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.
@Makisuo
Makisuo merged commit feb48f2 into main Aug 20, 2026
32 checks passed
@Makisuo
Makisuo deleted the fix/browser-error-capture-and-cause-logging branch August 20, 2026 17:15
@Makisuo
Makisuo deployed to pr-preview August 20, 2026 17:15 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit f14cc4f · View workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant