diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8736419d..8cf5a785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,10 +155,12 @@ jobs: # Playwright ships a downloader, not the browsers: `pnpm install` does not fetch them, so # `browser.e2e.ts` fails with "Executable doesn't exist at .../chrome-headless-shell". - # Scoped to chromium — it is the only engine the browser harness drives. + # Chromium drives the browser harness; Firefox and WebKit join it for the async-origin e2e (the engines differ in WHEN the media error + # event fires, in the stack format, and WebKit eliminates tail calls, which is exactly what that feature depends on). WebKit needs + # its system libraries, hence --with-deps. # On a fresh hosted VM this always downloads; it is a few seconds, not a failure. - - name: Install Playwright's Chromium - run: pnpm --filter @bugsee/instrumentation-tests exec playwright install chromium + - name: Install Playwright's browsers (Chromium, Firefox, WebKit) + run: pnpm --filter @bugsee/instrumentation-tests exec playwright install --with-deps chromium firefox webkit # The nuxt/sveltekit/astro harnesses each boot a real dev server on a FIXED port. A fresh hosted VM # starts clean, so this only guards a hung harness within one run (and a developer's machine, where diff --git a/biome.json b/biome.json index e6deda5c..118207a2 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,13 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!samples", "!.local-registry", "!packages/protocol/spec"] + "includes": [ + "**", + "!samples", + "!.local-registry", + "!packages/protocol/spec", + "!docs/design/async-origin-capture/**" + ] }, "formatter": { "enabled": true, diff --git a/docs/design/async-origin-capture.md b/docs/design/async-origin-capture.md new file mode 100644 index 00000000..5df0ff1f --- /dev/null +++ b/docs/design/async-origin-capture.md @@ -0,0 +1,589 @@ +# Async origin capture — giving stackless rejections a call site + +Status: IMPLEMENTED in PR #176; this is the design that was built, and §11-§14 record where building it differed and what real engines taught. The as-built parts cite files (`packages/capture/...`, `scripts/async-origin/...`, `packages/astro/src/hydration.ts`, and so on) that exist once #176 has merged; this document and #176 are meant to land together, #176 first. Trigger: staging report BGSVIEWER-14 — an `unhandledrejection` whose +reason is `NotSupportedError: The element has no supported sources.` with `crash.json.exception.frames = []`. + +## 1. The problem is real, and it is not in the SDK's parsing + +`HTMLMediaElement.play()` on an element that cannot load rejects **from a media task, with no JavaScript on the stack**. The +`DOMException` is a genuine `Error` but its `.stack` is `undefined` (Chromium 156) — there is nothing to parse and nothing to +recover at detection time. The only place the call site exists is **the moment the API is called**. + +It is not specific to `play()`. Measured with `probe.mjs` (this directory; headless Chromium 156.0.8078.4 and Firefox 151; WebKit +was not part of this probe; it was run later in the e2e, §12): + +| Reason of the rejection | Chromium | Firefox | +|---|---|---| +| User code: `async` throw / `Promise.reject(new Error)` / `new Promise` + `reject(new Error)` | frames, call site named | frames, call site named | +| User code: `Promise.reject('str')` / `Promise.reject()` / `Promise.reject({..})` | **no stack** (not an Error) | **no stack** | +| Platform API, rejected **synchronously** during argument validation (bad constraints, bad URL, bad algorithm name, bad key data, `OffscreenCanvas(0,0).convertToBlob`, `RTC.setRemoteDescription(bad)`, `permissions.query(bogus)`) | frames, call site named | frames, call site named | +| `fetch` network failure (`TypeError: Failed to fetch`) | frames, call site named | frames, call site named | +| Platform API that fails **later, asynchronously** | see §2 | fewer, see §2 | + +**Conclusion: user-code rejections of `Error`s need no Promise patching** — their stack is created in user code and survives to +`unhandledrejection`. Only (a) non-`Error` user rejections and (b) platform async failures lack a location. + +## 2. The stackless set (measured) + +`frames = 0` means the reason's `.stack` is absent or only `Name: message`. + +| API | Reason | Chromium | Firefox | +|---|---|---|---| +| `HTMLMediaElement.play` | NotSupportedError | **stackless** | has call site | +| `Clipboard.readText` / `writeText` (and `read`/`write`) | NotAllowedError | **stackless** | (did not reject in the probe) | +| `WakeLock.request` | NotAllowedError | **stackless** | (did not reject) | +| `ServiceWorkerContainer.register` | TypeError | **stackless** | **stackless** | +| `RTCPeerConnection.addIceCandidate` (state error) | InvalidStateError | **stackless** | has call site | +| `FileSystemDirectoryHandle.getFileHandle` (after an `await`) | TypeError | **stackless** | has call site | +| `HTMLImageElement.decode` | EncodingError | **stackless** | **stackless** | +| `BaseAudioContext.decodeAudioData` | EncodingError | **stackless** | **stackless** | +| `FontFace.load` | NetworkError | **stackless** | **stackless** | +| `FontFaceSet.load` (bad shorthand) | SyntaxError | **stackless** | has call site | +| `MediaDevices.getUserMedia` (device not found) | NotFoundError | **stackless** | **stackless** | +| `Worklet.addModule` (`CSS.paintWorklet`) | AbortError | **stackless** | (threw synchronously) | +| `createImageBitmap` (undecodable) | InvalidStateError | **stackless** | **stackless** | +| `Body.json` (invalid JSON) | SyntaxError | (no rejection seen) | **stackless** | +| dynamic `import()` failure (404 / syntax error) | TypeError / SyntaxError | **stackless** | **stackless** | +| `WebAssembly.compile` / `instantiate` | CompileError | 1 frame, **no user frame** | has call site | +| `structuredClone` inside a `.then` | DataCloneError | 1 frame, **no user frame** | has call site | + +The pattern: **the more asynchronous the failure (media tasks, permission prompts, network/decoder work, workers), the less +likely a stack exists, and Chromium is much worse than Firefox.** WebKit was measured later, in the e2e (§12): `HTMLMediaElement.play()` is stackless there too (Chromium-like: the e2e's call-site frames come from the wrapper, not from the engine), and WebKit also eliminates tail calls, which is why the e2e scenarios are non-tail on purpose. Do not read it as "like Firefox", whose `play()` row above has a call site. + +The last two rows are NOT part of the set the wrapper fixes: `structuredClone` is synchronous (its `DataCloneError` is thrown inside the +caller's own `.then`, not from a platform task), and a `WebAssembly.*` failure has a frame, only none of the application's. Neither is +wrapped. + +`import()` is syntax, not a function — it **cannot be patched**. Its message already carries the URL +(`Failed to fetch dynamically imported module: `), which is the useful part. + +## 3. The complete candidate list + +Every promise-returning method declared in TypeScript's `lib.dom.d.ts` (6.0.3): **80 interfaces, 187 methods** (the generator also finds +the stream readers/writers, `ReadableStream.cancel/pipeTo` and `LockManager.request`, which the hand-made list below omits; all eleven are +classified `off`). The list below is the original hand-made one; the generated snapshot is `packages/capture/spec/dom-promise-methods.snapshot.json` +and the classified table is `packages/capture/src/async-origin-table.ts`. A method that can +reject without a stack is any of these whose failure is not argument validation. This list is the patch table's source of truth +and must be regenerated, not hand-maintained (§5). + +``` +AudioContext: close,resume,suspend +AudioDecoder: flush AudioEncoder: flush +BaseAudioContext: decodeAudioData +Blob: arrayBuffer,bytes,text +Body: arrayBuffer,blob,bytes,formData,json,text (mixin → Request.prototype, Response.prototype) +CSSStyleSheet: replace +Cache: add,addAll,delete,keys,match,matchAll,put +CacheStorage: delete,has,keys,match,open +Clipboard: read,readText,write,writeText +ClipboardItem: getType +CookieStore: delete,get,getAll,set +CookieStoreManager: getSubscriptions,subscribe,unsubscribe +CredentialsContainer: create,get,preventSilentAccess,store +CustomElementRegistry: whenDefined +Document: exitFullscreen,exitPictureInPicture,hasStorageAccess,requestStorageAccess +Element: requestFullscreen,requestPointerLock +FileSystemDirectoryHandle: getDirectoryHandle,getFileHandle,removeEntry,resolve +FileSystemFileHandle: createWritable,getFile +FileSystemHandle: isSameEntry +FileSystemWritableFileStream: seek,truncate,write +FontFace: load FontFaceSet: load +GPU: requestAdapter GPUAdapter: requestDevice +GPUBuffer: mapAsync GPUDevice: createComputePipelineAsync,createRenderPipelineAsync,popErrorScope +GPUQueue: onSubmittedWorkDone GPUShaderModule: getCompilationInfo +GamepadHapticActuator: playEffect,reset +HTMLImageElement: decode +HTMLMediaElement: play,setMediaKeys,setSinkId +HTMLVideoElement: requestPictureInPicture +IDBFactory: databases +ImageCapture: getPhotoCapabilities,getPhotoSettings,grabFrame,takePhoto +ImageDecoder: decode +LockManager: query +MIDIPort: close,open +MediaCapabilities: decodingInfo,encodingInfo +MediaDevices: enumerateDevices,getDisplayMedia,getUserMedia +MediaKeySession: close,generateRequest,load,remove,update +MediaKeySystemAccess: createMediaKeys +MediaKeys: getStatusForPolicy,setServerCertificate +MediaSession: setCameraActive,setMicrophoneActive +MediaStreamTrack: applyConstraints +NavigationPreloadManager: disable,enable,getState,setHeaderValue +Navigator: requestMIDIAccess,requestMediaKeySystemAccess,share +NavigatorBadge: clearAppBadge,setAppBadge +NavigatorLogin: setStatus +OfflineAudioContext: resume,startRendering,suspend +OffscreenCanvas: convertToBlob +PaymentRequest: abort,canMakePayment,show +PaymentResponse: complete,retry +Permissions: query +PushManager: getSubscription,permissionState,subscribe +PushSubscription: unsubscribe +RTCPeerConnection: addIceCandidate,createAnswer,createOffer,getStats,setLocalDescription,setRemoteDescription +RTCRtpReceiver: getStats +RTCRtpSender: getStats,replaceTrack,setParameters +ReadableStreamGenericReader: cancel +RemotePlayback: cancelWatchAvailability,prompt,watchAvailability +Scheduler: postTask,yield +ScreenOrientation: lock +ServiceWorkerContainer: getRegistration,getRegistrations,register +ServiceWorkerRegistration: getNotifications,showNotification,unregister,update +StorageManager: estimate,getDirectory,persist,persisted +SubtleCrypto: decrypt,deriveBits,deriveKey,digest,encrypt,exportKey,generateKey,importKey,sign,unwrapKey,verify,wrapKey +VideoDecoder: flush VideoEncoder: flush VideoFrame: copyTo +WakeLock: request WakeLockSentinel: release +WebTransport: createBidirectionalStream,createUnidirectionalStream +WindowOrWorkerGlobalScope: createImageBitmap,fetch (mixin → Window / WorkerGlobalScope) +Worklet: addModule +``` + +Not in `lib.dom.d.ts` but real: `window.show{Open,Save}FilePicker`, `showDirectoryPicker`, `queryLocalFonts`, `Notification.requestPermission`, +`Document.fonts.ready`, `ServiceWorker`/`Client.postMessage` (no promise), `WebAssembly.{compile,instantiate,*Streaming}`, +`navigator.{bluetooth,usb,hid,serial}.*`, `Atomics.waitAsync`, `Response`/`Request` bodies, `structuredClone` (sync). +Add them by hand with a source note. + +## 4. Design (v2 — revised after an independent review, §9) + +There is no hook at the origin other than the call (`ReportingObserver`, `PerformanceObserver`, `console.createTask`, +`Error.prepareStackTrace` were checked: none yields anything for a media-task rejection), so the SDK wraps each listed method. + +### 4.1 The wrapper + +The trap that shapes everything: **attaching ANY reaction to a promise marks it handled**, so a side branch +(`p.then(noop, record)`) silently disables `unhandledrejection` for the app. The wrapper therefore returns a derived promise through a **pristine `then`**, and falls back to a no-reaction mode only when no +pristine `then` exists (§4.1a): + +```ts +// derived mode: return a DERIVED promise (same reason object) built with a PRISTINE then; key the origin by reason +const origin = { error: new Error(), target: this, method }; // stack formatted lazily, only on failure +const p = original.apply(this, args); +if (!isThenable(p)) return p; +const then = pristineThen(p); // §4.1a — NEVER the global Promise.prototype.then +if (then !== undefined) return then.call(p, undefined, (r) => { if (isObject(r)) byReason.set(r, origin); throw r; }); + +// fallback (no pristine then available): attach NO reaction; key the origin by the returned promise +const p = original.apply(this, args); +if (isObject(p)) byPromise.set(p, origin); +return p; // look up with event.promise +``` + +Why this shape (measured, Chromium 156 and Firefox 151): +- With zone.js 0.14.10 / 0.16.3 loaded, plain `p.then` returns a `ZoneAwarePromise` and the derived promise's rejection **never reaches + `unhandledrejection`**; a bare native `play()` rejection still does. A pristine `then` (§4.1a) gives a native derived promise, the + event fires with the same reason object, and the lookup by reason succeeds (Chromium and Firefox, bare and native `await`). +- The no-reaction fallback only locates a **bare** `p` (`event.promise === p`). `await v.play()`, `play().then(...)` and a + downleveled `__awaiter` all surface a different `event.promise`, so the lookup misses (measured everywhere). It is a last resort, + not a peer mode. +- The derived promise settles one microtask later than the original. + +Not used: a `Proxy` (apply trap +3.3 µs in Firefox, 20 µs cold), `Error.stackTraceLimit` tuning (capture cost is flat across limits; +20% memory only, and it contradicts `packages/core/src/stack.ts:283-285`), side-branch reactions. + +### 4.1a Obtaining a pristine `then` (measured; changes the earlier design) + +Capturing `Promise.prototype.then` **from the global** and calling it on the platform's promise is wrong whenever the global `Promise` was +replaced: with zone.js, core-js forced over a deleted native `Promise`, Bluebird (its UMD overwrites `window.Promise`), es6-promise +and RSVP, the captured `then` throws a `TypeError` **synchronously inside the wrapped `play()`** ("Incompatible receiver", +"this._then is not a function", "reading 'push'"). A wrapper must never be able to break the app call, so: + +1. Per call, take the `then` from the promise the platform returned, in this order: `p.__zone_symbol__then` (zone.js stores the native + one on native promises), else a `then` captured **at install**: the ENGINE INTRINSIC (the prototype of the promise an `async` + function returns, which no library can replace), else the global one if it is still native, else a hidden iframe's, else none. A + `toString`-based "is it native" test on the GLOBAL is not trusted: core-js patches `Function.prototype.toString` so its own `then` + reads native (§12). The mode is chosen once at install and each step is covered by a unit test. +2. Call it inside a `try`; if it throws or returns a non-thenable, return the original `p` untouched (no origin recorded) — a + failed wrapper degrades to no wrapper, never to a broken call. +3. Install order matters: if the SDK loads before zone.js, capturing at install is enough; after, rely on `__zone_symbol__then` or + the iframe realm. Without a document (worker) only the first two apply. + +Plain `p.then` under zone is the regression to avoid: the native event is lost for a bare `play()`, and the reason only reaches +`NgZone.onError` / `ErrorHandler`. + +### 4.2 Capture and retention + +- `new Error()` at call time (about 0.7 µs in Chromium, 2 µs in Firefox), then `Error.captureStackTrace(holder, wrapper)` where the + engine supports it, so the wrapper's own frame is not in the stack. Formatting `.stack` (2.7-3.8 µs) happens only when a + rejection with no frames is reported. +- Retained per origin: about 600 B (Chromium, depth 8). The origin is the VALUE of a `WeakMap` keyed by the reason, so it lives as long + as the reason does (not only until the promise settles); the `target` it references (a media element) is kept alive the same way, + which matters to an app that keeps a rejected `DOMException` in a list. +- `remember` stores only a reason that is an object **without usable frames**; nothing is retained for a rejection that already + has a stack. "Usable frames" means the browser's stack parser finds at least one frame (V8 indented `at` lines; Firefox/Safari + `name@url:line:col`); a header-only stack (`NotAllowedError: msg`, Chromium's async DOMExceptions) is stackless, and header text that + merely contains an `@` or starts "at least" is not a frame. The recorder takes that predicate from the parser (one definition, §14). +- An API that rejects with a stack made INSIDE the call (argument validation) has the wrapper as the top frame of that stack, which as the + top `user` frame would decide grouping; the enricher drops frames at the wrapper's own call site (found by a probe, §12). + +### 4.3 What is reported: the call site AND the target + +For `play()` the call site is the weaker signal; **`this` carries the root cause**. After the failure, in Chromium the element held +`networkState = 3`, `error.code = 4` and `error.message = "PipelineStatus::DEMUXER_ERROR_COULD_NOT_OPEN: FFmpegDemuxer: open context failed"`; in Firefox `"Failed to open media"`. So the origin keeps `target` (a strong reference, held as long as the origin is, §4.2) and the report adds a +description read **at report time** (the media `error` event fires before the rejection in Chromium and after it in Firefox, so +never at call time): tag/id, scrubbed `currentSrc`, `networkState`, `readyState`, `error.code/message`. Per-API describers are a +small table next to the method table (media element, image, font face, audio context, service worker container, ...); an API with +no describer reports the call site only. + +~~A zero-cost complement: one capture-phase `error` listener on `document` recording failing media/image loads as a system event.~~ Dropped (see §7, step 6): it would need a new spec event name and adds timeline noise for little over the Target line. + +### 4.4 Gating: arm on first failure, not a rate breaker + +The earlier proposal stopped capturing above ~200 calls/s. That is inverted: in a hot loop the first calls are usually the +successful ones, and the breaker trips exactly when failures begin. + +- **Rare set** (play, clipboard, wakeLock, getUserMedia, `serviceWorker.register`, pickers, worklet, `decodeAudioData`, + file-system handles): always capture. +- **AS BUILT (step 4):** an `armed` row is not wrapped at all until `recorder.arm()` is called, which the provider does on the first + stackless rejection nobody could locate. That is cheaper than the pass-through below (zero cost before the first failure, not one + boolean) and needs no per-call reaction to learn that a method failed. The trade-off: arming is for ALL armed rows at once, not the + one that failed, so afterwards the hot rows pay for capture too, bounded by the per-method budget (200 captures per minute per method, + reset by one shared 60 s timer). Verified in headless Chromium 156 with and without zone.js: a bare `play()` still raises + `unhandledrejection`, the origin is found by reason, and the recorded stack starts at the caller's frame. +- **Every other row: armed after the first stackless rejection nobody could locate** (mechanism: `recorder.arm()`, called by the + enricher, which every report path runs; arming covers all armed rows at once). Before that an armed row is not wrapped at all. The + accepted price: the FIRST stackless rejection of an armed API is reported without a call site (and without a target line, which + needs an origin), because nothing was recording yet; from the next one on the call site is captured. That is what buys zero cost before + any failure; the rare rows (`always`) have no such gap. A rejection that the application handles and never reports does not arm: there is + nothing to locate. Arming happens when a stackless reason reaches ANY report (`unhandledrejection`, window `error`, `logException`, + a framework error channel), through the enricher. +- A per-method **capture budget** (a counter reset by one shared 60 s timer, not `Date.now()` per call) bounds the cost when a + armed method fails in a loop. + +### 4.5 How the origin reaches `crash.json` (the seam) + +`syntheticFrames` is **not** the seam. It is read only when the thrown value is not an `Error` +(`packages/core/src/crash.ts:333-339`), and a `DOMException` is an `Error` in both engines, so BGSVIEWER-14 takes +`buildException` (`crash.ts:196-206`), where `parsed = []` and the only extension point is `enrichFrames` +(`type FrameEnricher`, `crash.ts:85`; chained by `composeFrameEnrichers`, `crash.ts:93`). + +- Add an `asyncOriginEnricher: FrameEnricher` that returns the origin's frames when `parsed.length === 0` (and the reason has an + origin), tagged `origin: 'call'` (DECIDED, §8; carried by `StackFrame`, `CrashFrame` and `toCrashFrame`). Frames an enricher supplies get their `debug_id` in `buildCrashJson`, because the worker symbolicates before it signs (§13). +- Thread it into the **browser** `crashOf` the way node already does (`packages/node/src/detection-providers.ts:44-54` passes + `enrichFrames`); as built, the browser providers pass `enrichFrames` (`packages/browser/src/detection-providers.ts`), as does the browser `launch()` for `logException`. +- There are TWO `describeError`s and both need the fallback (origin frames + the §4.3 target description) or the report has frames but a + bare description: the browser provider's (`packages/browser/src/detection-providers.ts`, for `unhandledrejection` and window `error`) and + core's (`packages/core/src/client.ts`, which `logException` uses and so every framework adapter, §10.3). The core one also takes the target + line through a `describeTarget` option, and puts the call site BEFORE a linked `cause` (React's component stack). +- The same enricher must run on the **handled** path (`logException` / `captureException`, `packages/core/src/client.ts`), and on + every framework adapter that captures an error instead of letting it reach `unhandledrejection` (§10): a stackless rejection that + a framework swallows into its own error handler is exactly as useless as BGSVIEWER-14. +- The origin LOOKUP walks `reason.cause` (a few levels, bounded; `originOf`) when the reason itself has none. That recovers a call site + only for a wrapper that keeps the original platform error as `.cause` (ky, ofetch); axios's XHR adapter has no `.cause` and Mithril's + `m.request` wraps in a new `Error` with none, so those stay unlocated (§10.4). +- **One origin per reason: the FIRST wins.** Two pending wrapped calls that reject with the same object (an app re-rejecting a cached + error) share one entry, and the report names the first call site recorded, not necessarily the call that produced this rejection. Accepted + and documented; keeping a collection per reason would cost memory for a case that is rare. +- Lookup at report time is **by reason, everywhere** (the reason object is the only thing that survives every path below); by + `event.promise` only in the fallback mode, bare call only. +- Paths that carry the same stackless reason without ever raising `unhandledrejection` (measured, §10): Vue `app.config.errorHandler` + (Vue attaches a `.catch` to promises returned by handlers/hooks/watchers), React 19 `use()` and error boundaries + (`onCaughtError`, `componentDidCatch`), Alpine and a React root with no boundary (the window `error` event → + `crashOfErrorEvent`, `detection-providers.ts:54`), TanStack Query (thrown to the caller), zone.js chains (`NgZone.onError`). + `buildCrashJson` must therefore get the origin on **every** Error path, not only the `unhandledrejection` provider. + +### One factory for every method, and what it costs (measured) + +All patched methods go through ONE factory, `wrapPromiseMethod(prototype, name, options)`, which does the lookup, the guarded +define, the idempotency mark, the `toString` delegation, the restore and the wrapper above. A new API is then one table row, not +new code. The table row carries only data: `{ iface, methods, mode, targets?, why }`, with `mode` one of `always` / `armed` / `off` (§4.4); `targets` names the constructors for a WebIDL mixin. + +Measured with `bench.mjs` (this directory) on a cheap real async call (`crypto.subtle.digest`, 20 000 calls, median of 5): + +| | Chromium 156 | Firefox 151 | +|---|---|---| +| native call | 1.2 µs | 4.3 µs | +| wrapper (`apply` + `.then`), no `Error` | 1.37 µs | 6.7 µs | +| **wrapper + `new Error()` (proposed)** | **2.33 µs** | **9.05 µs** | +| hand-written specialised wrapper + `Error` | 2.11 µs | 9.05 µs | +| `new Error()` alone / `new Error().stack` | 0.71 / 1.66 µs | 1.44 / 1.60 µs | + +- **The unified factory costs nothing measurable** over a hand-written wrapper (2.33 vs 2.11 µs Chromium; identical in Firefox): the + JIT specialises the closure per method. Unifying is free, and it is also the only way to keep the table maintainable. +- **Where the overhead goes** (independent measurements): in Chromium the derived `then` is about +10 ns and `new Error()` about +0.7 µs. In + Firefox the derived `then` itself costs +2.4 µs and `new Error()` about +4 µs more, so a no-reaction mode is about 3x cheaper there + (+0.7 µs). Reading `.stack` is paid only on a rejection that has no frames. +- That is noise for `play`, `getUserMedia`, clipboard, `decode` (a few calls per page) and visible only for APIs called thousands of + times a second, which is exactly why those rows are `armed` (§4.4) and not `always`. Moving a row between the three modes is a data change in the table, + not code. +- Not measured: real-page effects of the extra microtask, and the retained-`Error` memory under very many pending promises. + +### Which listed methods can sit in a tight loop + +Judged from how apps use them (not measured per app); the SDK's own loops use +`ReadableStreamDefaultReader.read()` (fetch/bundle body streaming) and `blob.arrayBuffer()` once per bundle, and those rows are `off`. The +SDK's own calls to a wrapped (`always`/`armed`) method are rare and one-off (the disk-pressure watch calls `navigator.storage.estimate()` +periodically, an `always` row): they pass through the wrapper like the application's and spend a little of its budget. + +| Class | Methods | Where the loop comes from | Default | +|---|---|---|---| +| **Hot** (per frame / per chunk / per task) | `Scheduler.postTask`, `Scheduler.yield`; `FileSystemWritableFileStream.write/seek/truncate`; `GPUBuffer.mapAsync`, `GPUQueue.onSubmittedWorkDone`, `GPUDevice.popErrorScope`; `ImageCapture.grabFrame`, `ImageDecoder.decode`, `VideoFrame.copyTo`, `Video/AudioDecoder/Encoder.flush`; `OffscreenCanvas.convertToBlob`; `createImageBitmap` | frame-rate media/GPU pipelines, cooperative schedulers, chunked file writes | **`armed`** (§4.4): not wrapped until the first stackless rejection nobody could locate, then captured with the per-method budget (as built, every row in this class is `armed`, including `createImageBitmap`, measured stackless in both engines) | +| **Hot in bulk** | `SubtleCrypto.*` (hashing/verifying many chunks or tokens), `Blob.arrayBuffer/bytes/text` and `Body.*` (`slice().arrayBuffer()` per chunk; `response.json()` per request on chatty apps), `Cache.*` / `CacheStorage.match` (a service worker's fetch handler runs per request) | loops over thousands of items | **off** (opt-in). `Body.json` is measured stackless on Firefox (§2) and is deliberately NOT wrapped: it is hot in bulk, so that one gap is accepted. `fetch` itself is covered by the network interceptor, never wrapped twice | +| **Bursty** | `HTMLImageElement.decode` (galleries), `CustomElementRegistry.whenDefined`, `CSSStyleSheet.replace` (one per component at startup), `FontFaceSet.load` (per text measure in some canvas libs), `RTCPeerConnection.getStats/addIceCandidate/replaceTrack/setParameters`, `CookieStore.*`, `MediaStreamTrack.applyConstraints` (per slider tick) | hundreds in a burst, then quiet | `armed` (§4.4) for the measured-stackless ones (`decode`, `FontFace.load`, `addIceCandidate`) | +| **Rare** | `HTMLMediaElement.play`, `Clipboard.*`, `WakeLock.request`, `getUserMedia`, `ServiceWorkerContainer.register`, `Permissions.query`, `FileSystem*Handle.get*Handle`, `Worklet.addModule`, `decodeAudioData`, picker APIs | user-driven, a few per page | **on** | + +**Gating replaces the breaker (see §4.4).** Frequency is a property of the app, not the API, so a static list is always wrong for +someone. Hot and bursty rows are armed only after the first stackless rejection nobody could locate, so a loop of successful calls pays +nothing, and a loop that starts failing pays the capture cost for a bounded number of calls (the per-method budget). In a pure tight loop over the cheapest native call +the capture alone roughly doubles the per-call cost (1.2 to 2.3 µs in Chromium); that is the case the arming exists for. + +### Cost and risk (what could make this a bad idea) +1. **Per-call overhead** of `new Error()` (microseconds). Fine for `play`, `getUserMedia`, `clipboard`; wrong for hot APIs — `fetch` (the network interceptor already wraps it; reuse its capture, don't add a second), `Scheduler.postTask/yield`, `FileSystemWritableFileStream.write`, `Blob/Body.*`, `SubtleCrypto.*` in loops, `ReadableStream` readers. The patch table's `mode` column decides (`always` / `armed` / `off`, §3-§4.4); there is no separate `denyHot` list. +2. **One-microtask delay** and a new promise identity: code comparing `video.play() === ...` or relying on exact microtask order can change. Rare, documented. +3. **Detectability:** `Function.prototype.toString` on a patched method is not `[native code]`; libraries that feature-detect natively can misjudge. Mitigate by wrapping with a `toString` that delegates to the original; still not a guarantee. +4. **Third-party patchers**: zone.js patches `then`, not `play`, so "compose with patchers" is not enough — it needs the pristine-`then` derived promise of §4.1/§4.1a (a measured regression otherwise). For other patchers of the same methods: wrap idempotently, mark ours with a symbol, and compose rather than replace. + Patches are per realm: iframes and workers are not covered unless the SDK installs there too; `HTMLMediaElement` and `Clipboard` do not exist in workers, `FontFace`, `Cache` and `SubtleCrypto` do — install only where `globalThis[ctor]?.prototype[method]` exists. +5. **Memory:** an origin holds one `Error` (stack formatted lazily) and the `target`; it is released when the REASON is garbage-collected (§4.2). +6. Must not throw, must survive a frozen/non-writable prototype (guarded define), and must restore on `stop()`. + +## 5. Keeping the table honest + +- (BUILT) `scripts/async-origin/extract.mjs` generates the candidate list from `lib.dom.d.ts` (`pnpm async-origin:refresh`); a WebIDL mixin is an interface with no `declare var`; the drift test is `packages/instrumentation-tests/test/async-origin-drift.test.ts` (it lives there, not in capture, because it reads files and capture is runtime-portable). +- Generate the candidate list from `lib.dom.d.ts` (or `@webref/idl`, the real WebIDL) in a script; mixins (`Body`, + `WindowOrWorkerGlobalScope`) resolve to the concrete prototypes that expose them. +- A **drift test** (same pattern as `spec-drift.test.ts`): generated list vs the committed table, so a new TypeScript release that + adds an API forces a decision (always / armed / ignore). +- Install only where `globalThis[ctor]?.prototype[method]` exists (workers differ: `HTMLMediaElement` and `Clipboard` are absent, + `FontFace`, `Cache`, `SubtleCrypto` present — measured). +- **Tests.** Factory unit tests: `this`/`length`/`name`/`toString` fidelity, idempotency symbol, restore on stop, frozen prototype, + synchronous throw and non-thenable passthrough, mode selection. Provider tests for both lookups (by reason, by promise). Real-browser + e2e in `@bugsee/instrumentation-tests` (headless, Chromium **and** Firefox): per always-on API, trigger an asynchronous rejection and + assert the report has the origin frame naming the test's call-site function plus the target description. A **zone.js composition + e2e** (serve the `zone.js` UMD from the repo's pnpm store) asserting a bare `play()` rejection still produces a report; and a + composition test for each custom Promise implementation found in §10. + +## 6. Non-`Error` user rejections + +`Promise.reject('x')`, `throw 'x'` in an async function, `reject({code})`: no stack anywhere, both engines. Patching the global +`Promise` constructor to capture a stack on every `reject()` is far more invasive than §4 (it touches every promise in the page +and every library) and is **not recommended**. Cheaper and sufficient: (a) describe the value (`typeof`, constructor name, a +bounded JSON preview) in the summary/description instead of `String(value)`; (b) optionally wrap only the **static** +`Promise.reject` to record a stack for non-`Error` reasons (a static, rarely hot). Executor-style `reject('x')` stays unlocated. + +## 7. Plan + +1. **Non-`Error` description** (§6a) — independent, can land first. +2. **Table + generator + drift test**, no behaviour change. +3. **`wrapPromiseMethod` factory with the pristine-then derived mode (no-reaction fallback), arming and budget** (browser-only shell; runtime-portable core in `@bugsee/capture`), + option `captureAsyncOrigins` (default on for the always-on set, opt-out), install/restore by the browser/webworker/electron-renderer + `launch`. +4. **`asyncOriginEnricher`** + browser `crashOf` + `describeError` + `logException` (§4.5), and the per-API target describers (§4.3). +5. **Framework adapters** (§10): route the enricher through every adapter path that captures a rejection itself. **DONE, with no per-adapter code:** every client-side adapter reports through `reportError` in `@bugsee/web-adapter`, which calls `client.logException(error)` with the ORIGINAL error object, and `logException` applies the enricher (step 5). Proven in real Chromium and Firefox for React 18 (`BugseeErrorBoundary`) and Vue 3 (`errorHandler`) in `test/async-origin.adapters.browser.e2e.ts`; Angular, Solid and Svelte use the same funnel (not run in a browser); Next/Remix/Nuxt/SvelteKit re-export React/Vue/Svelte. Server-side `reportServerError` is Node and out of scope. +6. ~~`document` capture-phase `error` listener → system event.~~ **DROPPED (owner, 2026-11-03):** it needs a new `events.system` name in the platform spec for a timeline-only signal; the call site and the Target line (steps 1-5) already cover the failing `play()`/decode case. +7. e2e (§5) - **BUILT for `HTMLMediaElement.play()`** (`test/async-origin.browser.e2e.ts`: headless Chromium AND Firefox, plain page and zone.js-first page; unhandled and `logException` paths). Later also built: WebKit, Bluebird and core-js, nine other APIs, React and Vue adapters (§12). Viewer: no change needed (§13): the call site is the primary stack. + +## 8. Open questions +- **Protocol (DECIDED, owner 2026-11-02):** a new optional `origin: 'call'` field on the frame ("this is where the API was called, not + where an exception was thrown"). Not a fixed marker frame name. The viewer will adopt the field. The schema gets the field plus a drift test. +- Default-on vs opt-in: measured value is high (BGSVIEWER-14 itself) and the always-on set is rare-call; recommend ON with opt-out. +- WebKit: measured since, in the e2e (§12). +- `Function.prototype.toString` on a wrapped method is not `[native code]` unless the wrapper delegates; it remains detectable. +- Per-realm: iframes are not covered; workers need their own install. +- RESOLVED: under zone.js the origin is found BY REASON (the derived promise rejects with the same reason object), verified in the e2e on a + zone.js-first page in three engines. Only the no-reaction fallback (keyed by promise) misses awaited chains. +- Node/Bun/Deno: native promise APIs there reject with stacks created in JS; not measured and out of scope here. + +## 9. Independent review (what changed from v1) + +An independent review (a second model, its own measurements) agreed that wrapping is the only hook and found these defects in v1, +all now reflected above: (1) the derived-promise wrapper loses `unhandledrejection` under zone.js (measured) → pristine-`then` derived promise, §4.1/§4.1a (the first review proposed two modes; the framework survey, §10, showed capturing the global `then` itself breaks `play()` when `Promise` is replaced); +(2) `syntheticFrames` is the wrong seam for a `DOMException` → `enrichFrames`, §4.5; (3) the rate breaker is inverted → arm on first +failure plus a budget, §4.4; (4) Firefox's cost is the derived `then` as much as the `Error` → a no-reaction mode, §4.1; (5) `Proxy` and +`stackTraceLimit` tuning buy nothing, §4.1; (6) the target (`this`) is the stronger signal for `play()` and v1 discarded it, §4.3. + + +## 10. Frameworks and custom Promise implementations (measured, headless Chromium 156 / Firefox 151) + +Question: do React, Vue, Svelte, Solid, Preact, Angular and the replacement-Promise libraries change what the wrapper and +`unhandledrejection` see? **No per-library code path is needed**, provided (a) the wrapper uses a pristine `then` and can never throw +into the app call (§4.1a), (b) the origin is looked up **by reason** at every consumer (§4.5), and (c) the SDK's framework adapters +pass the origin through the same crash builder. + +### 10.1 What each library does to a platform promise + +| Library | What it does to promises | Effect on `unhandledrejection` | Extra handling | +|---|---|---|---| +| Plain native; core-js with native present | does not replace `Promise` | fires, same reason | none | +| core-js forced over a deleted native `Promise` | own `Promise`; dispatches an untrusted synthetic event for its own promises | fires for its own; for the native `p`, `p.then` is native | never use the global `then` | +| Bluebird | UMD replaces `window.Promise`; own untrusted event | `Bluebird.resolve(p)` handles `p`; its event carries the same reason, not `p` | lookup by reason | +| es6-promise, RSVP | global replaced only if the app does it; **no DOM event** | silent for those chains (baseline gap: the SDK sees nothing) | none for the wrapper | +| zone.js (0.14.10, 0.16.3) | `ZoneAwarePromise`; patches native `then`; reports to console and `NgZone.onError` | native `p` unhandled: fires. Zone chains: **never fires** (also for a derived promise made with plain `p.then`) | pristine then (§4.1a); lookup is by REASON, which a derived promise preserves (the promise identity changes, the reason does not) | +| zone.js with `__zone_symbol__DISABLE_WRAPPING_UNCAUGHT_PROMISE_REJECTION=false` | wraps zone rejections | event fires for zone chains, but `event.reason` is then a wrapper object `{rejection, promise, zone, task}` with the DOMException on `.rejection` | pristine then keeps the promise path working; the by-reason lookup on `event.reason` misses: **not recoverable, accepted** (opt-in zone flag; the default does not wrap) | +| Vue 3 dev, with `errorHandler` | `callWithAsyncErrorHandling` runs `.catch` on handler/hook/watcher promises | **does not fire**; `errorHandler` gets the same reason | lookup by reason | +| Vue 3 dev, no `errorHandler` | `logError` rethrows | fires, same reason | none | +| Vue 3 prod, no `errorHandler` | `console.error` only | **does not fire → invisible** | the adapter is required | +| React 18/19 event handler | ignores the returned promise | fires, same reason | none | +| React 19 `use()` | attaches `then`, rethrows in render | does not fire; boundary gets the same reason; no boundary → window `error` | lookup by reason | +| TanStack Query | handles the promise internally | unhandled `fetchQuery`: fires; caught/observer errors: same reason | none | +| Redux Toolkit `createAsyncThunk` | serializes the error | `unwrap()` rejects with a **plain object, not the reason**: identity lookup misses | not recoverable: a serialised object has no identity link to the reason, so a name+message match cannot be built on a `WeakMap` store; accepted gap | +| jQuery 4 | `$.when`/`Deferred` pass the same reason to `.fail`; adopted native promises are handled | no event | none | +| Alpine | `.catch` → `setTimeout(throw)` | window `error` event, same reason; no `unhandledrejection` | `crashOfErrorEvent` | +| Solid | `castError` keeps Errors; `createResource` handles | reaches `ErrorBoundary` | none | +| Svelte 5 `{#await}` (source) | no `catch` block rethrows from the `then` callback | fires, same reason; silent with a `catch` block | none | +| Next.js / Nuxt client | listeners only check redirects / show a dev overlay; none `preventDefault` | no effect | the React / Vue adapters | +| Angular | `NgZone.onError` → `ErrorHandler`; `provideBrowserGlobalErrorListeners()` is opt-in and `preventDefault`s | native event still fires without it; zoneless is the v22 default (`core.mjs:1994`) but `samples/angular-spa` ships zone.js ~0.14.10 | pristine then; `NgZone.onError` carries the same reason object, so the by-reason lookup succeeds | + +### 10.2 "Custom Promise implementations" + +They fall into three groups, and none needs bespoke wrapper code: +- **They replace `globalThis.Promise`** (Bluebird by default, core-js when forced, zone.js's `ZoneAwarePromise`): the only hazard is the + wrapper calling the captured global `then` on a native promise — fixed by §4.1a. The platform keeps returning **native** promises, so the + wrapper still sees a native `p`. +- **They produce thenables or chains that never reach `unhandledrejection`** (es6-promise, RSVP, jQuery `Deferred`, Vue's handler + `.catch`, React's `use`): the reason is delivered through the library's or the framework's own channel. That is the SDK adapters' + job (§10.3); the wrapper's contribution is only that the **same reason object** carries the origin. +- **Transpiled async** (`__awaiter`, regenerator): changes which promise `event.promise` is, which is why the lookup is by reason, not by promise. + +### 10.3 SDK adapter paths that must apply the enrichment + +Every one of these is a route by which a stackless reason arrives; each must reach `buildCrashJson` with the origin lookup +(`asyncOriginEnricher`, §4.5): + +| Path | Where | +|---|---| +| `unhandledrejection` provider | `packages/browser/src/detection-providers.ts` | +| `error` event provider (Alpine, React root with no boundary) | `crashOfErrorEvent`, `detection-providers.ts:54` | +| `logException` / `captureException` | `packages/core/src/client.ts` | +| React error boundary / `reportReactError` | `packages/react/src/error-boundary.ts` | +| Vue `installBugseeErrorHandler`; Nuxt chains its own `errorHandler` (`entry.js:43`) | `packages/vue`, `packages/nuxt` | +| Angular `BugseeErrorHandler` (`reportError` → `logException`) | `packages/angular/src/error.ts` | +| Solid `solidErrorHandler`; SvelteKit `handleErrorWithBugsee` | `packages/solid`, `packages/sveltekit` | + +**Vue is the one that needs a documented requirement (now in `packages/vue/README.md`):** in production with no `app.config.errorHandler`, Vue only `console.error`s and +the SDK sees nothing, with or without this work; the adapter must be installed. + +### 10.4 The remaining frameworks and libraries (measured: headless Chromium 156, ~17 key cases also Firefox 151) + +**Answer: no library replaced the reason object on the platform-rejection path (`play()`), and none replaced `Promise` or +`Promise.prototype.then`** (NPO only when the native `Promise` is deleted first; the pristine-then wrapper still tagged the reason +there). Two kinds of extra handling emerge, neither library-specific code: + +1. **Identity breakers — network-error wrappers.** `axios` (1.21.0), `ky` (2.2.0), `ofetch` (1.5.1) and Mithril `m.request` wrap the + `fetch`/XHR failure in their own class; the original is only on `.cause` (axios with its **XHR adapter has no `.cause` at all**, + so the original is lost; Mithril `m.request` wraps in a new `Error`). These wrap *network* errors, not platform promises like + `play()`, but a stackless `TypeError: Failed to fetch` behind them is the same problem. **The origin lookup walks `reason.cause` a few + levels deep** as a fallback to the by-reason lookup; it helps only where the original is kept on `.cause` (ky, ofetch), not for axios's XHR + adapter or Mithril. +2. **Invisible-error gaps — the library swallows the error and the SDK sees nothing, with or without this work.** Listed in §10.5 so the + adapters/docs can address them. + +| Library (version) | Method | Effect on the promise | `unhandledrejection` | Same reason reaches its own channel? | SDK path / gap | +|---|---|---|---|---|---| +| Svelte 3.59.2, 4.2.20 | run | `{#await}` calls `.then(ok, err)`; with no `{:catch}` it rethrows (`await_block.js:70`) | fires with no `{:catch}`; silent with one; `onMount(async)`, `on:click(async)`, `$:` async fire | yes (`{:catch}`) | `packages/svelte` (no `{#await}` hook) | +| Lit 3.3.3 + `@lit/task` 1.0.3 | run | Task catches into state | **silent** with no `render({error})`/`onError`; `until()` and async lifecycles fire | yes (`render`, `onError`, `.error`) | **GAP** (no Lit adapter) | +| Ember 7.4.0 / RSVP 4.8.5 | run (bundled ember-source modules, not a full app) | `RSVP.resolve(p)` adopts `p`; the default `RSVP.on('error')` handler throws in a backburner microtask | fires with a **different `event.promise`**, same reason; `Ember.onerror` gets the same reason instead and nothing fires | yes; later-added `RSVP.on('error')` handlers never run | **GAP** (no Ember adapter) | +| Preact 11.1.1 + compat + preact-iso 2.12.2 | run | compat `lazy` rethrows into the boundary; **iso `lazy` hangs and fires twice** (derived promises, same reason) | handlers fire; compat `lazy` with no boundary → window `error` | yes for compat `lazy`, `useErrorBoundary`, `componentDidCatch`; **no** for iso `lazy` (`ErrorBoundary.onError` never called) | `packages/react` (preact-compat test exists) | +| SWR 2.5.1 | run | catches into hook state | none unless unhandled (`mutate(key, p)`, `trigger()`, `preload` fire) | yes (`onError`, `error`, Suspense) | `packages/react` | +| React Router 7.18.5 | run | data router catches loader/action/lazy errors | none | yes (`useRouteError`, `useAsyncError`, `onError`) | `packages/remix` (`bugseeOnError`) | +| React Router 6.30.4 / Remix 2.17.5 | router run; Remix runtime source only | as RR7; **no `onError` prop in v6** | none (console.error only without an `ErrorBoundary`) | yes | **GAP** (needs a route `ErrorBoundary`) | +| Astro 7.4.4 | source, not run | island hydration errors are caught and re-dispatched as a cancelable `astro:hydration-error` `CustomEvent` (`detail.error` is the same object) plus `console.error` | none | yes (`detail.error`) | **FIXED** (§10.5): `packages/astro` listens for it | +| Q 1.5.1 / 2.0.3 | run | Q-owned chains; adopts `p` on `Q.resolve` | none for `Q.resolve(p)`+`.then` (v2: `Q.resolve(p)` alone fires) | yes (`.fail`, `.done`, `Q.onerror`) | **GAP** | +| NPO 0.8.1 | run | no-op if native exists; with native deleted it installs its own `Promise` | normal with native; silent with native deleted (`.catch` gets the reason) | yes | **GAP** | +| Alpine 3.17.5 | run | evaluator catches, warns, rethrows via window `error` | window `error` (and `unhandledrejection` for a returned promise), same reason | yes | `crashOfErrorEvent`; the two events for one reason are not deduped (§10.5) | +| htmx 2.0.11, Stimulus 3.2.2 | run | ignore the returned promise (Stimulus `handleError`: sync throws only) | fire | n/a | browser provider | +| Mithril 2.3.8 | run | handlers ignore it; **`m.route` `onmatch` rejection swallowed** (redirect + `console.error`); `m.request` wraps | handlers fire; `onmatch` silent | handlers yes; `onmatch` no; `m.request` no | **GAP** (`m.request` wraps with no `.cause`) | +| Inferno 10.0.0, Knockout 3.5.3, Backbone 1.6.1, Underscore 1.13.8, jQuery 4.0.0 | run | no promise machinery in the tested paths | async handlers fire | n/a | browser provider | +| p-queue 10.0.0, p-retry 8.0.1 | run | pass the same reason through | fires if unhandled | yes | n/a | +| axios 1.21.0, ky 2.2.0, ofetch 1.5.1 | run (network failure) | wrap in `AxiosError`/`NetworkError`/`FetchError` | fires with the wrapper type | **no**, except via `.cause` (axios XHR: none) | the lookup walks `.cause` (not for axios XHR) | +| RxJS 7.8.2 | run | `from(p)` forwards to `subscribe` | none (error → window `error` with no callback); `firstValueFrom` unhandled fires | yes, in all 9 cases (`config.onUnhandledError` too) | n/a | +| Qwik 1.20.3, Stencil 4.45.2, Marko 6.4.7 | source only | Qwik passes the same reason to `onRejected`; **Stencil: a rejecting async `componentWillLoad/Render` goes to a bare `console.error` and bypasses `setErrorHandler`**; Marko not traced | not run | Qwik yes, Stencil yes | **GAP** | + +### 10.5 Invisible-error gaps found (independent of this feature) + +These swallow the error so the SDK sees nothing today; the origin work does not change that, but each is worth an adapter hook or a +documented requirement: +- **Lit `@lit/task`** with no `render({error})`/`onError`; **SWR** with no `onError`; **React Router 6 / Remix v2** with no `ErrorBoundary` + (`console.error` only); **Mithril `onmatch`**; **Q**; **Stencil** async lifecycle; **Vue production** without `errorHandler` (§10.3). +- **Astro:** the cancelable `astro:hydration-error` event carries the same reason and nothing listened for it. **FIXED:** `listenForAstroHydrationErrors` in `packages/astro/src/hydration.ts`, installed by `registerClient`; it reports the event's error (same object) without cancelling it. +- **Ember:** the default `RSVP.on('error')` throws before later handlers run; `Ember.onerror` is the usable channel. +- **Preact-iso `lazy` and Alpine:** two events carry the same reason (a double `unhandledrejection`; a window `error` plus `unhandledrejection`). `logException` dedupes an Error INSTANCE, but the detection providers do not dedupe across events today, so such a pair reports twice. That is independent of this feature and is not addressed here. + +### 10.6 Unverified +Ember routes (`Route#model()`, router_js) ran as source only, not in a full Ember app; the Remix v2 browser runtime (`RemixBrowser`, +single-fetch) is source-read only; Astro, Qwik, Stencil and Marko were not run in a browser (Marko ``/`` only skimmed). +Angular itself was not run in a browser (the zone paths were measured, Angular source read). WebKit was measured later in the e2e (§12). + +## 11. Known limitations of the first build (review round 1, accepted) + +- **A second recorder in the same realm is inert.** Install is idempotent through `Symbol.for('bugsee.asyncOrigin')`: a method already + wrapped is skipped, so a second copy of the SDK (two bundles, HMR) or a re-launch after a `launch()` that threw after installing + records nothing, and the first recorder's lookup does not see it. `launchCore` has no rollback for any of its interceptors, so this + is the same property fetch/XHR capture already has; a page reload clears it. +- **Static methods are not patched** (`Notification.requestPermission`, `PublicKeyCredential.is…Available`, `*.isConfigSupported`, …). + They are one-off capability probes. The drift test lists them (`ASYNC_ORIGIN_UNPATCHED_STATICS`) so a new one forces a decision. +- **A hostile or non-native `then`** that succeeds but returns a non-thenable leaves the derived promise dangling; native engines never + do this, and the recorder degrades to the untouched promise. +- **The derived promise settles one microtask after the platform's**, and is not the platform's promise object (identity, subclass + species). Inherent to keeping `unhandledrejection` alive (§4.1). +- **`describeThrown` serialises the whole object before clipping** to 256 characters. Contents of a thrown object appear in the report + summary as well as in `exception.reason` (where they already were); nothing redacts them beyond the existing report-level rules. +- **Target line:** the browser `launch()` passes the target describer to the core client (`describeTarget`), so `logException` (and every + framework adapter) gets the `Target:` line like the detection providers; media error messages go through the message sanitiser, + source URLs through `sanitizeUrl` with the query and fragment dropped entirely. Page-controlled text (element id, font family) goes + through `sanitizeErrorMessage`: token shapes are redacted; email addresses are not part of that shared definition and this does not add + a second one. +- **The budget counts calls, not failures.** A method called more than 200 times in a minute stops being captured for the rest of that + minute, so a later rejection of a bursty `always` method (`Permissions.query`, `decodeAudioData`) can lack an origin. +- **Retention.** A recorded origin holds its target (a media element) and, on V8, a lazily formatted stack until `.stack` is read; an + app that keeps a rejected `DOMException` keeps both alive. The entry is dropped with the reason (WeakMap). +- **Drift guard limits.** The extractor matches a return type literally named `Promise`, so `Promise | undefined`, `PromiseLike` and + property-signature methods are not seen (none exist in the pinned lib.dom.d.ts); a mixin target is checked to be a real + constructor, not to carry the method (a wrong target wraps nothing, silently). +- **Path tokens.** `sanitizeUrl` redacts `key=value` and `;matrix` secrets, not a bare path segment such as `/signed//clip.mp4`. + +## 12. Found by running real engines (replacement Promise, platform-API batch, WebKit) + +- **core-js disguises its `Promise`.** It patches `Function.prototype.toString` so its own `Promise` and `then` read `[native code]`; a + toString check on the GLOBAL then trusts a `then` that throws on the engine's own promises, no reaction is attached, and the reason + is never recorded. The pristine `then` now comes first from the engine intrinsic (the prototype of an async function's result, which no + library can swap); the global one and the hidden iframe remain as fallbacks for a native `then` patched in place (zone.js). + native-promise-only is not supported at all (no `Promise.allSettled`, which the SDK itself needs). +- **"Has a stack" means "has frames".** Chromium's async DOMExceptions for `Clipboard.readText` and `createImageBitmap` have a stack that is + only the header line, so a non-empty-string test skipped them. A stack counts only if it has an `at ...` or `name@url:line:col` line. +- **The wrapper is on top of a reason's own stack.** An API that rejects with a stack made inside the call (a `TypeError` from argument + validation) has the SDK's wrapper as its top frame, and a top `user` frame decides issue grouping. Every wrapper (and a probe) is + created by one function, so the `original.apply` line is one source location; the recorder exposes the probe's stack, and the enricher + drops frames at that location (the first non-native frame after the probe's thrower, found by position, not by name). +- **WebKit eliminates tail calls** (strict mode): `return video.play()` removes the calling function's own frame from the stack, so an + application function written that way is not in the call site on Safari. Nothing to fix; the e2e scenarios are non-tail on purpose. +- **Arming is verified for real:** after one unlocated stackless rejection (which is itself reported without frames), `HTMLImageElement.decode`, + `FontFace.load` and `createImageBitmap` get call sites in Chromium, Firefox and WebKit. +- **The wrapper site is found by position, not by name.** The probe throws from its own first frame, so the wrapper's call site is the next + non-native frame of the probe stack; the thrower's name does not survive a minifier. `hasFrames` is anchored so header text like + `user@host:8080` or a message line starting "at least" does not count as a frame. +- **Limits left alone:** a consumer that downlevels `async`/`await` to the global Promise defeats the intrinsic `then` (the global is then + the only source, and a core-js `then` that disguises itself would be trusted); the Astro listener's stop function is not kept (it lives + as long as the page). + +## 13. Grouping signature and presentation (owner, 2026-11-03) + +The captured call site is the report's PRIMARY exception data: `exception.frames` of `crash.json`, not a side channel. That settles two +things. + +- **Signature.** The worker signs on `name` + `reason` + the first non-hidden `user` frame's trace (`worker/crash/managed/common.py`), + after symbolicating each frame by its `debug_id` (`worker/crash/javascript.py`). A report with no frames had no signature at all (the + location is `None`), so every BGSVIEWER-14 event was its own issue. With the call site as the frames: the top frame is `user` and not + `hidden`; the same call site gives the same signature and a different call site a different one (run through the worker's own + `get_crash_signatures` on `crash.json` from real Chromium, Firefox and WebKit). Frames an enricher supplies get their `debug_id` in + `buildCrashJson` (both the Error and the non-Error path), because they are parsed after the stamp; without it a minified production + build would sign on the minified position, which changes every deploy. +- **Presentation.** Nothing to change in the viewer: the frames render as any stack. `origin: 'call'` stays on the frame as information + for a consumer that wants to tell a call site from a throw site; the viewer change that renders a heading and a chip (viewer branch + `feat/async-origin-call-frames`) is therefore NOT being pushed. +- `skipFrames` hides the top N frames of whatever frames are reported, call site included; no SDK path or adapter passes it by default. + +## 14. Review round 4 (PR #176 threads) + +- **One definition of "has frames".** The `@`-dialect parser accepted any line containing `@`, so header text like + `NotAllowedError: connect user@host:8080 failed` became a bogus frame (and the grouping location); it now needs `:line:col` (or + Safari's `[native code]`). `parseV8Stack` ignores an unindented line that merely starts with "at " (message text). The recorder's + predicate is injectable (`hasFrames`), and the browser passes `parseStack(stack).length > 0`. +- **Arming covers every report path and only the reported error.** The enricher (run by `unhandledrejection`, window `error`, + `logException`, framework error channels) arms; `FrameEnricher` gets `{ depth }`, so a frameless Error in a `cause` chain (React's + component stack) does not arm. The recorder is also released when the collector switches the SDK off (a detection provider whose + `stop()` releases it), and `event.promise` links a fallback-mode origin (keyed by promise) to the reason. +- **`logException` description:** header, then the call site, then the `Caused by:` chain, then the `Target:` line; a linked cause no + longer suppresses the call site. +- **A frameless non-Error reaches the enricher too** (`syntheticException` used to skip it), so a cross-realm `DOMException` (not + `instanceof Error`) gets its call site in `crash.json` and therefore a grouping signature; `composeFrameEnrichers` forwards `info`. +- **Astro:** the hydration listener attaches before `launch()` resolves and queues (newest 20) what arrives in the LAUNCH WINDOW only: the + window opens at a registration and is closed by the flush `registerClient` runs in a `finally` (so a failed `launch()` ends it too); + afterwards an error with no client is dropped, not queued. A later registration (relaunch) opens a new window and replaces the + `onError` the listener uses. diff --git a/docs/design/async-origin-capture/bench.mjs b/docs/design/async-origin-capture/bench.mjs new file mode 100644 index 00000000..1e41d21c --- /dev/null +++ b/docs/design/async-origin-capture/bench.mjs @@ -0,0 +1,54 @@ +import { createRequire } from 'node:module'; +import http from 'node:http'; +const require = createRequire(new URL('../../../packages/instrumentation-tests/package.json', import.meta.url)); +const pw = require('playwright'); +const srv = http.createServer((q,s)=>{s.setHeader('content-type','text/html');s.end('');}); +await new Promise(r=>srv.listen(0,'127.0.0.1',r)); +const PAGE = async () => { + const N = 20000, ROUNDS = 5; + const data = new Uint8Array(16); + const native = (...a) => crypto.subtle.digest('SHA-1', data); // cheap real async platform call + const origins = new WeakMap(); + const isThenable = (p) => p !== null && typeof p === 'object' && typeof p.then === 'function'; + // ONE generic factory: every patched method goes through it + const make = (original, { capture = true } = {}) => function (...args) { + const origin = capture ? new Error() : undefined; + const p = original.apply(this, args); + if (!isThenable(p)) return p; + return p.then(undefined, (r) => { if (origin && r !== null && typeof r === 'object') origins.set(r, origin); throw r; }); + }; + const orig = (a) => crypto.subtle.digest('SHA-1', a); + const variants = { + 'native call': () => orig(data), + 'wrapper: apply+then, NO Error': make(orig, { capture: false }), + 'wrapper: apply+then+new Error() (proposed)': make(orig), + }; + // hand-specialised (no rest args, no factory) to show the factory itself costs nothing + const special = function (a) { const o = new Error(); const p = orig(a); return p.then(undefined, (r) => { origins.set(r, o); throw r; }); }; + variants['hand-written specialised wrapper (+Error)'] = () => special(data); + const res = {}; + for (const [name, fn] of Object.entries(variants)) { + const times = []; + for (let k = 0; k < ROUNDS; k++) { + const t0 = performance.now(); + const ps = new Array(N); + for (let i = 0; i < N; i++) ps[i] = name === 'native call' ? orig(data) : fn(data); + await Promise.all(ps); + times.push((performance.now() - t0) / N * 1000); + } + times.sort((a, b) => a - b); + res[name] = +times[Math.floor(ROUNDS / 2)].toFixed(2) + ' us/call (median of ' + ROUNDS + ')'; + } + // cost of Error capture alone, and of reading .stack (only paid on rejection) + let t0 = performance.now(); let keep; for (let i = 0; i < 200000; i++) keep = new Error(); res['new Error() alone'] = ((performance.now() - t0) / 200000 * 1000).toFixed(2) + ' us'; + t0 = performance.now(); let n = 0; for (let i = 0; i < 20000; i++) n += new Error().stack.length; res['new Error().stack (format) alone'] = ((performance.now() - t0) / 20000 * 1000).toFixed(2) + ' us'; + return res; +}; +for (const [name, exe] of [['chromium', null], ['firefox', process.env.PW_firefox]]) { + let b; try { + b = await pw[name].launch({ headless: true, ...(exe ? { executablePath: exe } : {}) }); + const p = await b.newPage(); await p.goto('http://localhost:' + srv.address().port + '/'); + console.log('=== ' + name + ' ' + b.version()); console.log(JSON.stringify(await p.evaluate(`(${PAGE.toString()})()`), null, 1)); + } catch (e) { console.log(name, 'FAILED', String(e).slice(0, 150)); } finally { await b?.close(); } +} +srv.close(); diff --git a/docs/design/async-origin-capture/probe.mjs b/docs/design/async-origin-capture/probe.mjs new file mode 100644 index 00000000..92d93c0c --- /dev/null +++ b/docs/design/async-origin-capture/probe.mjs @@ -0,0 +1,90 @@ +import { createRequire } from 'node:module'; +import http from 'node:http'; +const require = createRequire(new URL('../../../packages/instrumentation-tests/package.json', import.meta.url)); +const pw = require('playwright'); +const srv = http.createServer((q,s)=>{ if(q.url==='/'){s.setHeader('content-type','text/html');s.end('');} else if(q.url==='/bad.js'){s.setHeader('content-type','text/javascript');s.end('syntax error here(');} else if(q.url==='/bad.json'){s.setHeader('content-type','application/json');s.end('{oops');} else {s.statusCode=404;s.end('nf');}}); +await new Promise(r=>srv.listen(0,'127.0.0.1',r)); +const base='http://localhost:'+srv.address().port; +const PAGE = () => { + const out = []; + const pend = new Map(); + window.addEventListener('unhandledrejection', (ev) => { + const id = pend.get(ev.promise); if (!id) return; pend.delete(ev.promise); + const r = ev.reason; const st = r && typeof r === 'object' ? r.stack : undefined; + const frames = typeof st === 'string' ? st.split('\n').filter(l => /^\s*at\s|@|:\d+:\d+/.test(l)).length : 0; + out.push({ id, kind: r === null ? 'null' : typeof r, name: r && r.name, msg: String(r && r.message !== undefined ? r.message : r).slice(0, 70), hasStackProp: typeof st === 'string', frames, mentionsCallsite: typeof st === 'string' && st.includes('callsite') , dom: typeof DOMException !== 'undefined' && r instanceof DOMException}); + }); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const cases = { + 'user: async fn throws Error': async function callsite() { throw new Error('x'); }, + 'user: Promise.reject(Error)': function callsite() { return Promise.reject(new Error('x')); }, + 'user: new Promise rej Error': function callsite() { return new Promise((_, rej) => setTimeout(() => rej(new Error('x')), 5)); }, + 'user: Promise.reject("str")': function callsite() { return Promise.reject('str'); }, + 'user: Promise.reject(undefined)': function callsite() { return Promise.reject(); }, + 'user: Promise.reject({obj})': function callsite() { return Promise.reject({ code: 1 }); }, + 'user: await then throw': async function callsite() { await sleep(5); throw new TypeError('late'); }, + 'media.play (bad src)': async function callsite() { const v = document.createElement('video'); v.src = 'data:video/mp4;base64,AAAA'; await new Promise((r) => { v.addEventListener('error', r, { once: true }); setTimeout(r, 1500); }); return v.play(); }, + 'clipboard.readText': function callsite() { return navigator.clipboard.readText(); }, + 'clipboard.writeText': function callsite() { return navigator.clipboard.writeText('x'); }, + 'requestFullscreen': function callsite() { return document.documentElement.requestFullscreen(); }, + 'getUserMedia(bad constraints)': function callsite() { return navigator.mediaDevices.getUserMedia({}); }, + 'getUserMedia(video)': function callsite() { return navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: 'nope' } } }); }, + 'permissions.query(bogus)': function callsite() { return navigator.permissions.query({ name: 'bogus' }); }, + 'fetch(refused)': function callsite() { return fetch('http://127.0.0.1:1/x'); }, + 'fetch(bad url)': function callsite() { return fetch('http://[bad'); }, + 'fetch(cors)': function callsite() { return fetch('http://127.0.0.1:1/x', { mode: 'cors' }); }, + 'response.json(bad)': async function callsite() { const r = await fetch('/bad.json'); return r.json(); }, + 'Image.decode(bad)': function callsite() { const i = new Image(); i.src = 'data:image/png;base64,AAAA'; return i.decode(); }, + 'createImageBitmap(bad)': function callsite() { return createImageBitmap(new Blob(['x'])); }, + 'subtle.digest(bogus)': function callsite() { return crypto.subtle.digest('bogus', new Uint8Array(1)); }, + 'subtle.importKey(bad)': function callsite() { return crypto.subtle.importKey('raw', new Uint8Array(1), 'AES-GCM', false, ['encrypt']); }, + 'serviceWorker.register(404)': function callsite() { return navigator.serviceWorker.register('/nope.js'); }, + 'navigator.share({})': function callsite() { return navigator.share({}); }, + 'orientation.lock': function callsite() { return screen.orientation.lock('portrait'); }, + 'wakeLock.request': function callsite() { return navigator.wakeLock.request('screen'); }, + 'FontFace.load(bad)': function callsite() { return new FontFace('x', 'url(/nope.woff)').load(); }, + 'decodeAudioData(bad)': function callsite() { return new AudioContext().decodeAudioData(new ArrayBuffer(8)); }, + 'RTC.setRemoteDescription(bad)': function callsite() { return new RTCPeerConnection().setRemoteDescription({ type: 'answer', sdp: 'x' }); }, + 'RTC.addIceCandidate(bad)': function callsite() { return new RTCPeerConnection().addIceCandidate({ candidate: 'x', sdpMid: 'zz' }); }, + 'import(404)': function callsite() { return import('/nope.mjs'); }, + 'import(syntax err)': function callsite() { return import('/bad.js'); }, + 'WebAssembly.compile(bad)': function callsite() { return WebAssembly.compile(new Uint8Array([0, 1, 2])); }, + 'WebAssembly.instantiate(bad)': function callsite() { return WebAssembly.instantiate(new Uint8Array([0, 97, 115, 109, 9, 0, 0, 0])); }, + 'OffscreenCanvas.convertToBlob(0)': function callsite() { return new OffscreenCanvas(0, 0).convertToBlob(); }, + 'storage.getDirectory+bad': async function callsite() { const d = await navigator.storage.getDirectory(); return d.getFileHandle('a/b'); }, + 'caches.open("")+match': function callsite() { return caches.match(new Request('/x'), { cacheName: 1n }); }, + 'document.fonts.load(bad)': function callsite() { return document.fonts.load('???'); }, + 'Notification.requestPermission': function callsite() { return Notification.requestPermission().then(() => { throw new Error('after'); }); }, + 'ReadableStream cancel err': function callsite() { return new ReadableStream({ cancel() { throw new Error('c'); } }).cancel(); }, + 'Blob.text on revoked? stream': function callsite() { const rs = new ReadableStream({ start(c) { c.error(new Error('e')); } }); return rs.getReader().read(); }, + 'IDB promisified (event err)': function callsite() { return new Promise((_, rej) => { const q = indexedDB.open('x', 0); q.onerror = () => rej(q.error); }); }, + 'CSS paintWorklet.addModule(404)': function callsite() { return CSS.paintWorklet.addModule('/nope.js'); }, + 'structuredClone via postMessage': function callsite() { return Promise.resolve().then(() => structuredClone(() => {})); }, + }; + return new Promise(async (resolve) => { + const started = []; + for (const [id, fn] of Object.entries(cases)) { + try { const p = fn(); pend.set(p, id); started.push(id); } catch (e) { out.push({ id, syncThrow: true, name: e.name, frames: (e.stack||'').split('\n').length }); } + } + await sleep(5000); + const seen = new Set(out.map((o) => o.id)); + for (const id of started) if (!seen.has(id)) out.push({ id, noEventWithin5s: true }); + resolve(out); + }); +}; +const only = process.argv[2]; +for (const name of ['chromium', 'firefox', 'webkit']) { + if (only && only !== name) continue; + let b; + try { + b = await pw[name].launch({ headless: true, ...(process.env['PW_'+name] ? { executablePath: process.env['PW_'+name] } : {}) }); + const ctx = await b.newContext({ permissions: [] }); + const p = await ctx.newPage(); + await p.goto(base + '/'); + const res = await p.evaluate(`(${PAGE.toString()})()`); + console.log('=== ' + name + ' ' + b.version()); + for (const r of res) console.log(JSON.stringify(r)); + } catch (e) { console.log('=== ' + name + ' FAILED ' + String(e).slice(0, 200)); } + finally { await b?.close(); } +} +srv.close(); diff --git a/package.json b/package.json index 9fabbb11..015fc8a8 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:mutation": "node scripts/mutation.mjs", "ci:local": "bash scripts/ci-local.sh", "spec:refresh": "node scripts/spec-snapshot/refresh.mjs", + "async-origin:refresh": "node scripts/async-origin/refresh.mjs", "spec:check": "node scripts/spec-snapshot/refresh.mjs --check" }, "devDependencies": { diff --git a/packages/astro/README.md b/packages/astro/README.md index a80b7e3c..c3722af8 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -53,6 +53,10 @@ bugsee({ }); ``` +## Island hydration errors + +Astro catches an island's hydration failure itself and re-dispatches it as a cancelable `astro:hydration-error` event (plus a `console.error`); nothing is rethrown, so the page's error handlers never see it. The browser launch listens for that event and reports the error, without cancelling the event, so Astro's own console output is unchanged. For component errors during rendering, also add the matching island adapter (`@bugsee/react`, `@bugsee/vue`, ...). + ## Links - Repository: https://github.com/bugsee/bugsee-javascript/tree/main/packages/astro diff --git a/packages/astro/package.json b/packages/astro/package.json index 144991d8..d1e45549 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -103,10 +103,11 @@ }, "dependencies": { "@bugsee/adapter-kit": "workspace:*", + "@bugsee/bugsee": "workspace:*", "@bugsee/core": "workspace:*", "@bugsee/protocol": "workspace:*", "@bugsee/vercel-edge": "workspace:*", - "@bugsee/bugsee": "workspace:*" + "@bugsee/web-adapter": "workspace:*" }, "devDependencies": { "@bugsee/node": "workspace:*", diff --git a/packages/astro/src/client.test.ts b/packages/astro/src/client.test.ts index ce48cdad..185927ae 100644 --- a/packages/astro/src/client.test.ts +++ b/packages/astro/src/client.test.ts @@ -1,12 +1,35 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { launch } = vi.hoisted(() => ({ launch: vi.fn() })); -vi.mock('@bugsee/bugsee', () => ({ launch })); +const { launch, relaunchUmbrella, listen, flush, order } = vi.hoisted(() => ({ + launch: vi.fn(), + relaunchUmbrella: vi.fn(), + listen: vi.fn(), + flush: vi.fn(), + order: [] as string[], +})); +vi.mock('@bugsee/bugsee', () => ({ launch, relaunch: relaunchUmbrella })); +vi.mock('./hydration', () => ({ + listenForAstroHydrationErrors: listen, + flushQueuedAstroHydrationErrors: flush, +})); -import { registerClient } from './client'; +// The client module remembers the last registration's hydration options (like core's relaunch keeps them), so each test gets a fresh one. +let registerClient: typeof import('./client').registerClient; +let relaunch: typeof import('./client').relaunch; describe('registerClient', () => { - afterEach(() => launch.mockReset()); + beforeEach(async () => { + vi.resetModules(); + ({ registerClient, relaunch } = await import('./client')); + }); + + afterEach(() => { + launch.mockReset(); + relaunchUmbrella.mockReset(); + listen.mockReset(); + flush.mockReset(); + order.length = 0; + }); it('launches the browser SDK with the appToken + options, returns the client', async () => { const client = { id: 'browser' }; @@ -19,4 +42,98 @@ describe('registerClient', () => { await registerClient('tok'); expect(launch).toHaveBeenCalledWith('tok', {}); }); + + it('listens BEFORE the launch (an island error during launch is queued) and flushes the queue once the client exists', async () => { + listen.mockImplementation(() => order.push('listen')); + launch.mockImplementation(async () => { + order.push('launch'); + return {}; + }); + flush.mockImplementation(() => order.push('flush')); + await registerClient('tok'); + expect(order).toEqual(['listen', 'launch', 'flush']); + }); + + it('relaunch() is wrapped in the same window: listen first, flush in a finally (the carrier client is cleared during the restart)', async () => { + listen.mockImplementation(() => order.push('listen')); + relaunchUmbrella.mockImplementation(async () => { + order.push('relaunch'); + return {}; + }); + flush.mockImplementation(() => order.push('flush')); + await relaunch({ replay: false }); + expect(order).toEqual(['listen', 'relaunch', 'flush']); + expect(relaunchUmbrella).toHaveBeenCalledWith({ replay: false }); + relaunchUmbrella.mockRejectedValue(new Error('relaunch failed')); + order.length = 0; + await expect(relaunch()).rejects.toThrow('relaunch failed'); + expect(order).toEqual(['listen', 'flush']); + }); + + it("hands the page document the launch uses (an explicit one, else the bound window's) to the hydration listener as its target", async () => { + launch.mockResolvedValue({}); + const doc = {} as Document; + await registerClient('tok', { document: doc } as never); + expect(listen).toHaveBeenLastCalledWith({ target: doc }); + const windowDoc = {} as Document; + await registerClient('tok', { window: { document: windowDoc } } as never); + expect(listen).toHaveBeenLastCalledWith({ target: windowDoc }); + const hostile = { + get document(): never { + throw new Error('sandboxed'); + }, + }; + await registerClient('tok', { window: hostile } as never); + // nothing selectable this time: the earlier registration's document is KEPT (core's relaunch keeps `document` and `window` too) + expect(listen).toHaveBeenLastCalledWith({ target: windowDoc }); + }); + + it('a plain relaunch() keeps the earlier document, carrier and onError (core keeps them), and an explicit one wins', async () => { + const doc = {} as Document; + const carrier = {}; + const onError = vi.fn(); + launch.mockResolvedValue({}); + relaunchUmbrella.mockResolvedValue({}); + await registerClient('tok', { document: doc, carrier, onError } as never); + await relaunch(); + expect(listen).toHaveBeenLastCalledWith({ target: doc, carrier, onError }); + expect(flush).toHaveBeenLastCalledWith({ target: doc, carrier, onError }); + const other = {} as Document; + await relaunch({ document: other } as never); + expect(listen).toHaveBeenLastCalledWith({ target: other, carrier, onError }); + }); + + it("a plain relaunch() after a bound WINDOW listens and flushes on that window's document (the same EventTarget as registerClient)", async () => { + const windowDoc = {} as Document; + launch.mockResolvedValue({}); + relaunchUmbrella.mockResolvedValue({}); + await registerClient('tok', { window: { document: windowDoc } } as never); + await relaunch({ replay: false }); + expect(listen).toHaveBeenLastCalledWith({ target: windowDoc }); + expect(flush).toHaveBeenLastCalledWith({ target: windowDoc }); + }); + + it('hands a custom carrier to the hydration listener, so it looks where the client lives', async () => { + const carrier = {}; + launch.mockResolvedValue({}); + await registerClient('tok', { carrier } as never); + expect(listen).toHaveBeenCalledWith({ carrier }); + expect(flush).toHaveBeenCalledWith({ carrier }); + }); + + it('ends the queueing window even when the launch fails, and still rejects with the launch error', async () => { + const failure = new Error('launch failed'); + launch.mockRejectedValue(failure); + await expect(registerClient('tok')).rejects.toBe(failure); + expect(flush).toHaveBeenCalledTimes(1); + }); + + it('passes the onError sink to the hydration listener, and keeps it for a later call that gives none (core keeps onError)', async () => { + const onError = vi.fn(); + await registerClient('tok', { onError }); + expect(listen).toHaveBeenCalledTimes(1); + expect(listen).toHaveBeenCalledWith({ onError }); + await registerClient('tok'); + expect(listen).toHaveBeenLastCalledWith({ onError }); + }); }); diff --git a/packages/astro/src/client.ts b/packages/astro/src/client.ts index a1f8be3e..2fbb974e 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -4,11 +4,77 @@ // a UI renderer). The Integration injects `injectScript('page', "import { registerClient } … registerClient(…)")`. // // Browser-only → behind the `@bugsee/astro/client` subpath, never the portable `.`/`./middleware` entries. -import { type Bugsee, type BugseeLaunchOptionsWithPerformance, launch } from '@bugsee/bugsee'; +import { + type Bugsee, + type BugseeLaunchOptionsWithPerformance, + launch, + relaunch as umbrellaRelaunch, +} from '@bugsee/bugsee'; +import { flushQueuedAstroHydrationErrors, listenForAstroHydrationErrors } from './hydration'; /** Options for the Astro client (browser) composition — the batteries-included browser umbrella options. */ export interface AstroClientOptions extends BugseeLaunchOptionsWithPerformance {} +// The hydration listener's options: where to report a failing sink, and which carrier the client lives on (a custom one is not on `globalThis`). +const hydrationOptions = ( + options: + | { + onError?: (error: unknown) => void; + carrier?: object; + document?: Document; + window?: unknown; + } + | undefined, +) => { + // The page document the launch itself uses: an explicit `document`, else the bound window's. Omitted when neither yields one, which + // keeps the listener's default (`globalThis.document`). A hostile `window.document` getter (a sandboxed frame) counts as none. + let target: Document | undefined = options?.document; + if (target === undefined) { + try { + target = (options?.window as { document?: Document } | undefined)?.document; + } catch { + target = undefined; + } + } + return { + ...(options?.onError !== undefined ? { onError: options.onError } : {}), + ...(options?.carrier !== undefined ? { carrier: options.carrier } : {}), + ...(target !== undefined ? { target } : {}), + }; +}; + +// The hydration options of the last registration (see `withHydrationWindow`). +let lastHydration: ReturnType = {}; + +/** + * Open the hydration listener's launch window, run `start`, and close the window in a `finally`: an error dispatched while the client is + * (re)starting is queued, and the flush runs whether the start succeeded or failed. + */ +async function withHydrationWindow( + options: + | { + onError?: (error: unknown) => void; + carrier?: object; + document?: Document; + window?: unknown; + } + | undefined, + start: () => Promise, +): Promise { + // Like core's `relaunchWith`, which keeps `document`, `window`, `carrier` and `onError` from the previous launch and lets an explicit one + // win: a plain `relaunch()` must watch the same document, on the same carrier, with the same sink. + const hydration = { ...lastHydration, ...hydrationOptions(options) }; + lastHydration = hydration; + // Astro swallows an island's hydration failure into an `astro:hydration-error` event; nothing else would see it. Listen BEFORE the + // launch resolves (an error dispatched meanwhile is queued) and flush once the client exists. + listenForAstroHydrationErrors(hydration); + try { + return await start(); + } finally { + flushQueuedAstroHydrationErrors(hydration); // success: report what queued; failure: end the window, nothing to report to + } +} + /** * Start Bugsee for the Astro client (browser). Returns the started client (a per-isolate singleton — a * repeat call returns the existing client). @@ -17,7 +83,15 @@ export async function registerClient( appToken: string, options: AstroClientOptions = {}, ): Promise { - return await launch(appToken, options); + return await withHydrationWindow(options, () => launch(appToken, options)); +} + +/** + * `relaunch` from the umbrella, inside the same window: it `stop()`s the running client (clearing the carrier slot) and waits out the + * drain before the new one is set, and an island that fails to hydrate meanwhile must not be lost. + */ +export async function relaunch(options?: Parameters[0]): Promise { + return await withHydrationWindow(options, () => umbrellaRelaunch(options)); } // API parity with the umbrella entries: the same VALUE surface, thin re-exports of the same functions. @@ -30,5 +104,4 @@ export { type LifecycleEventsListener, type LifecycleEventType, type ReportAttachmentInput, - relaunch, } from '@bugsee/bugsee'; diff --git a/packages/astro/src/hydration.test.ts b/packages/astro/src/hydration.test.ts new file mode 100644 index 00000000..87a2e931 --- /dev/null +++ b/packages/astro/src/hydration.test.ts @@ -0,0 +1,303 @@ +import type { Bugsee } from '@bugsee/bugsee'; +import { setCarrierClient } from '@bugsee/core'; +import { describe, expect, it, vi } from 'vitest'; +import { flushQueuedAstroHydrationErrors, listenForAstroHydrationErrors } from './hydration'; + +const clientWith = (logException = vi.fn(() => Promise.resolve(undefined))) => + ({ logException }) as unknown as Bugsee; +const hydrationError = (error: unknown, cancelable = true): Event => + new CustomEvent('astro:hydration-error', { detail: { error }, cancelable, bubbles: true }); + +describe('listenForAstroHydrationErrors', () => { + it('reports the error carried by astro:hydration-error, once per event, without cancelling the event', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + listenForAstroHydrationErrors({ target, getClient: () => clientWith(logException) }); + const error = new Error('island failed'); + const event = hydrationError(error); + target.dispatchEvent(event); + expect(logException).toHaveBeenCalledTimes(1); + const [reported, options] = logException.mock.calls[0] as unknown as [ + unknown, + { labels?: string[] }, + ]; + expect(reported).toBe(error); // the SAME object: instance dedupe and the async-origin lookup both key on it + expect(options.labels).toContain('astro.hydration'); + expect(event.defaultPrevented).toBe(false); // Astro's own console.error still happens + }); + + it('ignores an event without an error, and does nothing when no client is launched', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + listenForAstroHydrationErrors({ target, getClient: () => clientWith(logException) }); + target.dispatchEvent(new CustomEvent('astro:hydration-error', { detail: {} })); + target.dispatchEvent(new CustomEvent('astro:hydration-error')); + expect(logException).not.toHaveBeenCalled(); + + const noClient = new EventTarget(); + listenForAstroHydrationErrors({ target: noClient, getClient: () => undefined }); + expect(() => noClient.dispatchEvent(hydrationError(new Error('x')))).not.toThrow(); + }); + + it('never throws into the page when reporting fails, and tells onError', () => { + const target = new EventTarget(); + const onError = vi.fn(); + const boom = vi.fn(() => { + throw new Error('sdk down'); + }); + listenForAstroHydrationErrors({ target, getClient: () => clientWith(boom as never), onError }); + expect(() => target.dispatchEvent(hydrationError(new Error('x')))).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('is idempotent per target (a repeated launch does not report twice) and stops on request', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + const options = { target, getClient: () => clientWith(logException) }; + const stop = listenForAstroHydrationErrors(options); + listenForAstroHydrationErrors(options); + target.dispatchEvent(hydrationError(new Error('a'))); + expect(logException).toHaveBeenCalledTimes(1); + stop(); + target.dispatchEvent(hydrationError(new Error('b'))); + expect(logException).toHaveBeenCalledTimes(1); + // and a stopped listener can be installed again + listenForAstroHydrationErrors(options); + target.dispatchEvent(hydrationError(new Error('c'))); + expect(logException).toHaveBeenCalledTimes(2); + }); + + it('is a no-op without a document (server render)', () => { + const stop = listenForAstroHydrationErrors({ target: undefined }); + expect(() => stop()).not.toThrow(); + }); + + it('defaults to the page document and to the process client (none launched here: nothing to report, nothing thrown)', () => { + const document = new EventTarget(); + (globalThis as { document?: unknown }).document = document; + try { + const stop = listenForAstroHydrationErrors(); + expect(() => document.dispatchEvent(hydrationError(new Error('x')))).not.toThrow(); + stop(); + } finally { + (globalThis as { document?: unknown }).document = undefined; + } + }); + + // The listener is attached BEFORE launch() resolves (the carrier client is set after an async device-id read), so an island error + // dispatched in that window must not be dropped. + it('queues an error that arrives before the client exists, and reports it on flush', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + let client: Bugsee | undefined; + const options = { target, getClient: () => client }; + listenForAstroHydrationErrors(options); + const early = new Error('during launch'); + target.dispatchEvent(hydrationError(early)); + expect(logException).not.toHaveBeenCalled(); + client = clientWith(logException); + flushQueuedAstroHydrationErrors(options); + expect(logException).toHaveBeenCalledTimes(1); + expect((logException.mock.calls[0] as unknown as [unknown])[0]).toBe(early); + flushQueuedAstroHydrationErrors(options); // the queue is emptied: nothing is reported twice + expect(logException).toHaveBeenCalledTimes(1); + }); + + it('keeps the queue bounded (the newest errors survive)', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + let client: Bugsee | undefined; + const options = { target, getClient: () => client }; + listenForAstroHydrationErrors(options); + for (let i = 0; i < 25; i += 1) { + target.dispatchEvent(hydrationError(new Error(`e${i}`))); + } + client = clientWith(logException); + flushQueuedAstroHydrationErrors(options); + const reported = logException.mock.calls.map((c) => (c as unknown as [Error])[0].message); + expect(reported).toHaveLength(20); + expect(reported[0]).toBe('e5'); + expect(reported[19]).toBe('e24'); + }); + + it('a later registration (relaunch) replaces the onError the listener uses; flush with nothing queued or no document is a no-op', () => { + const target = new EventTarget(); + const first = vi.fn(); + const second = vi.fn(); + const boom = vi.fn(() => { + throw new Error('sdk down'); + }); + const getClient = () => clientWith(boom as never); + listenForAstroHydrationErrors({ target, getClient, onError: first }); + const again = listenForAstroHydrationErrors({ target, getClient, onError: second }); // relaunch: the new options win + again(); // the second registration's stop is a no-op: the first registration owns the listener + target.dispatchEvent(hydrationError(new Error('x'))); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + expect(() => flushQueuedAstroHydrationErrors({ target, getClient })).not.toThrow(); + expect(() => flushQueuedAstroHydrationErrors({ target: undefined })).not.toThrow(); + expect(() => flushQueuedAstroHydrationErrors({ target: new EventTarget() })).not.toThrow(); // never listened + }); + + it('a client lookup that throws is reported to onError and the error is queued, not lost', () => { + const target = new EventTarget(); + const onError = vi.fn(); + const logException = vi.fn(() => Promise.resolve(undefined)); + let healthy = false; + const options = { + target, + onError, + getClient: () => { + if (!healthy) { + throw new Error('carrier unavailable'); + } + return clientWith(logException); + }, + }; + listenForAstroHydrationErrors(options); + target.dispatchEvent(hydrationError(new Error('x'))); + expect(onError).toHaveBeenCalledTimes(1); + expect(logException).not.toHaveBeenCalled(); + healthy = true; + flushQueuedAstroHydrationErrors(options); + expect(logException).toHaveBeenCalledTimes(1); + }); + + // The queue covers ONLY the launch window: after the flush that ends it, an error with no client (a stopped or killed SDK) is dropped, + // not queued for ever and not reported by a later, unrelated flush. + it('stops queueing once the launch window has been flushed', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + let client: Bugsee | undefined; + const options = { target, getClient: () => client }; + listenForAstroHydrationErrors(options); + flushQueuedAstroHydrationErrors(options); // launch resolved (or failed): the window is over + target.dispatchEvent(hydrationError(new Error('after the SDK went away'))); // no client + client = clientWith(logException); + flushQueuedAstroHydrationErrors(options); + expect(logException).not.toHaveBeenCalled(); + }); + + it('a relaunch opens a new queueing window', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + let client: Bugsee | undefined; + const options = { target, getClient: () => client }; + listenForAstroHydrationErrors(options); + flushQueuedAstroHydrationErrors(options); + listenForAstroHydrationErrors(options); // registerClient again (relaunch) + target.dispatchEvent(hydrationError(new Error('during the relaunch'))); + client = clientWith(logException); + flushQueuedAstroHydrationErrors(options); + expect(logException).toHaveBeenCalledTimes(1); + }); + + it('a throwing onError never escapes the listener (and the error is still queued)', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + let healthy = false; + const options = { + target, + onError: () => { + throw new Error('sink failed'); + }, + getClient: () => { + if (!healthy) { + throw new Error('carrier unavailable'); + } + return clientWith(logException); + }, + }; + listenForAstroHydrationErrors(options); + expect(() => target.dispatchEvent(hydrationError(new Error('x')))).not.toThrow(); + healthy = true; + flushQueuedAstroHydrationErrors(options); + expect(logException).toHaveBeenCalledTimes(1); + }); + + // Two overlapping registrations: one fails (no client) while the other is still resolving. The window stays open until the LAST settles. + it('keeps the launch window open while another registration is still pending', () => { + const target = new EventTarget(); + const logException = vi.fn(() => Promise.resolve(undefined)); + let client: Bugsee | undefined; + const options = { target, getClient: () => client }; + listenForAstroHydrationErrors(options); // registration A + listenForAstroHydrationErrors(options); // registration B, overlapping + target.dispatchEvent(hydrationError(new Error('during both'))); + flushQueuedAstroHydrationErrors(options); // A fails: no client, B still pending + expect(logException).not.toHaveBeenCalled(); + target.dispatchEvent(hydrationError(new Error('while B resolves'))); // must still queue + client = clientWith(logException); + flushQueuedAstroHydrationErrors(options); // B succeeds + expect(logException).toHaveBeenCalledTimes(2); + target.dispatchEvent(hydrationError(new Error('after both'))); // window closed, client exists: reported directly + expect(logException).toHaveBeenCalledTimes(3); + }); + + it('a client lookup that throws while flushing drops the queue only once the last registration has settled, and never throws', () => { + const target = new EventTarget(); + const options = { + target, + getClient: () => { + throw new Error('carrier unavailable'); + }, + onError: () => undefined, + }; + listenForAstroHydrationErrors(options); + target.dispatchEvent(hydrationError(new Error('x'))); + expect(() => flushQueuedAstroHydrationErrors(options)).not.toThrow(); + // the window is closed now: nothing is queued any more + target.dispatchEvent(hydrationError(new Error('y'))); + expect(() => flushQueuedAstroHydrationErrors(options)).not.toThrow(); + }); + + // `launch({ carrier })` keeps the client on a custom carrier object, not on globalThis: the listener must look there. + it('finds the client on a custom carrier', () => { + const target = new EventTarget(); + const carrier = {}; + const logException = vi.fn(() => Promise.resolve(undefined)); + setCarrierClient(clientWith(logException), carrier); + listenForAstroHydrationErrors({ target, carrier }); + flushQueuedAstroHydrationErrors({ target, carrier }); + target.dispatchEvent(hydrationError(new Error('on a custom carrier'))); + expect(logException).toHaveBeenCalledTimes(1); + }); + + // core's relaunch KEEPS `carrier` and `onError` from the previous launch and lets an explicit one win; the listener mirrors that. + it('a re-registration without options keeps the earlier carrier and onError; an explicit one wins', () => { + const target = new EventTarget(); + const carrier = {}; + const logException = vi.fn(() => Promise.resolve(undefined)); + setCarrierClient(clientWith(logException), carrier); + const kept = vi.fn(); + listenForAstroHydrationErrors({ target, carrier, onError: kept }); + listenForAstroHydrationErrors({ target }); // a plain relaunch(): nothing explicit + target.dispatchEvent(hydrationError(new Error('still on the custom carrier'))); + expect(logException).toHaveBeenCalledTimes(1); // found on the kept carrier, not on globalThis + const explicit = vi.fn(); + const boom = () => { + throw new Error('sdk down'); + }; + setCarrierClient(clientWith(boom as never), carrier); + listenForAstroHydrationErrors({ target, onError: explicit }); // explicit onError wins, carrier is still kept + target.dispatchEvent(hydrationError(new Error('x'))); + expect(explicit).toHaveBeenCalledTimes(1); + expect(kept).not.toHaveBeenCalled(); + }); + + it('after the launch window a failing client lookup notifies onError ONCE (no second lookup through reportError)', () => { + const target = new EventTarget(); + const onError = vi.fn(); + const getClient = vi.fn(() => { + throw new Error('carrier unavailable'); + }); + const options = { target, getClient, onError }; + listenForAstroHydrationErrors(options); + flushQueuedAstroHydrationErrors(options); // the window is closed + getClient.mockClear(); + onError.mockClear(); + target.dispatchEvent(hydrationError(new Error('x'))); + expect(onError).toHaveBeenCalledTimes(1); + expect(getClient).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/astro/src/hydration.ts b/packages/astro/src/hydration.ts new file mode 100644 index 00000000..099cab5d --- /dev/null +++ b/packages/astro/src/hydration.ts @@ -0,0 +1,145 @@ +// Astro catches an island's hydration failure itself: it re-dispatches it as a cancelable `astro:hydration-error` CustomEvent +// (`detail.error` is the SAME error object) and logs it with `console.error`. Nothing is rethrown, so neither `window.onerror` nor +// `unhandledrejection` ever sees it and the SDK would report nothing (docs/design/async-origin-capture.md §10.5). One document-level +// listener closes that gap. It never calls `preventDefault`, so Astro's own console.error still happens. +// +// The listener is attached BEFORE `launch()` resolves (the carrier client is set after an asynchronous device-id read), so an error +// dispatched in that window is QUEUED (bounded) and reported by `flushQueuedAstroHydrationErrors` once the client exists. A repeated +// registration (a `relaunch()`) replaces the options the listener uses, so a later `onError` wins over the first one. + +import { getCarrierClient } from '@bugsee/core'; +import { type AdapterClientOptions, reportError } from '@bugsee/web-adapter'; + +export interface AstroHydrationOptions extends AdapterClientOptions { + /** Where the event is dispatched: the page `document`. Default `globalThis.document`; absent during a server render. */ + target?: EventTarget | undefined; + /** The carrier object `launch({ carrier })` keeps the client on. Default: the process-global one (`globalThis`). */ + carrier?: object | undefined; +} + +// The client type the adapter kit resolves (astro does not depend on `@bugsee/browser` directly). +type Client = ReturnType>; + +const EVENT = 'astro:hydration-error'; +const MAX_QUEUED = 20; + +interface Listening { + listener: EventListener; + options: AstroHydrationOptions; + queue: unknown[]; + /** Registrations whose launch has not settled yet. The window is open while it is above zero; outside it a missing client drops the error. */ + pending: number; +} +const listening = new WeakMap(); + +// Queue an error that has no client to go to yet, but only inside the launch window; the newest MAX_QUEUED survive. +function queue(state: Listening, error: unknown): void { + if (state.pending <= 0) { + return; + } + state.queue.push(error); + if (state.queue.length > MAX_QUEUED) { + state.queue.shift(); + } +} + +const targetOf = (options: AstroHydrationOptions): EventTarget | undefined => + 'target' in options ? options.target : (globalThis as { document?: EventTarget }).document; + +const clientOf = (options: AstroHydrationOptions): Client => + options.getClient !== undefined + ? options.getClient() + : getCarrierClient>(options.carrier ?? globalThis); + +const report = (error: unknown, options: AstroHydrationOptions): void => { + reportError(error, { + getClient: () => clientOf(options), // the SAME lookup the listener used (a custom carrier is not the process-global one) + ...(options.onError !== undefined ? { onError: options.onError } : {}), + labels: ['astro.hydration'], + }); +}; + +/** + * Report Astro's `astro:hydration-error` events to the launched client; an error that arrives while no client exists yet is queued. + * Idempotent per target (a repeated launch does not report twice), but the LATEST options win. Returns a function that removes the + * listener. A no-op without a `document`. + */ +export function listenForAstroHydrationErrors(options: AstroHydrationOptions = {}): () => void { + const target = targetOf(options); + if (target === undefined) { + return () => undefined; + } + const existing = listening.get(target); + if (existing !== undefined) { + // relaunch: like core's `relaunchWith`, the carrier and onError of the previous launch are KEPT and an explicit one wins; a new window opens + existing.options = { ...existing.options, ...options }; + existing.pending += 1; + return () => undefined; + } + const state: Listening = { + options, + queue: [], + pending: 1, + listener: (event) => { + const error = (event as CustomEvent<{ error?: unknown } | undefined>).detail?.error; + if (error === undefined) { + return; + } + let client: Client; + try { + client = clientOf(state.options); + } catch (clientError) { + // The lookup itself failed: tell onError ONCE, and queue only inside the launch window. Never fall through to `report`, whose own + // lookup would throw again and notify a second time. + try { + state.options.onError?.(clientError); + } catch { + // a failing sink must not escape into Astro's event dispatch + } + queue(state, error); + return; + } + if (client === undefined) { + queue(state, error); + return; + } + report(error, state.options); + }, + }; + listening.set(target, state); + target.addEventListener(EVENT, state.listener); + return () => { + target.removeEventListener(EVENT, state.listener); + listening.delete(target); + }; +} + +/** + * Settle one registration's launch window (call once its launch has settled, success or failure): report what was queued if a client + * exists now, and close the window once the last pending registration has settled (afterwards an error with no client is dropped, not + * queued), with the options of the latest registration. Never throws. + */ +export function flushQueuedAstroHydrationErrors(options: AstroHydrationOptions = {}): void { + const target = targetOf(options); + const state = target === undefined ? undefined : listening.get(target); + if (state === undefined) { + return; + } + // One registration has settled (launch resolved or failed). With a client, report what queued (even if another registration is still + // resolving); the window closes, and the rest is dropped, only when the LAST pending registration has settled. + state.pending = Math.max(0, state.pending - 1); + let client: Client; + try { + client = clientOf(state.options); + } catch { + client = undefined; + } + if (client !== undefined) { + for (const error of state.queue.splice(0)) { + report(error, state.options); + } + } + if (state.pending === 0) { + state.queue.length = 0; + } +} diff --git a/packages/browser/src/detection-providers.test.ts b/packages/browser/src/detection-providers.test.ts index 6ce9b631..4290c524 100644 --- a/packages/browser/src/detection-providers.test.ts +++ b/packages/browser/src/detection-providers.test.ts @@ -1,3 +1,4 @@ +import { createAsyncOriginReporting, installAsyncOrigins } from '@bugsee/capture'; import type { Client, CrashJson, ReportingRequest } from '@bugsee/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -5,6 +6,7 @@ import { createWindowErrorProvider, type WindowEvents, } from './detection-providers'; +import { parseStack } from './stack'; // A fake window event target: records listeners by type and can dispatch synthetic events. function fakeWindow() { @@ -242,7 +244,7 @@ describe('createUnhandledRejectionProvider', () => { const w = fakeWindow(); const requests = started(createUnhandledRejectionProvider(w.win)); w.emit('unhandledrejection', { reason: { code: 42 } }); - expect(requests[0]?.report.summary).toBe('[object Object]'); + expect(requests[0]?.report.summary).toBe('Object: {"code":42}'); expect(requests[0]?.report.description).toBeUndefined(); // A rejected non-Error still gets a usable crash document. expect(requests[0]?.report.crash).toMatchObject({ @@ -261,3 +263,267 @@ describe('createUnhandledRejectionProvider', () => { expect(w.count('unhandledrejection')).toBe(0); }); }); + +describe('async origin (a stackless platform rejection reported WITH its call site)', () => { + // A DOMException from an async media task: an Error, with no stack at all (BGSVIEWER-14). + const stacklessError = (): Error => { + const e = new Error('The element has no supported sources'); + e.name = 'NotSupportedError'; + Object.defineProperty(e, 'stack', { value: undefined }); + return e; + }; + + // A recorder over a fake realm whose `play()` rejects with `reason`; returns the reason after it passed through the wrapper. + async function located(reason: Error) { + class Media { + tagName = 'VIDEO'; + id = 'hero'; + networkState = 3; + readyState = 0; + play(): Promise { + return Promise.reject(reason); + } + } + const recorder = installAsyncOrigins({ + global: { HTMLMediaElement: Media, Promise, Error }, + rows: [{ iface: 'HTMLMediaElement', methods: ['play'], mode: 'always', why: 'test' }], + }); + const origins = createAsyncOriginReporting(recorder, parseStack); + function startPlayback(): Promise { + return new Media().play(); + } + await startPlayback().catch(() => undefined); + return { origins, recorder }; + } + + it('puts the call-site frames in crash.json and the description, marked origin: call, with the target', async () => { + const reason = stacklessError(); + const { origins, recorder } = await located(reason); + const w = fakeWindow(); + const requests = started(createUnhandledRejectionProvider(w.win, origins)); + w.emit('unhandledrejection', { reason }); + const req = requests[0] as ReportingRequest; + const frames = (req.report.crash as CrashJson).exception.frames; + expect(frames.length).toBeGreaterThan(0); + expect(frames.every((f) => f.origin === 'call')).toBe(true); + expect(frames.some((f) => f.trace.includes('startPlayback'))).toBe(true); + expect(req.report.summary).toBe('The element has no supported sources'); + expect(req.report.description).toContain('startPlayback'); + expect(req.report.description).toContain( + 'Target: