Capture browser exceptions, and stop dumping whole causes into logs - #551
Merged
Merged
Conversation
…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.
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An audit of error handling across every app, package and lib turned up five gaps. The architecture itself is sound — 172
Schema.TaggedErrordeclarations against zeroEffect.catchAll, two symmetric HTTP error boundaries, and nounwrap()/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.onerrororunhandledrejectionhandler, and nothing anywhere called anything likecaptureException. Both only ever emitted anexceptionevent 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
Errorcarrying anexceptionevent — the shapeerror_events_mvfingerprints on — so browser crashes group beside server-side errors instead of in a silo.@maple-dev/effect-sdkroutes them through aDiecause so they take the same road as every other failure. That also keeps them clear ofanticipatedErrorIdentifiers: a caller cannot silence a real crash by listing a tag.@maple-dev/browserdoes the same over the vanilla OTel API, behindtracing.captureErrors.captureException, and the web app's two boundaries call it.Two deliberate exclusions:
"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 iscrossoriginon the script tag.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.prettywas rendering whole causes into log annotationsAt 34 sites. It walks
Error.causechains inline, so aDatabaseError— whosecauseis the raw postgres.js error — dragged the driver's options into the annotation, and a warehouse failure dragged in the whole statement that failed.summarizeToolFailurealready 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/apiandapps/alertingself-instrument through a singleMAPLE_INGEST_KEYinto 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
summarizeCauseis a thin wrapper overCause.prettyErrors— the same normalizerCause.prettyis built on, stopped one step earlier. It hands back anErrorper reason withnameresolved to the tag (@maple/api/lib/DatabaseError) andmessageresolved through Effect's owntoString/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 itsexceptionevents, so a log line and its span now agree on what a failure is called instead of deriving the name two different ways.summarizeToolFailuredrops 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
DatabaseErrordeclaredcause: Schema.Unknown— the only such site in the repo, against 27 that useSchema.Defect().Unknownhas no encoded form, so anything serializing aDatabaseErrorserialized the raw driver object. NowSchema.Defect({ excludeCause: true }).MobilePushServiceused a bareEffect.ignore, against that file's own docstring promising every failure is a log line. A droppedmarkPushedbuzzes the same phone about the same incident again with nothing saying why; a droppeddisablekeeps 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.Data.TaggedErrorclasses 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: 2withexception.type/exception.message/exception.stacktracepopulated, plusurl.full,maple.exception.source,code.filepathandcode.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-cloudflarehas 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.toDatabaseErrorbakes the driver's root-cause message — including the host and port it was dialing — intoDatabaseError.messageitself. 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.summarizeCauseversus a first draft: interrupts render asInterruptError: …(every call site guards withCause.hasInterruptsOnlyfirst, so they do not arrive), and there is nodefectprefix — the name already carries it, since a defect shows asTypeError/Errorwhile an expected failure shows a@maple/…tag.packages/effect-sdkis consumed as builtdist, so it was rebuilt for the web typecheck;distis gitignored and not in this diff.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.