From f52f9db59adfe2871424544d9583361175f42407 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 20:23:39 +0500 Subject: [PATCH 01/32] feat(core): optional frame.origin = 'call' marks a call-site frame on the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An async platform rejection (video.play(), clipboard, ...) carries no stack, so the SDK will record where the API was called. This adds the marker the viewer uses to tell that frame from a thrown one. Step 1 of docs/design/async-origin-capture.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/core/src/crash.test.ts | 21 +++++++++++++++++++++ packages/core/src/crash.ts | 9 +++++++++ packages/core/src/stack.ts | 6 ++++++ 3 files changed, 36 insertions(+) diff --git a/packages/core/src/crash.test.ts b/packages/core/src/crash.test.ts index 707a8fc1..0870d6ee 100644 --- a/packages/core/src/crash.test.ts +++ b/packages/core/src/crash.test.ts @@ -527,3 +527,24 @@ describe('applyExceptionOptions skipFrames clamp', () => { expect(sink).toHaveBeenCalledTimes(1); }); }); + +describe('toCrashFrame — call-site origin', () => { + const crashFrames = (frames: StackFrame[]) => + buildCrashJson(errorWith('Error', 'boom', 'Error: boom\n at x (a.js:1:1)'), { + parseStack: () => frames, + }).exception.frames; + + it("carries a frame's `origin: 'call'` onto the wire", () => { + // An async platform rejection has no thrown stack; the SDK records where the API was CALLED. The + // viewer must be able to tell that frame from a thrown one, so the marker rides the wire frame. + const [frame] = crashFrames([ + { file: 'a.js', line: 3, column: 5, function: 'play', origin: 'call' }, + ]); + expect(frame?.origin).toBe('call'); + }); + + it('leaves the field off an ordinary thrown frame', () => { + const [frame] = crashFrames([{ file: 'a.js', line: 3, column: 5 }]); + expect(frame).not.toHaveProperty('origin'); + }); +}); diff --git a/packages/core/src/crash.ts b/packages/core/src/crash.ts index 12237fd7..38903814 100644 --- a/packages/core/src/crash.ts +++ b/packages/core/src/crash.ts @@ -26,6 +26,12 @@ export interface CrashFrame { variables?: Record; /** Source lines around this frame, when the platform read them. */ context?: FrameContext; + /** + * `'call'` marks the frame where a platform API was CALLED, not where an exception was thrown: an async + * rejection (`video.play()`, clipboard, …) has no stack of its own, so the SDK records the call site. + * Absent on every thrown frame; a viewer that does not know the field ignores it. + */ + origin?: 'call'; } /** @@ -165,6 +171,9 @@ function toCrashFrame(frame: StackFrame): CrashFrame { if (frame.context !== undefined) { crashFrame.context = frame.context; } + if (frame.origin !== undefined) { + crashFrame.origin = frame.origin; + } return crashFrame; } diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 659c96f0..264f992b 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -38,6 +38,12 @@ export interface StackFrame { * the platform read it (node's opt-in source-context capture). */ context?: FrameContext; + /** + * `'call'` when this frame is where a platform API was CALLED rather than where anything was thrown — + * recorded for an async rejection (`video.play()`, `navigator.clipboard.*`) whose own error carries no + * stack. Absent on every ordinary thrown frame. + */ + origin?: 'call'; } // Strip file:// URLs; normalize webpack:/// (and friends) to a friendly path; and keep the From 5aef99e77d5bf49e9255021b4a33fd98e4b103d0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 20:36:53 +0500 Subject: [PATCH 02/32] =?UTF-8?q?feat(core):=20describeThrown=20=E2=80=94?= =?UTF-8?q?=20summarise=20non-Error=20values=20by=20class=20and=20contents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rejected or thrown plain object was summarised with String(value), i.e. "[object Object]", so an issue could have no usable title. describeThrown renders 'Class: ', keeps primitives as their text, names functions without printing their source, is bounded to 256 characters and never throws. Used by the browser, node and vercel-edge detection providers and by logException. Step 2 of docs/design/async-origin-capture.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../browser/src/detection-providers.test.ts | 2 +- packages/browser/src/detection-providers.ts | 3 +- packages/core/src/client.test.ts | 11 ++++ packages/core/src/client.ts | 4 +- packages/core/src/crash.test.ts | 59 +++++++++++++++++++ packages/core/src/crash.ts | 30 ++++++++++ packages/core/src/index.ts | 1 + packages/node/src/detection-providers.test.ts | 2 +- packages/node/src/detection-providers.ts | 3 +- packages/vercel-edge/src/detection.test.ts | 7 +++ packages/vercel-edge/src/detection.ts | 3 +- 11 files changed, 118 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/detection-providers.test.ts b/packages/browser/src/detection-providers.test.ts index 6ce9b631..76a0a80a 100644 --- a/packages/browser/src/detection-providers.test.ts +++ b/packages/browser/src/detection-providers.test.ts @@ -242,7 +242,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({ diff --git a/packages/browser/src/detection-providers.ts b/packages/browser/src/detection-providers.ts index 017afa9e..d2a17a0c 100644 --- a/packages/browser/src/detection-providers.ts +++ b/packages/browser/src/detection-providers.ts @@ -3,6 +3,7 @@ import { applyDebugIds, buildCrashJson, DetectionProviderBase, + describeThrown, formatStack, neverThrow, parseLocation, @@ -36,7 +37,7 @@ function describeError(value: unknown): { summary: string; description?: string } return { summary }; } - return { summary: String(value) }; + return { summary: describeThrown(value) }; } /** Structured crash.json from a thrown value — `handled: false` (uncaught). A non-Error gets a diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 13a0a248..d2329ee7 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -1093,6 +1093,17 @@ describe('createClient — crash.json', () => { expect(crash.exception).toMatchObject({ name: 'String', reason: 'just a string' }); expect(crash.handled).toBe(true); // logException is a caught exception }); + + it('summarises a non-Error object by its class and contents, never "[object Object]"', async () => { + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ uploadPipeline, appToken: 'tok', getEnvironment }); + await client.logException({ code: 'E_QUOTA' }); + const files = unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body as Uint8Array); + const request = JSON.parse(strFromU8(files['request.json'] as Uint8Array)) as { + summary: string; + }; + expect(request.summary).toBe('Object: {"code":"E_QUOTA"}'); + }); }); describe('createClient — report identity is snapshotted at submit time', () => { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 64b4e1dd..893e0a82 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -58,7 +58,7 @@ import { type Client, type OptionsContainer, } from './contracts'; -import { applyExceptionOptions, buildCrashJson, type FrameEnricher } from './crash'; +import { applyExceptionOptions, buildCrashJson, describeThrown, type FrameEnricher } from './crash'; import { checkOrSetAlreadyCaught } from './dedup'; import { createDetectionCoordinator } from './detection-coordinator'; import type { IdentifiedBundle } from './durable-upload-pipeline'; @@ -1746,7 +1746,7 @@ export function createClient(options: CreateClientOptions = {}): CreatedClient { } catch (reportSiteError) { onError(reportSiteError); } - const message = error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : describeThrown(error); const description = describeError(error); // stack + the `cause` chain (LinkedErrors) // Structured crash.json: built from the Error's stack + per-frame debug-ids. handled: true — // logException is a programmatically-logged (caught) exception. Undefined for non-Errors. diff --git a/packages/core/src/crash.test.ts b/packages/core/src/crash.test.ts index 0870d6ee..2e19a895 100644 --- a/packages/core/src/crash.test.ts +++ b/packages/core/src/crash.test.ts @@ -5,6 +5,7 @@ import { buildCrashJson, type CrashJson, composeFrameEnrichers, + describeThrown, type NativeCrashJson, stampCrashProvenance, } from './crash'; @@ -548,3 +549,61 @@ describe('toCrashFrame — call-site origin', () => { expect(frame).not.toHaveProperty('origin'); }); }); + +describe('describeThrown', () => { + it('keeps a string, number, boolean, null, undefined, symbol and bigint as their plain text', () => { + expect(describeThrown('just a string')).toBe('just a string'); + expect(describeThrown(42)).toBe('42'); + expect(describeThrown(false)).toBe('false'); + expect(describeThrown(null)).toBe('null'); + expect(describeThrown(undefined)).toBe('undefined'); + expect(describeThrown(Symbol('s'))).toBe('Symbol(s)'); + expect(describeThrown(10n)).toBe('10'); + }); + + it('describes a plain object by its class and a JSON preview, never "[object Object]"', () => { + expect(describeThrown({ code: 42 })).toBe('Object: {"code":42}'); + }); + + it("prefers an object's own message over its JSON", () => { + expect(describeThrown({ name: 'HttpError', message: 'not found', status: 404 })).toBe( + 'HttpError: not found', + ); + }); + + it('names a class instance by its constructor', () => { + class Quota { + left = 0; + } + expect(describeThrown(new Quota())).toBe('Quota: {"left":0}'); + }); + + it('survives a circular object and a throwing getter', () => { + const loop: Record = {}; + loop.self = loop; + expect(describeThrown(loop)).toBe('Object: [object Object]'); + const bad = { + get message(): string { + throw new Error('getter'); + }, + }; + expect(describeThrown(bad)).toBe('[object Object]'); + }); + + it('does not print the source of a function', () => { + function secretNamedThing(): string { + return 'api-key-123'; + } + expect(describeThrown(secretNamedThing)).toBe('Function: secretNamedThing'); + expect(describeThrown(() => 1)).toBe('Function'); + }); + + it('truncates a long value to 256 characters with an ellipsis', () => { + const out = describeThrown({ blob: 'x'.repeat(1000) }); + expect(out).toHaveLength(256); + expect(out.endsWith('…')).toBe(true); + expect(describeThrown('y'.repeat(1000))).toHaveLength(256); + expect(describeThrown('z'.repeat(256))).toBe('z'.repeat(256)); // exactly the limit is not clipped + expect(describeThrown('z'.repeat(257))).toBe(`${'z'.repeat(255)}…`); + }); +}); diff --git a/packages/core/src/crash.ts b/packages/core/src/crash.ts index 38903814..f50f93ef 100644 --- a/packages/core/src/crash.ts +++ b/packages/core/src/crash.ts @@ -307,6 +307,36 @@ function renderThrowable(value: unknown): string { return String(value); } +const DESCRIBE_LIMIT = 256; + +const clip = (text: string): string => + text.length > DESCRIBE_LIMIT ? `${text.slice(0, DESCRIBE_LIMIT - 1)}…` : text; + +/** + * One line for a thrown or rejected value, for a report SUMMARY. `String(value)` printed a plain object + * as "[object Object]" and a class instance the same way, so `reject({ code: 'E1' })` produced an issue + * titled with nothing. An object is shown as `Class: `; a primitive keeps its plain + * text (a thrown string IS its own message); a function is named, never printed (its source is the + * application's). Bounded to 256 characters, and never throws: a hostile getter or a circular object + * falls back to the engine's own tag. + */ +export function describeThrown(value: unknown): string { + try { + if (typeof value === 'function') { + const name = (value as { name?: unknown }).name; + return typeof name === 'string' && name !== '' ? `Function: ${clip(name)}` : 'Function'; + } + if (typeof value === 'object' && value !== null) { + const reason = renderThrowable(value); + const tag = typeTag(value); + return clip(reason === '' ? tag : `${tag}: ${reason}`); + } + return clip(String(value)); + } catch { + return Object.prototype.toString.call(value); + } +} + /** * Apply the already-validated `logException` options that live in the crash document: stamp `domain` on * the outermost exception and mark its top `skipFrames` frames `hidden`, never the last one (Android/iOS `skipFrames`: the diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a131a4d..4c3735b5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -168,6 +168,7 @@ export { type CrashJson, type CrashProvenance, composeFrameEnrichers, + describeThrown, type FrameEnricher, isUserFrame, type NativeCrashJson, diff --git a/packages/node/src/detection-providers.test.ts b/packages/node/src/detection-providers.test.ts index e1739fb8..94b7162e 100644 --- a/packages/node/src/detection-providers.test.ts +++ b/packages/node/src/detection-providers.test.ts @@ -141,7 +141,7 @@ describe('createUnhandledRejectionProvider', () => { const p = fakeProcess(); const requests = started(createUnhandledRejectionProvider(p.proc)); p.emit('unhandledRejection', { 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(); expect(requests[0]?.report.crash).toMatchObject({ handled: false, diff --git a/packages/node/src/detection-providers.ts b/packages/node/src/detection-providers.ts index a06f29e1..e69e27a8 100644 --- a/packages/node/src/detection-providers.ts +++ b/packages/node/src/detection-providers.ts @@ -3,6 +3,7 @@ import type { CrashJson, DetectionProvider } from '@bugsee/core'; import { buildCrashJson, DetectionProviderBase, + describeThrown, type FrameEnricher, formatStack, parseV8Stack, @@ -33,7 +34,7 @@ function describeError(value: unknown): { summary: string; description?: string } return { summary }; } - return { summary: String(value) }; + return { summary: describeThrown(value) }; } /** diff --git a/packages/vercel-edge/src/detection.test.ts b/packages/vercel-edge/src/detection.test.ts index d83cd306..3f94edab 100644 --- a/packages/vercel-edge/src/detection.test.ts +++ b/packages/vercel-edge/src/detection.test.ts @@ -80,6 +80,13 @@ describe('createEdgeUnhandledRejectionProvider', () => { expect('description' in (requests[0]?.report ?? {})).toBe(false); // omitted, not present-as-undefined }); + it('describes a non-Error object rejection by its class and contents', () => { + const t = fakeTarget(); + const requests = start(createEdgeUnhandledRejectionProvider(t.target)); + t.emit('unhandledrejection', { reason: { code: 42 } }); + expect(requests[0]?.report.summary).toBe('Object: {"code":42}'); + }); + it('reports a non-Error rejection via String(value), description key absent', () => { const t = fakeTarget(); const requests = start(createEdgeUnhandledRejectionProvider(t.target)); diff --git a/packages/vercel-edge/src/detection.ts b/packages/vercel-edge/src/detection.ts index c6b1a7d9..0784ff12 100644 --- a/packages/vercel-edge/src/detection.ts +++ b/packages/vercel-edge/src/detection.ts @@ -3,6 +3,7 @@ import { type CrashJson, type DetectionProvider, DetectionProviderBase, + describeThrown, formatStack, parseV8Stack, } from '@bugsee/core'; @@ -31,7 +32,7 @@ function describeError(value: unknown): { summary: string; description?: string ? { summary, description: formatStack(parseV8Stack(value.stack)) } : { summary }; } - return { summary: String(value) }; + return { summary: describeThrown(value) }; } /** Structured crash.json (SC3) from a rejection reason — `handled: false`. Undefined for non-Errors. Edge is From 3e5a8999ef8b12bce019c3710b9679023e11338d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 20:49:41 +0500 Subject: [PATCH 03/32] feat(capture): async-origin patch table, generator and drift test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data only, no behaviour change. scripts/async-origin/extract.mjs lists every promise-returning method in lib.dom.d.ts (80 interfaces, 187 methods; the design's hand-made list missed ten stream/lock methods). async-origin-table.ts classifies each as always / armed / off with a reason; a drift test in instrumentation-tests fails when a TypeScript upgrade adds an API nobody has classified, when the committed snapshot is stale, or when a mixin lacks concrete targets. Step 3 of docs/design/async-origin-capture.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/async-origin-capture.md | 6 +- package.json | 1 + .../spec/dom-promise-methods.snapshot.json | 350 ++++++++++++++++ .../capture/src/async-origin-table.test.ts | 57 +++ packages/capture/src/async-origin-table.ts | 394 ++++++++++++++++++ packages/capture/src/index.ts | 7 + .../test/async-origin-drift.test.ts | 63 +++ .../test/spec-drift.test.ts | 5 + scripts/async-origin/extract.d.mts | 5 + scripts/async-origin/extract.mjs | 50 +++ scripts/async-origin/refresh.mjs | 24 ++ 11 files changed, 961 insertions(+), 1 deletion(-) create mode 100644 packages/capture/spec/dom-promise-methods.snapshot.json create mode 100644 packages/capture/src/async-origin-table.test.ts create mode 100644 packages/capture/src/async-origin-table.ts create mode 100644 packages/instrumentation-tests/test/async-origin-drift.test.ts create mode 100644 scripts/async-origin/extract.d.mts create mode 100644 scripts/async-origin/extract.mjs create mode 100644 scripts/async-origin/refresh.mjs diff --git a/docs/design/async-origin-capture.md b/docs/design/async-origin-capture.md index 4e65cc40..2c50e4d5 100644 --- a/docs/design/async-origin-capture.md +++ b/docs/design/async-origin-capture.md @@ -55,7 +55,10 @@ likely a stack exists, and Chromium is much worse than Firefox.** WebKit is unte ## 3. The complete candidate list -Every promise-returning method declared in TypeScript's `lib.dom.d.ts` (6.0.3): **75 interfaces, 177 methods**. A method that can +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 ten 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). @@ -306,6 +309,7 @@ the capture alone roughly doubles the per-call cost (1.2 to 2.3 µs in Chromium) ## 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/capture/src/async-origin-table.drift.test.ts`. - 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 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/capture/spec/dom-promise-methods.snapshot.json b/packages/capture/spec/dom-promise-methods.snapshot.json new file mode 100644 index 00000000..e20ebda7 --- /dev/null +++ b/packages/capture/spec/dom-promise-methods.snapshot.json @@ -0,0 +1,350 @@ +{ + "typescript": "6.0.3", + "interfaces": { + "AudioContext": { + "mixin": false, + "methods": ["close", "resume", "suspend"] + }, + "AudioDecoder": { + "mixin": false, + "methods": ["flush"] + }, + "AudioEncoder": { + "mixin": false, + "methods": ["flush"] + }, + "BaseAudioContext": { + "mixin": false, + "methods": ["decodeAudioData"] + }, + "Blob": { + "mixin": false, + "methods": ["arrayBuffer", "bytes", "text"] + }, + "Body": { + "mixin": true, + "methods": ["arrayBuffer", "blob", "bytes", "formData", "json", "text"] + }, + "CSSStyleSheet": { + "mixin": false, + "methods": ["replace"] + }, + "Cache": { + "mixin": false, + "methods": ["add", "addAll", "delete", "keys", "match", "matchAll", "put"] + }, + "CacheStorage": { + "mixin": false, + "methods": ["delete", "has", "keys", "match", "open"] + }, + "Clipboard": { + "mixin": false, + "methods": ["read", "readText", "write", "writeText"] + }, + "ClipboardItem": { + "mixin": false, + "methods": ["getType"] + }, + "CookieStore": { + "mixin": false, + "methods": ["delete", "get", "getAll", "set"] + }, + "CookieStoreManager": { + "mixin": false, + "methods": ["getSubscriptions", "subscribe", "unsubscribe"] + }, + "CredentialsContainer": { + "mixin": false, + "methods": ["create", "get", "preventSilentAccess", "store"] + }, + "CustomElementRegistry": { + "mixin": false, + "methods": ["whenDefined"] + }, + "Document": { + "mixin": false, + "methods": [ + "exitFullscreen", + "exitPictureInPicture", + "hasStorageAccess", + "requestStorageAccess" + ] + }, + "Element": { + "mixin": false, + "methods": ["requestFullscreen", "requestPointerLock"] + }, + "FileSystemDirectoryHandle": { + "mixin": false, + "methods": ["getDirectoryHandle", "getFileHandle", "removeEntry", "resolve"] + }, + "FileSystemFileHandle": { + "mixin": false, + "methods": ["createWritable", "getFile"] + }, + "FileSystemHandle": { + "mixin": false, + "methods": ["isSameEntry"] + }, + "FileSystemWritableFileStream": { + "mixin": false, + "methods": ["seek", "truncate", "write"] + }, + "FontFace": { + "mixin": false, + "methods": ["load"] + }, + "FontFaceSet": { + "mixin": false, + "methods": ["load"] + }, + "GPU": { + "mixin": false, + "methods": ["requestAdapter"] + }, + "GPUAdapter": { + "mixin": false, + "methods": ["requestDevice"] + }, + "GPUBuffer": { + "mixin": false, + "methods": ["mapAsync"] + }, + "GPUDevice": { + "mixin": false, + "methods": ["createComputePipelineAsync", "createRenderPipelineAsync", "popErrorScope"] + }, + "GPUQueue": { + "mixin": false, + "methods": ["onSubmittedWorkDone"] + }, + "GPUShaderModule": { + "mixin": false, + "methods": ["getCompilationInfo"] + }, + "GamepadHapticActuator": { + "mixin": false, + "methods": ["playEffect", "reset"] + }, + "HTMLImageElement": { + "mixin": false, + "methods": ["decode"] + }, + "HTMLMediaElement": { + "mixin": false, + "methods": ["play", "setMediaKeys", "setSinkId"] + }, + "HTMLVideoElement": { + "mixin": false, + "methods": ["requestPictureInPicture"] + }, + "IDBFactory": { + "mixin": false, + "methods": ["databases"] + }, + "ImageCapture": { + "mixin": false, + "methods": ["getPhotoCapabilities", "getPhotoSettings", "grabFrame", "takePhoto"] + }, + "ImageDecoder": { + "mixin": false, + "methods": ["decode"] + }, + "LockManager": { + "mixin": false, + "methods": ["query", "request"] + }, + "MIDIPort": { + "mixin": false, + "methods": ["close", "open"] + }, + "MediaCapabilities": { + "mixin": false, + "methods": ["decodingInfo", "encodingInfo"] + }, + "MediaDevices": { + "mixin": false, + "methods": ["enumerateDevices", "getDisplayMedia", "getUserMedia"] + }, + "MediaKeySession": { + "mixin": false, + "methods": ["close", "generateRequest", "load", "remove", "update"] + }, + "MediaKeySystemAccess": { + "mixin": false, + "methods": ["createMediaKeys"] + }, + "MediaKeys": { + "mixin": false, + "methods": ["getStatusForPolicy", "setServerCertificate"] + }, + "MediaSession": { + "mixin": false, + "methods": ["setCameraActive", "setMicrophoneActive"] + }, + "MediaStreamTrack": { + "mixin": false, + "methods": ["applyConstraints"] + }, + "NavigationPreloadManager": { + "mixin": false, + "methods": ["disable", "enable", "getState", "setHeaderValue"] + }, + "Navigator": { + "mixin": false, + "methods": ["requestMIDIAccess", "requestMediaKeySystemAccess", "share"] + }, + "NavigatorBadge": { + "mixin": true, + "methods": ["clearAppBadge", "setAppBadge"] + }, + "NavigatorLogin": { + "mixin": false, + "methods": ["setStatus"] + }, + "OfflineAudioContext": { + "mixin": false, + "methods": ["resume", "startRendering", "suspend"] + }, + "OffscreenCanvas": { + "mixin": false, + "methods": ["convertToBlob"] + }, + "PaymentRequest": { + "mixin": false, + "methods": ["abort", "canMakePayment", "show"] + }, + "PaymentResponse": { + "mixin": false, + "methods": ["complete", "retry"] + }, + "Permissions": { + "mixin": false, + "methods": ["query"] + }, + "PushManager": { + "mixin": false, + "methods": ["getSubscription", "permissionState", "subscribe"] + }, + "PushSubscription": { + "mixin": false, + "methods": ["unsubscribe"] + }, + "RTCPeerConnection": { + "mixin": false, + "methods": [ + "addIceCandidate", + "createAnswer", + "createOffer", + "getStats", + "setLocalDescription", + "setRemoteDescription" + ] + }, + "RTCRtpReceiver": { + "mixin": false, + "methods": ["getStats"] + }, + "RTCRtpSender": { + "mixin": false, + "methods": ["getStats", "replaceTrack", "setParameters"] + }, + "ReadableStream": { + "mixin": false, + "methods": ["cancel", "pipeTo"] + }, + "ReadableStreamBYOBReader": { + "mixin": false, + "methods": ["read"] + }, + "ReadableStreamDefaultReader": { + "mixin": false, + "methods": ["read"] + }, + "ReadableStreamGenericReader": { + "mixin": true, + "methods": ["cancel"] + }, + "RemotePlayback": { + "mixin": false, + "methods": ["cancelWatchAvailability", "prompt", "watchAvailability"] + }, + "Scheduler": { + "mixin": false, + "methods": ["postTask", "yield"] + }, + "ScreenOrientation": { + "mixin": false, + "methods": ["lock"] + }, + "ServiceWorkerContainer": { + "mixin": false, + "methods": ["getRegistration", "getRegistrations", "register"] + }, + "ServiceWorkerRegistration": { + "mixin": false, + "methods": ["getNotifications", "showNotification", "unregister", "update"] + }, + "StorageManager": { + "mixin": false, + "methods": ["estimate", "getDirectory", "persist", "persisted"] + }, + "SubtleCrypto": { + "mixin": false, + "methods": [ + "decrypt", + "deriveBits", + "deriveKey", + "digest", + "encrypt", + "exportKey", + "generateKey", + "importKey", + "sign", + "unwrapKey", + "verify", + "wrapKey" + ] + }, + "VideoDecoder": { + "mixin": false, + "methods": ["flush"] + }, + "VideoEncoder": { + "mixin": false, + "methods": ["flush"] + }, + "VideoFrame": { + "mixin": false, + "methods": ["copyTo"] + }, + "WakeLock": { + "mixin": false, + "methods": ["request"] + }, + "WakeLockSentinel": { + "mixin": false, + "methods": ["release"] + }, + "WebTransport": { + "mixin": false, + "methods": ["createBidirectionalStream", "createUnidirectionalStream"] + }, + "WindowOrWorkerGlobalScope": { + "mixin": true, + "methods": ["createImageBitmap", "fetch"] + }, + "Worklet": { + "mixin": false, + "methods": ["addModule"] + }, + "WritableStream": { + "mixin": false, + "methods": ["abort", "close"] + }, + "WritableStreamDefaultWriter": { + "mixin": false, + "methods": ["abort", "close", "write"] + } + } +} diff --git a/packages/capture/src/async-origin-table.test.ts b/packages/capture/src/async-origin-table.test.ts new file mode 100644 index 00000000..34c1e703 --- /dev/null +++ b/packages/capture/src/async-origin-table.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { + ASYNC_ORIGIN_EXTRAS, + ASYNC_ORIGIN_TABLE, + type AsyncOriginRow, + rowTargets, +} from './async-origin-table'; + +describe('rowTargets', () => { + it('is the interface itself unless the row names concrete targets', () => { + expect(rowTargets({ iface: 'Clipboard', methods: ['read'], mode: 'always', why: 'x' })).toEqual( + ['Clipboard'], + ); + expect( + rowTargets({ + iface: 'Body', + methods: ['json'], + mode: 'off', + targets: ['Request', 'Response'], + why: 'x', + }), + ).toEqual(['Request', 'Response']); + }); +}); + +describe('the async-origin table', () => { + const all: readonly AsyncOriginRow[] = [...ASYNC_ORIGIN_TABLE, ...ASYNC_ORIGIN_EXTRAS]; + + it('has a mode, a reason and at least one method on every row', () => { + for (const row of all) { + expect(['always', 'armed', 'off']).toContain(row.mode); + expect(row.why.length).toBeGreaterThan(0); + expect(row.methods.length).toBeGreaterThan(0); + } + }); + + it('classifies a (target, method) pair once, so a method has exactly one mode', () => { + const seen = new Map(); + for (const row of all) { + for (const target of rowTargets(row)) { + for (const method of row.methods) { + const key = `${target}.${method}`; + expect(seen.has(key), `${key} is classified twice`).toBe(false); + seen.set(key, row.mode); + } + } + } + }); + + it('keeps the measured BGSVIEWER-14 method in the always-on set and fetch out of the default set', () => { + const mode = (iface: string, method: string): string | undefined => + all.find((row) => row.iface === iface && row.methods.includes(method))?.mode; + expect(mode('HTMLMediaElement', 'play')).toBe('always'); + expect(mode('WindowOrWorkerGlobalScope', 'fetch')).toBe('off'); + expect(mode('SubtleCrypto', 'digest')).toBe('off'); + }); +}); diff --git a/packages/capture/src/async-origin-table.ts b/packages/capture/src/async-origin-table.ts new file mode 100644 index 00000000..07bf60a5 --- /dev/null +++ b/packages/capture/src/async-origin-table.ts @@ -0,0 +1,394 @@ +// The patch table for async-origin capture (docs/design/async-origin-capture.md §3, §4.4, §5): every promise-returning platform +// method, and what the SDK does with it. DATA ONLY — no behaviour; the wrapper factory (a later step) reads it. +// +// The candidate list is generated from TypeScript's `lib.dom.d.ts` (`pnpm async-origin:refresh` → +// `spec/dom-promise-methods.snapshot.json`), and `async-origin-table.drift.test.ts` fails when this table and that list disagree, +// so a new TypeScript release that adds an API forces a decision here rather than silently going unwrapped. + +/** + * What the SDK does with a method: + * - `always`: wrap and capture the call site on every call. Rare, user-driven APIs (a few calls per page). + * - `armed`: pass-through until the method's FIRST rejection, then capture (frequency is a property of the app, not the API). + * - `off`: never wrapped by default — hot in bulk, or already covered, or the rejection is the application's own. + */ +export type AsyncOriginMode = 'always' | 'armed' | 'off'; + +export interface AsyncOriginRow { + /** The `lib.dom.d.ts` interface (or, for a manual extra, the global the methods hang off). */ + iface: string; + methods: readonly string[]; + mode: AsyncOriginMode; + /** + * The constructors whose `prototype` carries the methods, when `iface` is a WebIDL mixin (no constructor of its own). + * `'globalThis'` means the methods are own properties of the global object. Absent: `iface` itself. + */ + targets?: readonly string[]; + /** Why this mode. One line. */ + why: string; +} + +const RARE = 'rare, user-driven: a few calls per page'; +const BURSTY = 'can burst (hundreds, then quiet): armed on first failure'; +const HOT = + 'per frame / chunk / task: armed on first failure so a loop of successes pays one boolean'; +const BULK = 'hot in bulk (per chunk / per request): off by default'; + +export const ASYNC_ORIGIN_TABLE: readonly AsyncOriginRow[] = [ + { iface: 'AudioContext', methods: ['close', 'resume', 'suspend'], mode: 'always', why: RARE }, + { iface: 'AudioDecoder', methods: ['flush'], mode: 'armed', why: HOT }, + { iface: 'AudioEncoder', methods: ['flush'], mode: 'armed', why: HOT }, + { + iface: 'BaseAudioContext', + methods: ['decodeAudioData'], + mode: 'always', + why: 'measured stackless (EncodingError); a few per page', + }, + { iface: 'Blob', methods: ['arrayBuffer', 'bytes', 'text'], mode: 'off', why: BULK }, + { + iface: 'Body', + methods: ['arrayBuffer', 'blob', 'bytes', 'formData', 'json', 'text'], + mode: 'off', + targets: ['Request', 'Response'], + why: BULK, + }, + { iface: 'CSSStyleSheet', methods: ['replace'], mode: 'armed', why: BURSTY }, + { + iface: 'Cache', + methods: ['add', 'addAll', 'delete', 'keys', 'match', 'matchAll', 'put'], + mode: 'off', + why: BULK, + }, + { + iface: 'CacheStorage', + methods: ['delete', 'has', 'keys', 'match', 'open'], + mode: 'off', + why: BULK, + }, + { + iface: 'Clipboard', + methods: ['read', 'readText', 'write', 'writeText'], + mode: 'always', + why: 'measured stackless (NotAllowedError); user-driven', + }, + { iface: 'ClipboardItem', methods: ['getType'], mode: 'always', why: RARE }, + { iface: 'CookieStore', methods: ['delete', 'get', 'getAll', 'set'], mode: 'armed', why: BURSTY }, + { + iface: 'CookieStoreManager', + methods: ['getSubscriptions', 'subscribe', 'unsubscribe'], + mode: 'always', + why: RARE, + }, + { + iface: 'CredentialsContainer', + methods: ['create', 'get', 'preventSilentAccess', 'store'], + mode: 'always', + why: RARE, + }, + { iface: 'CustomElementRegistry', methods: ['whenDefined'], mode: 'armed', why: BURSTY }, + { + iface: 'Document', + methods: ['exitFullscreen', 'exitPictureInPicture', 'hasStorageAccess', 'requestStorageAccess'], + mode: 'always', + why: RARE, + }, + { + iface: 'Element', + methods: ['requestFullscreen', 'requestPointerLock'], + mode: 'always', + why: RARE, + }, + { + iface: 'FileSystemDirectoryHandle', + methods: ['getDirectoryHandle', 'getFileHandle', 'removeEntry', 'resolve'], + mode: 'always', + why: 'measured stackless after an await; a few per page', + }, + { + iface: 'FileSystemFileHandle', + methods: ['createWritable', 'getFile'], + mode: 'always', + why: RARE, + }, + { iface: 'FileSystemHandle', methods: ['isSameEntry'], mode: 'always', why: RARE }, + { + iface: 'FileSystemWritableFileStream', + methods: ['seek', 'truncate', 'write'], + mode: 'armed', + why: HOT, + }, + { + iface: 'FontFace', + methods: ['load'], + mode: 'armed', + why: 'measured stackless; one per face, bursty at startup', + }, + { + iface: 'FontFaceSet', + methods: ['load'], + mode: 'armed', + why: 'measured stackless; per text measure in some canvas libraries', + }, + { iface: 'GPU', methods: ['requestAdapter'], mode: 'armed', why: HOT }, + { iface: 'GPUAdapter', methods: ['requestDevice'], mode: 'armed', why: HOT }, + { iface: 'GPUBuffer', methods: ['mapAsync'], mode: 'armed', why: HOT }, + { + iface: 'GPUDevice', + methods: ['createComputePipelineAsync', 'createRenderPipelineAsync', 'popErrorScope'], + mode: 'armed', + why: HOT, + }, + { iface: 'GPUQueue', methods: ['onSubmittedWorkDone'], mode: 'armed', why: HOT }, + { iface: 'GPUShaderModule', methods: ['getCompilationInfo'], mode: 'armed', why: HOT }, + { iface: 'GamepadHapticActuator', methods: ['playEffect', 'reset'], mode: 'armed', why: BURSTY }, + { + iface: 'HTMLImageElement', + methods: ['decode'], + mode: 'armed', + why: 'measured stackless (EncodingError); galleries decode in bursts', + }, + { + iface: 'HTMLMediaElement', + methods: ['play'], + mode: 'always', + why: 'BGSVIEWER-14: NotSupportedError with no stack; rare', + }, + { iface: 'HTMLMediaElement', methods: ['setMediaKeys', 'setSinkId'], mode: 'always', why: RARE }, + { iface: 'HTMLVideoElement', methods: ['requestPictureInPicture'], mode: 'always', why: RARE }, + { iface: 'IDBFactory', methods: ['databases'], mode: 'always', why: RARE }, + { + iface: 'ImageCapture', + methods: ['getPhotoCapabilities', 'getPhotoSettings', 'grabFrame', 'takePhoto'], + mode: 'armed', + why: HOT, + }, + { iface: 'ImageDecoder', methods: ['decode'], mode: 'armed', why: HOT }, + { iface: 'LockManager', methods: ['query'], mode: 'always', why: RARE }, + { + iface: 'LockManager', + methods: ['request'], + mode: 'off', + why: "settles with the application callback's own result, whose Error already has a stack", + }, + { iface: 'MIDIPort', methods: ['close', 'open'], mode: 'always', why: RARE }, + { + iface: 'MediaCapabilities', + methods: ['decodingInfo', 'encodingInfo'], + mode: 'always', + why: RARE, + }, + { + iface: 'MediaDevices', + methods: ['enumerateDevices', 'getDisplayMedia', 'getUserMedia'], + mode: 'always', + why: 'measured stackless (NotFoundError); user-driven', + }, + { + iface: 'MediaKeySession', + methods: ['close', 'generateRequest', 'load', 'remove', 'update'], + mode: 'always', + why: RARE, + }, + { iface: 'MediaKeySystemAccess', methods: ['createMediaKeys'], mode: 'always', why: RARE }, + { + iface: 'MediaKeys', + methods: ['getStatusForPolicy', 'setServerCertificate'], + mode: 'always', + why: RARE, + }, + { + iface: 'MediaSession', + methods: ['setCameraActive', 'setMicrophoneActive'], + mode: 'always', + why: RARE, + }, + { + iface: 'MediaStreamTrack', + methods: ['applyConstraints'], + mode: 'armed', + why: 'per slider tick in some apps', + }, + { + iface: 'NavigationPreloadManager', + methods: ['disable', 'enable', 'getState', 'setHeaderValue'], + mode: 'always', + why: RARE, + }, + { + iface: 'Navigator', + methods: ['requestMIDIAccess', 'requestMediaKeySystemAccess', 'share'], + mode: 'always', + why: RARE, + }, + { + iface: 'NavigatorBadge', + methods: ['clearAppBadge', 'setAppBadge'], + mode: 'always', + targets: ['Navigator', 'WorkerNavigator'], + why: RARE, + }, + { iface: 'NavigatorLogin', methods: ['setStatus'], mode: 'always', why: RARE }, + { + iface: 'OfflineAudioContext', + methods: ['resume', 'startRendering', 'suspend'], + mode: 'always', + why: RARE, + }, + { iface: 'OffscreenCanvas', methods: ['convertToBlob'], mode: 'armed', why: HOT }, + { + iface: 'PaymentRequest', + methods: ['abort', 'canMakePayment', 'show'], + mode: 'always', + why: RARE, + }, + { iface: 'PaymentResponse', methods: ['complete', 'retry'], mode: 'always', why: RARE }, + { iface: 'Permissions', methods: ['query'], mode: 'always', why: RARE }, + { + iface: 'PushManager', + methods: ['getSubscription', 'permissionState', 'subscribe'], + mode: 'always', + why: RARE, + }, + { iface: 'PushSubscription', methods: ['unsubscribe'], mode: 'always', why: RARE }, + { + iface: 'RTCPeerConnection', + methods: [ + 'addIceCandidate', + 'createAnswer', + 'createOffer', + 'getStats', + 'setLocalDescription', + 'setRemoteDescription', + ], + mode: 'armed', + why: 'measured stackless (addIceCandidate); ICE trickles in bursts', + }, + { iface: 'RTCRtpReceiver', methods: ['getStats'], mode: 'armed', why: BURSTY }, + { + iface: 'RTCRtpSender', + methods: ['getStats', 'replaceTrack', 'setParameters'], + mode: 'armed', + why: BURSTY, + }, + { + iface: 'ReadableStream', + methods: ['cancel', 'pipeTo'], + mode: 'off', + why: "stream plumbing; the SDK's own body streaming runs on it, and the reason is usually the application's", + }, + { iface: 'ReadableStreamBYOBReader', methods: ['read'], mode: 'off', why: HOT }, + { iface: 'ReadableStreamDefaultReader', methods: ['read'], mode: 'off', why: HOT }, + { + iface: 'ReadableStreamGenericReader', + methods: ['cancel'], + mode: 'off', + targets: ['ReadableStreamDefaultReader', 'ReadableStreamBYOBReader'], + why: 'stream plumbing', + }, + { + iface: 'RemotePlayback', + methods: ['cancelWatchAvailability', 'prompt', 'watchAvailability'], + mode: 'always', + why: RARE, + }, + { iface: 'Scheduler', methods: ['postTask', 'yield'], mode: 'armed', why: HOT }, + { iface: 'ScreenOrientation', methods: ['lock'], mode: 'always', why: RARE }, + { + iface: 'ServiceWorkerContainer', + methods: ['getRegistration', 'getRegistrations', 'register'], + mode: 'always', + why: 'measured stackless (register); once per page', + }, + { + iface: 'ServiceWorkerRegistration', + methods: ['getNotifications', 'showNotification', 'unregister', 'update'], + mode: 'always', + why: RARE, + }, + { + iface: 'StorageManager', + methods: ['estimate', 'getDirectory', 'persist', 'persisted'], + mode: 'always', + why: RARE, + }, + { + iface: 'SubtleCrypto', + methods: [ + 'decrypt', + 'deriveBits', + 'deriveKey', + 'digest', + 'encrypt', + 'exportKey', + 'generateKey', + 'importKey', + 'sign', + 'unwrapKey', + 'verify', + 'wrapKey', + ], + mode: 'off', + why: BULK, + }, + { iface: 'VideoDecoder', methods: ['flush'], mode: 'armed', why: HOT }, + { iface: 'VideoEncoder', methods: ['flush'], mode: 'armed', why: HOT }, + { iface: 'VideoFrame', methods: ['copyTo'], mode: 'armed', why: HOT }, + { + iface: 'WakeLock', + methods: ['request'], + mode: 'always', + why: 'measured stackless (NotAllowedError); rare', + }, + { iface: 'WakeLockSentinel', methods: ['release'], mode: 'always', why: RARE }, + { + iface: 'WebTransport', + methods: ['createBidirectionalStream', 'createUnidirectionalStream'], + mode: 'armed', + why: BURSTY, + }, + { + iface: 'WindowOrWorkerGlobalScope', + methods: ['createImageBitmap'], + mode: 'armed', + targets: ['globalThis'], + why: 'measured stackless; per sprite / frame in some apps', + }, + { + iface: 'WindowOrWorkerGlobalScope', + methods: ['fetch'], + mode: 'off', + targets: ['globalThis'], + why: 'covered by the network interceptor, which already records the request; never add a second wrapper', + }, + { iface: 'WritableStream', methods: ['abort', 'close'], mode: 'off', why: 'stream plumbing' }, + { + iface: 'WritableStreamDefaultWriter', + methods: ['abort', 'close', 'write'], + mode: 'off', + why: HOT, + }, + { + iface: 'Worklet', + methods: ['addModule'], + mode: 'always', + why: 'measured stackless (AbortError); rare', + }, +]; + +/** + * Real promise-returning APIs that `lib.dom.d.ts` does not declare (it lags the platform), added BY HAND. The drift test cannot see + * these, so each carries its source. Kept to the ones the design measured or named; add more with a measurement. + */ +export const ASYNC_ORIGIN_EXTRAS: readonly AsyncOriginRow[] = [ + { + iface: 'globalThis', + methods: ['showOpenFilePicker', 'showSaveFilePicker', 'showDirectoryPicker', 'queryLocalFonts'], + mode: 'always', + targets: ['globalThis'], + why: 'File System Access / Local Font Access: user-driven, not yet in lib.dom.d.ts (design doc §3)', + }, +]; + +/** The constructors whose `prototype` (or, for `'globalThis'`, the global object itself) carries a row's methods. */ +export function rowTargets(row: AsyncOriginRow): readonly string[] { + return row.targets ?? [row.iface]; +} diff --git a/packages/capture/src/index.ts b/packages/capture/src/index.ts index 8dcb2ead..6564d899 100644 --- a/packages/capture/src/index.ts +++ b/packages/capture/src/index.ts @@ -2,6 +2,13 @@ // runtime-agnostic CaptureProviders consume those hubs. Platform packages compose this and add only // their runtime-specific sources (XHR, node:http, error/rejection globals). +export { + ASYNC_ORIGIN_EXTRAS, + ASYNC_ORIGIN_TABLE, + type AsyncOriginMode, + type AsyncOriginRow, + rowTargets, +} from './async-origin-table'; export { type CaptureStateName, CaptureStateProducer } from './capture-state-producer'; // `absolutizeUrl`/`resolveBaseUrl`/`UrlBaseGlobals` (./absolutize-url) are an internal helper of // network-provider.ts (imported directly, not via this barrel) — no external consumer, not re-exported diff --git a/packages/instrumentation-tests/test/async-origin-drift.test.ts b/packages/instrumentation-tests/test/async-origin-drift.test.ts new file mode 100644 index 00000000..8a373f5a --- /dev/null +++ b/packages/instrumentation-tests/test/async-origin-drift.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { extractDomPromiseMethods } from '../../../scripts/async-origin/extract.mjs'; +import { ASYNC_ORIGIN_TABLE } from '../../capture/src/async-origin-table'; + +// Lives here, not in @bugsee/capture: it reads files, and capture is runtime-portable (no Node types). +// DRIFT GUARD (docs/design/async-origin-capture.md §5). The candidate list is generated from the installed TypeScript's +// `lib.dom.d.ts`; the table must classify every method on it, and nothing else. A new TypeScript release that adds a +// promise-returning API fails here until someone decides always / armed / off for it. +// 1. the committed snapshot equals a fresh extraction (refresh: `pnpm async-origin:refresh`); +// 2. every snapshot method is classified in the table, exactly once; +// 3. every table method is on the snapshot (no stale row); +// 4. a WebIDL mixin (no constructor of its own) names its concrete targets. +type Snapshot = ReturnType; + +const snapshot = JSON.parse( + readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'capture', + 'spec', + 'dom-promise-methods.snapshot.json', + ), + 'utf8', + ), +) as Snapshot; + +const pairs = (interfaces: Snapshot['interfaces']): string[] => + Object.entries(interfaces) + .flatMap(([iface, entry]) => entry.methods.map((method) => `${iface}.${method}`)) + .sort(); + +describe('async-origin table drift', () => { + it('the committed snapshot matches lib.dom.d.ts of the installed TypeScript', () => { + expect(snapshot.interfaces).toEqual(extractDomPromiseMethods().interfaces); + }); + + it('classifies every promise-returning method on the snapshot, exactly once', () => { + const classified = ASYNC_ORIGIN_TABLE.flatMap((row) => + row.methods.map((method) => `${row.iface}.${method}`), + ).sort(); + expect(classified).toEqual(pairs(snapshot.interfaces)); + }); + + it('gives every mixin concrete targets', () => { + for (const row of ASYNC_ORIGIN_TABLE) { + const entry = snapshot.interfaces[row.iface]; + expect(entry, `${row.iface} is not on the snapshot`).toBeDefined(); + if (entry?.mixin) { + expect( + row.targets, + `${row.iface} is a mixin: name the constructors that carry it`, + ).toBeDefined(); + } else { + expect(row.targets, `${row.iface} has a constructor: no targets needed`).toBeUndefined(); + } + } + }); +}); diff --git a/packages/instrumentation-tests/test/spec-drift.test.ts b/packages/instrumentation-tests/test/spec-drift.test.ts index b19693b2..57ea9107 100644 --- a/packages/instrumentation-tests/test/spec-drift.test.ts +++ b/packages/instrumentation-tests/test/spec-drift.test.ts @@ -75,6 +75,11 @@ const NOT_A_WIRE_NAME: Readonly< reason: "the sanitizer's 'url' kind", literals: { url: 1 }, }, + 'capture/src/async-origin-table.ts': { + reason: + "the Web Audio method name 'resume' (AudioContext / OfflineAudioContext), a platform API name", + literals: { resume: 2 }, + }, 'electron/src/main-control.ts': { reason: "the control-channel command 'resume'", literals: { resume: 1 }, diff --git a/scripts/async-origin/extract.d.mts b/scripts/async-origin/extract.d.mts new file mode 100644 index 00000000..4d8b1ba9 --- /dev/null +++ b/scripts/async-origin/extract.d.mts @@ -0,0 +1,5 @@ +export interface DomPromiseSnapshot { + typescript: string; + interfaces: Record; +} +export function extractDomPromiseMethods(libDomPath?: string): DomPromiseSnapshot; diff --git a/scripts/async-origin/extract.mjs b/scripts/async-origin/extract.mjs new file mode 100644 index 00000000..0fedb0e3 --- /dev/null +++ b/scripts/async-origin/extract.mjs @@ -0,0 +1,50 @@ +// Extracts every promise-returning method declared in TypeScript's lib.dom.d.ts. This is the SOURCE OF TRUTH for the +// async-origin patch table (docs/design/async-origin-capture.md §3, §5): the committed snapshot is regenerated from it, and +// a drift test fails when a new TypeScript release adds an API, forcing a classification decision. +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import ts from 'typescript'; + +/** @returns {{ typescript: string, interfaces: Record }} */ +export function extractDomPromiseMethods( + libDomPath = createRequire(import.meta.url).resolve('typescript/lib/lib.dom.d.ts'), +) { + const source = ts.createSourceFile( + 'lib.dom.d.ts', + readFileSync(libDomPath, 'utf8'), + ts.ScriptTarget.Latest, + true, + ); + const constructible = new Set(); // `declare var X: {...}` — a real global with a prototype + const methods = new Map(); + const returnsPromise = (node) => { + const type = node.type; + return ( + type !== undefined && + ts.isTypeReferenceNode(type) && + type.typeName.getText(source) === 'Promise' + ); + }; + for (const statement of source.statements) { + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + constructible.add(declaration.name.getText(source)); + } + } else if (ts.isInterfaceDeclaration(statement)) { + const found = methods.get(statement.name.text) ?? new Set(); + for (const member of statement.members) { + if (ts.isMethodSignature(member) && returnsPromise(member)) { + found.add(member.name.getText(source)); + } + } + if (found.size > 0) { + methods.set(statement.name.text, found); + } + } + } + const interfaces = {}; + for (const name of [...methods.keys()].sort()) { + interfaces[name] = { mixin: !constructible.has(name), methods: [...methods.get(name)].sort() }; + } + return { typescript: ts.version, interfaces }; +} diff --git a/scripts/async-origin/refresh.mjs b/scripts/async-origin/refresh.mjs new file mode 100644 index 00000000..a68dd2e8 --- /dev/null +++ b/scripts/async-origin/refresh.mjs @@ -0,0 +1,24 @@ +// Regenerates packages/capture/spec/dom-promise-methods.snapshot.json from the installed TypeScript's lib.dom.d.ts. +// Run after a TypeScript upgrade (`pnpm async-origin:refresh`), then classify any new method in +// packages/capture/src/async-origin-table.ts. +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractDomPromiseMethods } from './extract.mjs'; + +const target = join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'packages', + 'capture', + 'spec', + 'dom-promise-methods.snapshot.json', +); +mkdirSync(dirname(target), { recursive: true }); +const snapshot = extractDomPromiseMethods(); +writeFileSync(target, `${JSON.stringify(snapshot, null, 2)}\n`); +const names = Object.values(snapshot.interfaces); +console.log( + `typescript ${snapshot.typescript}: ${names.length} interfaces, ${names.reduce((n, i) => n + i.methods.length, 0)} methods`, +); From 50210310bb9c48952a1b4fd12ef87ab4f1d2d19e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 22:01:11 +0500 Subject: [PATCH 04/32] =?UTF-8?q?feat(capture):=20installAsyncOrigins=20?= =?UTF-8?q?=E2=80=94=20call-site=20recorder=20for=20stackless=20async=20pl?= =?UTF-8?q?atform=20rejections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the promise-returning methods of the async-origin table. Returns a derived promise built with a pristine then (zone.js' stored native then, else one captured at install from a native global or a hidden iframe realm, else none and no reaction is attached), keys the origin by the rejection reason (with a bounded .cause walk), retains nothing for a reason that already has a stack, and degrades to the untouched promise on any failure of its own. 'always' rows install at once; 'armed' rows install on arm(). Per-method capture budget reset by one shared unref'd 60 s timer. Idempotent, composes with other patchers, restores on stop. Verified in headless Chromium with and without zone.js. Step 4 of docs/design/async-origin-capture.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/async-origin-capture.md | 6 + packages/capture/src/async-origin.test.ts | 635 ++++++++++++++++++++++ packages/capture/src/async-origin.ts | 353 ++++++++++++ packages/capture/src/index.ts | 6 + 4 files changed, 1000 insertions(+) create mode 100644 packages/capture/src/async-origin.test.ts create mode 100644 packages/capture/src/async-origin.ts diff --git a/docs/design/async-origin-capture.md b/docs/design/async-origin-capture.md index 2c50e4d5..9f4c8913 100644 --- a/docs/design/async-origin-capture.md +++ b/docs/design/async-origin-capture.md @@ -221,6 +221,12 @@ 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 its first rejection.** Before that the wrapper is a pass-through with one boolean check (about the cost of the native call); the first failure is reported without a call site (the report still has name/message and, where a describer exists, the target), and from the second failure on the call site is captured. Cost on the fast path: one load and diff --git a/packages/capture/src/async-origin.test.ts b/packages/capture/src/async-origin.test.ts new file mode 100644 index 00000000..45d8773b --- /dev/null +++ b/packages/capture/src/async-origin.test.ts @@ -0,0 +1,635 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { type AsyncOriginRecorder, installAsyncOrigins } from './async-origin'; +import type { AsyncOriginRow } from './async-origin-table'; + +// A stackless platform rejection reason (what a DOMException from an async media task looks like: an object, no `.stack`). +const stackless = (name = 'NotSupportedError'): { name: string; message: string } => ({ + name, + message: 'no sources', +}); + +type Fake = Record; +const callSite = (fn: () => unknown): unknown => fn(); // a named frame the captured stack must contain + +const row = (over: Partial = {}): AsyncOriginRow => ({ + iface: 'Media', + methods: ['play'], + mode: 'always', + why: 'test', + ...over, +}); + +function fakeGlobal(play: (this: unknown, ...args: unknown[]) => unknown): Fake { + class Media { + play(...args: unknown[]): unknown { + return play.apply(this, args); + } + } + return { Media, Promise, Error }; +} + +const media = (g: Fake): { prototype: { play(...args: unknown[]): unknown } } => + g.Media as { prototype: { play(...args: unknown[]): unknown } }; + +let recorder: AsyncOriginRecorder | undefined; +afterEach(() => { + recorder?.stop(); + recorder = undefined; +}); + +const install = ( + g: Fake, + options: Parameters[0] = {}, +): AsyncOriginRecorder => { + recorder = installAsyncOrigins({ global: g, rows: [row()], ...options }); + return recorder; +}; + +describe('installAsyncOrigins — what is wrapped', () => { + it('wraps an always-on row, leaves an armed row alone until arm(), and never wraps an off row', () => { + const g = { + A: class { + a() { + return 1; + } + }, + B: class { + b() { + return 1; + } + }, + C: class { + c() { + return 1; + } + }, + }; + const originals = { a: g.A.prototype.a, b: g.B.prototype.b, c: g.C.prototype.c }; + const r = install(g, { + rows: [ + row({ iface: 'A', methods: ['a'], mode: 'always' }), + row({ iface: 'B', methods: ['b'], mode: 'armed' }), + row({ iface: 'C', methods: ['c'], mode: 'off' }), + ], + }); + expect(g.A.prototype.a).not.toBe(originals.a); + expect(g.B.prototype.b).toBe(originals.b); + expect(r.installed).toEqual(['A.a']); + r.arm(); + expect(g.B.prototype.b).not.toBe(originals.b); + expect(g.C.prototype.c).toBe(originals.c); + expect(r.installed).toEqual(['A.a', 'B.b']); + r.arm(); // idempotent + expect(r.installed).toEqual(['A.a', 'B.b']); + }); + + it('only installs the modes asked for', () => { + const g = { + A: class { + a() { + return 1; + } + }, + B: class { + b() { + return 1; + } + }, + }; + const r = install(g, { + modes: ['off'], + rows: [ + row({ iface: 'A', methods: ['a'], mode: 'always' }), + row({ iface: 'B', methods: ['b'], mode: 'off' }), + ], + }); + expect(r.installed).toEqual(['B.b']); + r.arm(); + expect(r.installed).toEqual(['B.b']); + }); + + it('wraps every target of a mixin row and the global object itself', () => { + const fetchLike = vi.fn(() => 1); + const g = { + fetch: fetchLike, + Request: class { + json() { + return 1; + } + }, + Response: class { + json() { + return 1; + } + }, + }; + const r = install(g, { + rows: [ + row({ iface: 'Body', methods: ['json'], targets: ['Request', 'Response'] }), + row({ iface: 'WindowOrWorkerGlobalScope', methods: ['fetch'], targets: ['globalThis'] }), + ], + }); + expect(r.installed).toEqual(['Request.json', 'Response.json', 'globalThis.fetch']); + expect(g.fetch).not.toBe(fetchLike); + }); + + it('skips what the realm does not have (workers lack HTMLMediaElement) without throwing', () => { + const r = install( + {}, + { rows: [row({ iface: 'HTMLMediaElement' }), row({ iface: 'Object', methods: ['nope'] })] }, + ); + expect(r.installed).toEqual([]); + const g = { Media: class {} }; + expect(install(g).installed).toEqual([]); // ctor present, method absent + }); + + it('skips an inherited, non-function, frozen or non-configurable method rather than throwing', () => { + class Base { + play() { + return 1; + } + } + class Sub extends Base {} + const frozen = class { + play() { + return 1; + } + }; + Object.freeze(frozen.prototype); + const accessor = class {}; + Object.defineProperty(accessor.prototype, 'play', { get: () => () => 1, configurable: true }); + const value = class {}; + Object.defineProperty(value.prototype, 'play', { + value: 5, + configurable: true, + writable: true, + }); + const locked = class {}; + Object.defineProperty(locked.prototype, 'play', { + value: () => 1, + configurable: false, + writable: false, + }); + const r = install( + { Sub, frozen, accessor, value, locked }, + { rows: ['Sub', 'frozen', 'accessor', 'value', 'locked'].map((iface) => row({ iface })) }, + ); + expect(r.installed).toEqual([]); + }); + + it('is idempotent: a second install over the same global wraps nothing and does not double-wrap', () => { + const g = fakeGlobal(() => Promise.resolve(1)); + const first = install(g); + const wrapped = media(g).prototype.play; + const second = installAsyncOrigins({ global: g, rows: [row()] }); + expect(second.installed).toEqual([]); + expect(media(g).prototype.play).toBe(wrapped); + second.stop(); // must not unwrap the first instance's patch + expect(media(g).prototype.play).toBe(wrapped); + first.stop(); + }); +}); + +describe('installAsyncOrigins — the wrapper keeps the call faithful', () => { + it('forwards this and arguments and returns the platform result on success', async () => { + const seen: unknown[] = []; + const g = fakeGlobal(function (this: unknown, ...args: unknown[]) { + seen.push(this, ...args); + return Promise.resolve('ok'); + }); + install(g); + const el = new (g.Media as new () => { play(...a: unknown[]): Promise })(); + await expect(el.play(1, 'two')).resolves.toBe('ok'); + expect(seen[0]).toBe(el); + expect(seen.slice(1)).toEqual([1, 'two']); + }); + + it('keeps name and length, and toString answers as the original', () => { + const g = fakeGlobal(() => 1); + class Plain { + play(_a: unknown, _b: unknown) { + return Promise.resolve(); + } + } + (g as Fake).Plain = Plain; + const original = Plain.prototype.play; + install(g, { rows: [row({ iface: 'Plain' })] }); + expect(Plain.prototype.play).not.toBe(original); + expect(Plain.prototype.play.name).toBe('play'); + expect(Plain.prototype.play.length).toBe(2); + expect(Plain.prototype.play.toString()).toBe(Function.prototype.toString.call(original)); + }); + + it('returns a non-thenable result untouched and lets a synchronous throw through unchanged', () => { + const boom = new TypeError('sync'); + let mode: 'value' | 'throw' = 'value'; + const g = fakeGlobal(() => { + if (mode === 'throw') throw boom; + return 42; + }); + install(g); + const el = new (g.Media as new () => { play(): unknown })(); + expect(el.play()).toBe(42); + mode = 'throw'; + expect(() => el.play()).toThrow(boom); + }); +}); + +describe('installAsyncOrigins — recording the origin', () => { + it('returns a derived promise with the SAME reason object and records where it was called', async () => { + const reason = stackless(); + const original = Promise.reject(reason); + original.catch(() => undefined); + const g = fakeGlobal(() => original); + const r = install(g); + const el = new (g.Media as new () => { play(): Promise })(); + const derived = callSite(() => el.play()) as Promise; + expect(derived).not.toBe(original); + await expect(derived).rejects.toBe(reason); + const origin = r.originOf(reason); + expect(origin?.api).toBe('Media.play'); + expect(origin?.target).toBe(el); + expect(origin?.holder.stack).toContain('callSite'); + expect(r.originOfPromise(derived)).toBeUndefined(); // derived mode keys by reason, not by promise + }); + + it('does not retain a rejection that already carries a stack, nor overwrite the first origin', async () => { + const withStack = new Error('has a stack'); + const bare = stackless(); + let next: unknown = withStack; + const g = fakeGlobal(() => Promise.reject(next)); + const r = install(g); + const el = new (g.Media as new () => { play(): Promise })(); + await expect(el.play()).rejects.toBe(withStack); + expect(r.originOf(withStack)).toBeUndefined(); + next = bare; + await expect(el.play()).rejects.toBe(bare); + const first = r.originOf(bare); + await expect(el.play()).rejects.toBe(bare); // same reason object again + expect(r.originOf(bare)).toBe(first); + }); + + it('passes a primitive rejection through unchanged and records nothing for it', async () => { + const g = fakeGlobal(() => Promise.reject('plain string')); + const r = install(g); + const el = new (g.Media as new () => { play(): Promise })(); + await expect(el.play()).rejects.toBe('plain string'); + expect(r.originOf('plain string')).toBeUndefined(); + }); + + it('records for a function-valued reason too', async () => { + const reason = Object.assign(() => 1, { name2: 'fn' }); + const g = fakeGlobal(() => Promise.reject(reason)); + const r = install(g); + const el = new (g.Media as new () => { play(): Promise })(); + await expect(el.play()).rejects.toBe(reason); + expect(r.originOf(reason)).toBeDefined(); + }); + + it('finds the origin through reason.cause (axios/ky/ofetch wrap the platform failure), a few levels deep', async () => { + const inner = stackless(); + const g = fakeGlobal(() => Promise.reject(inner)); + const r = install(g); + const el = new (g.Media as new () => { play(): Promise })(); + await el.play().catch(() => undefined); + const wrapped = { cause: { cause: inner } }; + expect(r.originOf(wrapped)?.api).toBe('Media.play'); + const tooDeep = { cause: { cause: { cause: { cause: inner } } } }; + expect(r.originOf(tooDeep)).toBeUndefined(); + const loop: { cause?: unknown } = {}; + loop.cause = loop; + expect(r.originOf(loop)).toBeUndefined(); + expect(r.originOf(null)).toBeUndefined(); + expect(r.originOf('str')).toBeUndefined(); + }); + + it('records the target for the report to describe later', async () => { + const g = fakeGlobal(() => Promise.reject(stackless())); + const r = install(g); + const el = new (g.Media as new () => { play(): Promise })(); + const p = el.play(); + const reason = await p.catch((e: unknown) => e); + expect(r.originOf(reason)?.target).toBe(el); + }); +}); + +describe('installAsyncOrigins — a pristine then, never the global one', () => { + const nativeThen = Promise.prototype.then; + + it("uses zone.js's stored native then (p.__zone_symbol__then) and never the promise's own patched then", async () => { + const patchedThen = vi.fn(() => { + throw new Error('the patched then must not be called'); + }); + const reason = stackless(); + const real = Promise.reject(reason); + real.catch(() => undefined); + // The promise-like's own (patched) `then` must never run; the native one is reached through the stored symbol, bound to `real`. + const promiseLike: Record = { + __zone_symbol__then: (...a: unknown[]) => nativeThen.apply(real, a as never), + }; + Object.defineProperty(promiseLike, 'then', { value: patchedThen, enumerable: true }); + const g = fakeGlobal(() => promiseLike); + const r = install(g, { cleanThen: null }); + const el = new (g.Media as new () => { play(): Promise })(); + await expect(el.play()).rejects.toBe(reason); + expect(patchedThen).not.toHaveBeenCalled(); + expect(r.originOf(reason)).toBeDefined(); + }); + + it('falls back to the then captured at install when the promise carries no stored one', async () => { + const reason = stackless(); + const real = Promise.reject(reason); + real.catch(() => undefined); + const g = fakeGlobal(() => real); + const r = install(g); // auto: the global Promise is native here + const el = new (g.Media as new () => { play(): Promise })(); + await expect(el.play()).rejects.toBe(reason); + expect(r.originOf(reason)).toBeDefined(); + }); + + it('with NO pristine then it attaches no reaction, returns the same promise and keys the origin by promise', () => { + const real = Promise.reject(stackless()); + real.catch(() => undefined); + const g = fakeGlobal(() => real); + const r = install(g, { cleanThen: null }); + const el = new (g.Media as new () => { play(): Promise })(); + const returned = callSite(() => el.play()); + expect(returned).toBe(real); + expect(r.originOfPromise(real)?.api).toBe('Media.play'); + expect(r.originOfPromise({})).toBeUndefined(); + expect(r.originOfPromise(null)).toBeUndefined(); + }); + + it('a then that throws degrades to the untouched promise, never to a broken call', () => { + const real = Promise.resolve(1); + const exploding = () => { + throw new TypeError('Incompatible receiver'); + }; + const g = fakeGlobal(() => real); + const r = install(g, { cleanThen: exploding as never }); + const el = new (g.Media as new () => { play(): unknown })(); + expect(el.play()).toBe(real); + expect(r.originOfPromise(real)).toBeDefined(); + }); + + it('a then that returns a non-thenable degrades to the untouched promise', () => { + const real = Promise.resolve(1); + const g = fakeGlobal(() => real); + install(g, { cleanThen: (() => 5) as never }); + const el = new (g.Media as new () => { play(): unknown })(); + expect(el.play()).toBe(real); + }); + + it('captures the clean then from a hidden iframe realm when the global Promise is not native', async () => { + const reason = stackless(); + const real = Promise.reject(reason); + real.catch(() => undefined); + const removed = vi.fn(); + const frame = { + style: {} as Record, + setAttribute: vi.fn(), + remove: removed, + contentWindow: { Promise }, + }; + const appended: unknown[] = []; + const doc = { + createElement: () => frame, + documentElement: { appendChild: (n: unknown) => appended.push(n) }, + }; + const g: Fake = { ...fakeGlobal(() => real), Promise: class FakeZonePromise {}, document: doc }; + const r = install(g); + const el = new (g.Media as new () => { play(): Promise })(); + await expect(el.play()).rejects.toBe(reason); + expect(r.originOf(reason)).toBeDefined(); + expect(appended).toEqual([frame]); + expect(removed).toHaveBeenCalledTimes(1); + }); + + it('gives up cleanly (no pristine then, promise returned untouched) when the iframe route is missing, throws, is not native or has no window', () => { + const notNative = { Promise: class NotNative {} }; + const frame = (contentWindow?: object) => ({ + style: {}, + setAttribute: () => undefined, + remove: () => undefined, + contentWindow, + }); + const docs: (object | undefined)[] = [ + undefined, + { + createElement: () => frame({ Promise: class X {} }), + documentElement: { appendChild: () => undefined }, + }, + { createElement: () => frame(), documentElement: { appendChild: () => undefined } }, + { createElement: () => frame(), documentElement: undefined }, + { + createElement: () => { + throw new Error('csp'); + }, + documentElement: { appendChild: () => undefined }, + }, + ]; + for (const document of docs) { + const real = Promise.resolve(1); + const g: Fake = { ...fakeGlobal(() => real), ...notNative, document }; + const r = install(g); + const el = new (g.Media as new () => { play(): unknown })(); + expect(el.play()).toBe(real); + expect(r.originOfPromise(real)).toBeDefined(); // fallback mode: keyed by promise + r.stop(); + } + }); +}); + +describe('installAsyncOrigins — cost bounds', () => { + it('stops capturing a method after its budget and resumes when the shared timer resets it', async () => { + let reset: (() => void) | undefined; + const setIntervalStub = vi.fn((fn: () => void, _ms: number) => { + reset = fn; + return 7; + }); + const clearIntervalStub = vi.fn(); + const g = fakeGlobal(() => Promise.reject(stackless())); + const r = install(g, { + maxCapturesPerMinute: 2, + setInterval: setIntervalStub as never, + clearInterval: clearIntervalStub as never, + }); + const el = new (g.Media as new () => { play(): Promise })(); + const reasons: unknown[] = []; + for (let i = 0; i < 3; i++) { + reasons.push(await el.play().catch((e: unknown) => e)); + } + expect(r.originOf(reasons[0])).toBeDefined(); + expect(r.originOf(reasons[1])).toBeDefined(); + expect(r.originOf(reasons[2])).toBeUndefined(); // over budget: untouched pass-through + expect(setIntervalStub).toHaveBeenCalledTimes(1); + expect(setIntervalStub.mock.calls[0]?.[1]).toBe(60_000); + reset?.(); + const later = await el.play().catch((e: unknown) => e); + expect(r.originOf(later)).toBeDefined(); + r.stop(); + expect(clearIntervalStub).toHaveBeenCalledWith(7); + }); + + it('unrefs the reset timer so it never keeps a process alive, and tolerates a timer without unref', () => { + const unref = vi.fn(); + install( + fakeGlobal(() => 1), + { setInterval: (() => ({ unref })) as never, clearInterval: (() => undefined) as never }, + ); + expect(unref).toHaveBeenCalledTimes(1); + recorder?.stop(); + install( + fakeGlobal(() => 1), + { setInterval: (() => 5) as never, clearInterval: (() => undefined) as never }, + ); + }); + + it('does not start a timer when nothing was installed', () => { + const setIntervalStub = vi.fn(); + install({}, { setInterval: setIntervalStub as never }); + expect(setIntervalStub).not.toHaveBeenCalled(); + }); + + it('works without any timer API (a restricted realm): the budget then never resets', async () => { + const g = fakeGlobal(() => Promise.reject(stackless())); + const r = install(g, { + maxCapturesPerMinute: 1, + setInterval: undefined, + clearInterval: undefined, + }); + const el = new (g.Media as new () => { play(): Promise })(); + const first = await el.play().catch((e: unknown) => e); + expect(r.originOf(first)).toBeDefined(); + }); + + it('uses the engine stack-capture where it exists (no frame to skip) and new Error() elsewhere (skip the wrapper frame)', async () => { + const g1 = fakeGlobal(() => Promise.reject(stackless())); + const r1 = install(g1); + const el1 = new (g1.Media as new () => { play(): Promise })(); + const reason1 = await el1.play().catch((e: unknown) => e); + expect(r1.originOf(reason1)?.skip).toBe( + typeof (Error as { captureStackTrace?: unknown }).captureStackTrace === 'function' ? 0 : 1, + ); + r1.stop(); + const g2 = fakeGlobal(() => Promise.reject(stackless())); + const r2 = install(g2, { captureStackTrace: null }); + const el2 = new (g2.Media as new () => { play(): Promise })(); + const reason2 = await el2.play().catch((e: unknown) => e); + expect(r2.originOf(reason2)?.skip).toBe(1); + expect(typeof r2.originOf(reason2)?.holder.stack).toBe('string'); + }); + + it('a failure while capturing degrades to the untouched result', () => { + const real = Promise.resolve(1); + const g = fakeGlobal(() => real); + install(g, { + captureStackTrace: () => { + throw new Error('capture failed'); + }, + }); + const el = new (g.Media as new () => { play(): unknown })(); + expect(el.play()).toBe(real); + }); +}); + +describe('installAsyncOrigins — stop', () => { + it('restores the original method, and a wrapper someone kept calling passes straight through', async () => { + const g = fakeGlobal(() => Promise.reject(stackless())); + const original = media(g).prototype.play; + const r = install(g); + const wrapped = media(g).prototype.play; + r.stop(); + expect(media(g).prototype.play).toBe(original); + const reason = await ( + wrapped.call(new (g.Media as new () => object)()) as Promise + ).catch((e: unknown) => e); + expect(r.originOf(reason)).toBeUndefined(); + r.stop(); // idempotent + }); + + it('leaves a method alone that another patcher wrapped on top of ours', () => { + const g = fakeGlobal(() => Promise.resolve(1)); + const r = install(g); + const ours = media(g).prototype.play; + const theirs = function theirs(this: unknown, ...a: unknown[]) { + return ours.apply(this, a); + }; + media(g).prototype.play = theirs; + r.stop(); + expect(media(g).prototype.play).toBe(theirs); + }); + + it('composes on top of an earlier third-party patch rather than replacing it', async () => { + const reason = stackless(); + const g = fakeGlobal(() => Promise.reject(reason)); + const inner = media(g).prototype.play; + const calls: string[] = []; + media(g).prototype.play = function thirdParty(this: unknown, ...a: unknown[]) { + calls.push('third-party'); + return inner.apply(this, a); + }; + const r = install(g); + const el = new (g.Media as new () => { play(): Promise })(); + await expect(el.play()).rejects.toBe(reason); + expect(calls).toEqual(['third-party']); + expect(r.originOf(reason)).toBeDefined(); + }); +}); + +describe('installAsyncOrigins — defensive paths', () => { + it('skips a method whose define was silently ignored (a proxied prototype)', () => { + const target = { play: () => Promise.resolve(1) }; + const proto = new Proxy(target, { defineProperty: () => true }); + const r = install({ Media: { prototype: proto }, Promise }, {}); + expect(r.installed).toEqual([]); + expect(proto.play).toBe(target.play); + }); + + it('skips a method whose define the realm refuses (a trap that rejects it)', () => { + const target = { play: () => Promise.resolve(1) }; + const proto = new Proxy(target, { defineProperty: () => false }); + expect(install({ Media: { prototype: proto }, Promise }, {}).installed).toEqual([]); + }); + + it('swallows a hostile realm silently when no onError was given', () => { + const g = fakeGlobal(() => 1); + Object.defineProperty(g, 'Media', { + get() { + throw new Error('hostile getter'); + }, + }); + expect(() => install(g)).not.toThrow(); + }); + + it('reports a restore that the realm refuses (the prototype was frozen after install) and carries on', () => { + const onError = vi.fn(); + const g = fakeGlobal(() => Promise.resolve(1)); + const r = install(g, { onError }); + Object.freeze(media(g).prototype); + r.stop(); + expect(onError).toHaveBeenCalledTimes(1); + }); +}); + +describe('installAsyncOrigins — defaults', () => { + it('installs from the real table onto the real global when given neither', () => { + const r = installAsyncOrigins({ modes: ['always'] }); + expect(Array.isArray(r.installed)).toBe(true); // node has Blob/Request/... but none are 'always' rows; must not throw + r.stop(); + }); + + it('reports an internal failure through onError and carries on', () => { + const onError = vi.fn(); + const g = fakeGlobal(() => 1); + Object.defineProperty(g, 'Media', { + get() { + throw new Error('hostile getter'); + }, + }); + const r = installAsyncOrigins({ global: g, rows: [row()], onError }); + expect(r.installed).toEqual([]); + expect(onError).toHaveBeenCalledTimes(1); + r.stop(); + }); +}); diff --git a/packages/capture/src/async-origin.ts b/packages/capture/src/async-origin.ts new file mode 100644 index 00000000..f9ddc515 --- /dev/null +++ b/packages/capture/src/async-origin.ts @@ -0,0 +1,353 @@ +// Async origin capture (docs/design/async-origin-capture.md §4). A platform API that fails ASYNCHRONOUSLY rejects with a +// value that has no stack (`video.play()` → a `NotSupportedError` DOMException), so the report says what failed and not where. +// This wraps the promise-returning methods listed in `async-origin-table.ts` and records the CALL SITE, keyed by the REASON +// object so it can be found again wherever the rejection surfaces (`unhandledrejection`, a framework's error handler, a boundary). +// +// The trap that shapes the code: attaching ANY reaction to a promise marks it handled and silences `unhandledrejection`. So the +// wrapper returns a DERIVED promise, built with a PRISTINE `then` (never the global one, which zone.js / core-js / Bluebird +// replace), and when no pristine `then` exists it attaches nothing and keys the origin by the returned promise instead. +// A wrapper must never break the app's call: every failure of our own degrades to "return the platform's promise untouched". +// +// Runtime-portable: it reaches runtime globals only through the injected `global`; no DOM or `node:*` types. +import { + ASYNC_ORIGIN_EXTRAS, + ASYNC_ORIGIN_TABLE, + type AsyncOriginMode, + type AsyncOriginRow, + rowTargets, +} from './async-origin-table'; + +/** Where a platform API was called. */ +export interface AsyncOrigin { + /** `Interface.method` of the target the method was found on, e.g. `HTMLMediaElement.play`. */ + api: string; + /** The receiver (`this`) of the call: the element / registry / context. Describe it at REPORT time, not here. */ + target: unknown; + /** Holds the call-site stack. `.stack` is formatted lazily by the engine, so reading it is paid only when a report needs it. */ + holder: { stack?: string }; + /** Leading stack frames that are the wrapper's own (1 when the engine could not omit it, else 0). */ + skip: number; +} + +export interface AsyncOriginRecorder { + /** The origin for a rejection reason: its own, else one found through `reason.cause` (a few levels). */ + originOf(reason: unknown): AsyncOrigin | undefined; + /** The origin recorded against a promise, for the fallback mode that attaches no reaction (a bare call only). */ + originOfPromise(promise: unknown): AsyncOrigin | undefined; + /** Install the `armed` rows. Called on the first stackless rejection nobody could locate; idempotent. */ + arm(): void; + /** `Interface.method` of every wrapper currently installed (targets named for mixins). */ + readonly installed: readonly string[]; + /** Restore every method this recorder wrapped (leaving one that another patcher has since wrapped on top). */ + stop(): void; +} + +type ThenFn = (this: unknown, ...args: unknown[]) => unknown; +type CaptureStackTrace = (target: object, constructorOpt: (...args: never[]) => unknown) => void; + +export interface InstallAsyncOriginsOptions { + /** The realm to patch. Default `globalThis`. */ + global?: object; + /** Default: the generated table plus the hand-added extras. */ + rows?: readonly AsyncOriginRow[]; + /** Which modes to install. Default `['always', 'armed']`; `armed` rows install on `arm()`, `off` rows only when asked for. */ + modes?: readonly AsyncOriginMode[]; + /** Per-method cap on captured call sites per minute; the call passes through untouched beyond it. Default 200. */ + maxCapturesPerMinute?: number; + /** A `then` known to be the engine's own. Default: the global one if still native, else a hidden iframe's. `null`: none. */ + cleanThen?: ThenFn | null; + /** `Error.captureStackTrace` where the engine has it (omits the wrapper frame, one capture). `null`: not available. */ + captureStackTrace?: CaptureStackTrace | null; + setInterval?: ((fn: () => void, ms: number) => unknown) | undefined; + clearInterval?: ((handle: unknown) => void) | undefined; + onError?: (error: unknown) => void; +} + +const MARK = Symbol.for('bugsee.asyncOrigin'); +const CAUSE_DEPTH = 3; +const BUDGET_WINDOW_MS = 60_000; +const DEFAULT_BUDGET = 200; +const ZONE_THEN = '__zone_symbol__then'; + +const isObjectLike = (value: unknown): value is object => + (typeof value === 'object' && value !== null) || typeof value === 'function'; + +const isThenable = (value: unknown): value is { then: unknown } => + isObjectLike(value) && typeof (value as { then?: unknown }).then === 'function'; + +const isNative = (fn: unknown): boolean => + typeof fn === 'function' && + /\{\s*\[native code\]\s*\}\s*$/.test(Function.prototype.toString.call(fn)); + +/** A `then` that is the engine's own, read once at install. See design §4.1a. */ +function cleanThenOf(global: Record): ThenFn | undefined { + const own = (global.Promise as { prototype?: { then?: unknown } } | undefined)?.prototype?.then; + if (isNative(global.Promise) && isNative(own)) { + return own as ThenFn; + } + // The global Promise was replaced (zone.js, core-js, Bluebird): a fresh same-origin iframe still has the native one. A promise + // from another realm is fine: the brand check is on the promise, not the realm. + const doc = global.document as + | { + createElement?: (tag: string) => { + style?: Record; + setAttribute?: (name: string, value: string) => void; + remove?: () => void; + contentWindow?: { Promise?: { prototype?: { then?: unknown } } }; + }; + documentElement?: { appendChild?: (node: unknown) => unknown }; + } + | undefined; + try { + const frame = doc?.createElement?.('iframe'); + if (frame === undefined || doc?.documentElement?.appendChild === undefined) { + return undefined; + } + if (frame.style !== undefined) { + frame.style.display = 'none'; + } + frame.setAttribute?.('aria-hidden', 'true'); + doc.documentElement.appendChild(frame); + const then = frame.contentWindow?.Promise?.prototype?.then; + frame.remove?.(); + return isNative(then) ? (then as ThenFn) : undefined; + } catch { + return undefined; + } +} + +interface Installed { + key: string; + owner: Record; + name: string; + original: unknown; + wrapper: unknown; + state: { active: boolean }; +} + +export function installAsyncOrigins(options: InstallAsyncOriginsOptions = {}): AsyncOriginRecorder { + const global = (options.global ?? globalThis) as Record; + const rows = options.rows ?? [...ASYNC_ORIGIN_TABLE, ...ASYNC_ORIGIN_EXTRAS]; + const modes = new Set(options.modes ?? ['always', 'armed']); + const budget = options.maxCapturesPerMinute ?? DEFAULT_BUDGET; + const onError = options.onError ?? (() => undefined); + const cleanThen = + options.cleanThen === undefined ? cleanThenOf(global) : (options.cleanThen ?? undefined); + const captureStackTrace: CaptureStackTrace | undefined = + options.captureStackTrace === undefined + ? ( + global.Error as { captureStackTrace?: CaptureStackTrace } | undefined + )?.captureStackTrace?.bind(global.Error) + : (options.captureStackTrace ?? undefined); + const setTimer = + 'setInterval' in options + ? options.setInterval + : (global.setInterval as typeof options.setInterval); + const clearTimer = + 'clearInterval' in options + ? options.clearInterval + : (global.clearInterval as typeof options.clearInterval); + + const byReason = new WeakMap(); + const byPromise = new WeakMap(); + const counters: { used: number }[] = []; + const installed: Installed[] = []; + let timer: unknown; + let timerStarted = false; + let stopped = false; + + const startTimer = (): void => { + if (timerStarted || setTimer === undefined) { + return; + } + timerStarted = true; + timer = setTimer(() => { + for (const counter of counters) { + counter.used = 0; + } + }, BUDGET_WINDOW_MS); + (timer as { unref?: () => void } | undefined)?.unref?.(); + }; + + const thenOf = (promise: object): ThenFn | undefined => { + const stored = (promise as Record)[ZONE_THEN]; + return typeof stored === 'function' ? (stored as ThenFn) : cleanThen; + }; + + function wrap(owner: Record, name: string, api: string): Installed | undefined { + const descriptor = Object.getOwnPropertyDescriptor(owner, name); + const original = descriptor?.value; + if ( + descriptor === undefined || + typeof original !== 'function' || + (original as { [MARK]?: boolean })[MARK] === true + ) { + return undefined; + } + const state = { active: true }; + const counter = { used: 0 }; + + const observe = ( + promise: object, + target: unknown, + self: (...args: never[]) => unknown, + ): unknown => { + if (counter.used >= budget) { + return promise; + } + counter.used++; + const holder: { stack?: string } = {}; + let skip = 0; + if (captureStackTrace !== undefined) { + captureStackTrace(holder, self); + } else { + holder.stack = new Error().stack; + skip = 1; + } + const origin: AsyncOrigin = { api, target, holder, skip }; + const then = thenOf(promise); + if (then !== undefined) { + try { + const derived = then.call(promise, undefined, (reason: unknown) => { + // Only a reason with no stack of its own is worth remembering, and the FIRST origin wins. + if (isObjectLike(reason) && !byReason.has(reason)) { + const stack = (reason as { stack?: unknown }).stack; + if (typeof stack !== 'string' || stack === '') { + byReason.set(reason, origin); + } + } + throw reason; + }); + if (isThenable(derived)) { + return derived; + } + } catch { + // fall through: no usable then on this promise + } + } + byPromise.set(promise, origin); + return promise; + }; + + const wrapper = function wrapped(this: unknown, ...args: unknown[]): unknown { + const result = (original as (...a: unknown[]) => unknown).apply(this, args); + if (!state.active || !isThenable(result)) { + return result; + } + try { + return observe(result as object, this, wrapped as never); + } catch { + return result; + } + }; + Object.defineProperty(wrapper, 'name', { + value: (original as { name: string }).name, + configurable: true, + }); + Object.defineProperty(wrapper, 'length', { + value: (original as { length: number }).length, + configurable: true, + }); + Object.defineProperty(wrapper, 'toString', { + value: () => Function.prototype.toString.call(original), + configurable: true, + writable: true, + }); + Object.defineProperty(wrapper, MARK, { value: true }); + try { + Object.defineProperty(owner, name, { ...descriptor, value: wrapper }); + } catch { + return undefined; // frozen or sealed prototype + } + if (owner[name] !== wrapper) { + return undefined; // a setter-less or otherwise ignored define + } + counters.push(counter); + return { key: api, owner, name, original, wrapper, state }; + } + + const installRows = (wanted: (mode: AsyncOriginMode) => boolean): void => { + for (const row of rows) { + if (!wanted(row.mode)) { + continue; + } + for (const target of rowTargets(row)) { + try { + const owner = + target === 'globalThis' + ? global + : (global[target] as { prototype?: unknown } | undefined)?.prototype; + if (!isObjectLike(owner)) { + continue; + } + for (const method of row.methods) { + const entry = wrap(owner as Record, method, `${target}.${method}`); + if (entry !== undefined) { + installed.push(entry); + } + } + } catch (error) { + onError(error); + } + } + } + if (installed.length > 0) { + startTimer(); + } + }; + + installRows((mode) => mode === 'always' && modes.has('always')); + if (modes.has('off')) { + installRows((mode) => mode === 'off'); + } + + return { + originOf(reason: unknown): AsyncOrigin | undefined { + let current: unknown = reason; + // Bounded, so a cyclic `cause` costs a few steps rather than a loop. + for (let depth = 0; depth <= CAUSE_DEPTH && isObjectLike(current); depth++) { + const found = byReason.get(current); + if (found !== undefined) { + return found; + } + current = (current as { cause?: unknown }).cause; + } + return undefined; + }, + originOfPromise(promise: unknown): AsyncOrigin | undefined { + return isObjectLike(promise) ? byPromise.get(promise) : undefined; + }, + arm(): void { + if (stopped || !modes.has('armed')) { + return; // already-wrapped methods are skipped by their mark, so a second call is a no-op + } + installRows((mode) => mode === 'armed'); + }, + get installed(): readonly string[] { + return installed.filter((entry) => entry.state.active).map((entry) => entry.key); + }, + stop(): void { + if (stopped) { + return; + } + stopped = true; + for (const entry of installed) { + entry.state.active = false; + // Leave it alone when another patcher wrapped on top of ours: restoring would drop THEIR patch. + if (entry.owner[entry.name] === entry.wrapper) { + try { + Object.defineProperty(entry.owner, entry.name, { + ...Object.getOwnPropertyDescriptor(entry.owner, entry.name), + value: entry.original, + }); + } catch (error) { + onError(error); + } + } + } + if (timerStarted && clearTimer !== undefined) { + clearTimer(timer); + } + }, + }; +} diff --git a/packages/capture/src/index.ts b/packages/capture/src/index.ts index 6564d899..76a78f22 100644 --- a/packages/capture/src/index.ts +++ b/packages/capture/src/index.ts @@ -2,6 +2,12 @@ // runtime-agnostic CaptureProviders consume those hubs. Platform packages compose this and add only // their runtime-specific sources (XHR, node:http, error/rejection globals). +export { + type AsyncOrigin, + type AsyncOriginRecorder, + type InstallAsyncOriginsOptions, + installAsyncOrigins, +} from './async-origin'; export { ASYNC_ORIGIN_EXTRAS, ASYNC_ORIGIN_TABLE, From 55b22ce73654af5d6073337e940134a521a0b673 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 22:06:45 +0500 Subject: [PATCH 05/32] feat(capture): async-origin enricher and target describers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createAsyncOriginEnricher turns a recorded call site into frames marked origin: 'call' for an error that has none (a FrameEnricher, so it rides buildCrashJson's enrichFrames seam); describeOriginTarget describes the element a call was made on (media, image, font face, audio context) at report time with query/fragment/data URLs scrubbed. Step 5a of docs/design/async-origin-capture.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../capture/src/async-origin-report.test.ts | 143 ++++++++++++++++++ packages/capture/src/async-origin-report.ts | 118 +++++++++++++++ packages/capture/src/index.ts | 1 + 3 files changed, 262 insertions(+) create mode 100644 packages/capture/src/async-origin-report.test.ts create mode 100644 packages/capture/src/async-origin-report.ts diff --git a/packages/capture/src/async-origin-report.test.ts b/packages/capture/src/async-origin-report.test.ts new file mode 100644 index 00000000..8c804179 --- /dev/null +++ b/packages/capture/src/async-origin-report.test.ts @@ -0,0 +1,143 @@ +import type { StackFrame } from '@bugsee/core'; +import { parseV8Stack } from '@bugsee/core'; +import { describe, expect, it } from 'vitest'; +import type { AsyncOrigin, AsyncOriginRecorder } from './async-origin'; +import { createAsyncOriginEnricher, describeOriginTarget } from './async-origin-report'; + +const origin = (over: Partial = {}): AsyncOrigin => ({ + api: 'HTMLMediaElement.play', + target: undefined, + holder: { + stack: + 'Error\n at playClip (https://app.test/player.js:10:5)\n at onClick (https://app.test/ui.js:3:1)', + }, + skip: 0, + ...over, +}); + +const recorderFor = (found: AsyncOrigin | undefined): AsyncOriginRecorder => ({ + originOf: () => found, + originOfPromise: () => undefined, + arm: () => undefined, + installed: [], + stop: () => undefined, +}); + +describe('createAsyncOriginEnricher', () => { + const reason = { name: 'NotSupportedError' }; + + it("turns the recorded call site into frames marked origin: 'call' when the error has none", () => { + const enrich = createAsyncOriginEnricher(recorderFor(origin()), parseV8Stack); + const frames = enrich(reason, []); + expect(frames.map((f) => [f.function, f.file, f.line, f.column, f.origin])).toEqual([ + ['playClip', 'https://app.test/player.js', 10, 5, 'call'], + ['onClick', 'https://app.test/ui.js', 3, 1, 'call'], + ]); + }); + + it("leaves an error's own frames alone (a thrown stack beats a call site)", () => { + const own: StackFrame[] = [{ function: 'thrower', file: 'a.js', line: 1, column: 1 }]; + expect(createAsyncOriginEnricher(recorderFor(origin()), parseV8Stack)(reason, own)).toBe(own); + }); + + it('drops the wrapper frames the engine could not omit (skip)', () => { + const o = origin({ + skip: 1, + holder: { + stack: + 'Error\n at wrapped (sdk.js:1:1)\n at playClip (https://app.test/player.js:10:5)', + }, + }); + const frames = createAsyncOriginEnricher(recorderFor(o), parseV8Stack)(reason, []); + expect(frames.map((f) => f.function)).toEqual(['playClip']); + }); + + it('returns the frames untouched when there is no origin, no stack text, or the parser throws', () => { + expect(createAsyncOriginEnricher(recorderFor(undefined), parseV8Stack)(reason, [])).toEqual([]); + expect( + createAsyncOriginEnricher(recorderFor(origin({ holder: {} })), parseV8Stack)(reason, []), + ).toEqual([]); + const boom = (): StackFrame[] => { + throw new Error('parse'); + }; + expect(createAsyncOriginEnricher(recorderFor(origin()), boom)(reason, [])).toEqual([]); + }); +}); + +describe('describeOriginTarget', () => { + it('describes a media element: tag, id, scrubbed source, network/ready state and its error', () => { + const video = { + tagName: 'VIDEO', + id: 'hero', + currentSrc: 'https://cdn.test/v/clip.mp4?token=SECRET#t=3', + networkState: 3, + readyState: 0, + error: { code: 4, message: 'DEMUXER_ERROR_COULD_NOT_OPEN' }, + }; + const text = describeOriginTarget(origin({ target: video })); + expect(text).toBe( + 'Target: