diff --git a/docs/design/mobile-api-alignment.md b/docs/design/mobile-api-alignment.md index 9bf876a1..2e4de9e0 100644 --- a/docs/design/mobile-api-alignment.md +++ b/docs/design/mobile-api-alignment.md @@ -18,7 +18,7 @@ Each task is test-first with the mutator loop, keeps the coverage gate, and land | T5 | Launch state: `getStatus()` with the Stopped/Launching/Launched/Stopping enum, `relaunch(options?)`, `getLaunchOptions()`, `getHostLaunchOptions()`; verify launch → stop → launch works | `getStatus`, `relaunch`, `getLaunchOptions` | | | T6 | Missing options: `captureLogsLevel`, `captureBreadcrumbs`, `detectHttpErrors`, `captureNetworkDefaultSanitizer`, `reportDefaultCrashPriority`, `reportDefaultErrorPriority`; `getDefaultNetworkSanitizer()` so a custom filter can reuse the default redaction | `OptionsDescriptors` keys under `com.bugsee.option.*` | canonical keys already exist for some | | T7 | `notify(title, body?, severity?, fields?, urgent?)` | `Bugsee.notify` | needs a backend check first: which endpoint carries it, and does the collector accept it from a browser | -| T8 | `logException` options: `domain`; check what `mergingRules` does on iOS | Android `ExceptionOptions.Domain`, iOS `BugseeExceptionLoggingOptions` | | +| T8 | `logException` options: `domain`; check what `mergingRules` does on iOS | Android `ExceptionOptions.Domain`, iOS `BugseeExceptionLoggingOptions` | Done: `domain` + `skipFrames` (see T8 findings) | | T9 | Browser: persist the user identifier across page loads, as iOS does | iOS `setUserIdentifier` | storage-failure tolerant | ## Options added by T6 @@ -43,6 +43,31 @@ Invalid values warn through `onError` and fall back to the default. `{ sanitize, sanitizeUrl, sanitizeErrorMessage }`, the same object the built-in sanitizer uses. Call it from inside a `setNetworkEventFilter` filter to keep the default redaction. +## T8 findings + +Investigated 2026-10 against Android `library/.../reporting/exceptions/`, iOS `BugseeExceptionLoggingOptions.h`, +`BGSCrashReport.m`, and worker `crash/{android,apple,javascript}.py`, `crash/managed/common.py`. + +- **`domain`** (Android `ExceptionOptions.Domain = "domain"`, iOS `exceptionDomain`): a short issue-organising + category. Android writes it as `exception.domain` in crash.json (`ExceptionSerializer`); iOS as the error `domain`. + The worker folds it into the grouping signature (`android.py:175-179`, `apple.py:370`: "reports with the same + domain and stack trace group together") and prefixes the issue summary with `[domain]` (`android.py:251-268`, capped + at 150 chars). **Gap:** JavaScript crashes go through `crash/managed/common.py:get_crash_signatures`, which does not + read `domain`, and `javascript.py:530` builds its own summary. The SDK now sends `exception.domain` (the Android wire + shape); it has no grouping or summary effect until the worker's managed path reads it. Not verified against a + running backend. +- **`skipFrames`** (Android `ExceptionOptions.SkipFrames`, iOS `mergingRules[BugseeMergingRulesSkipFramesKey]`): + skips N top frames when computing the crash location/signature; frames stay in the report. Android applies it + client-side in `ExceptionSignature`; the worker also reads `exceptionLoggingOptions.mergingRules.skipFrames` + (`android.py:111,248`, `apple.py:301,439`), but not on the managed/JS path. JS equivalent: the top N frames of the + outermost exception get `hidden: true`, which `managed/common.py:_get_crash_location` already skips. Same + semantics, no worker change, trace still shows them. Implemented. +- **`mergingRules`**: a dictionary whose only key is `skipFrames`. Not added as its own option; `skipFrames` is the + flat equivalent (Android's map already uses the flat key). +- **`includeVideo`**: forces a video-less report. Not applicable: JS has no video; replay is governed by the + `replay` launch option. Kept difference. +- Invalid values (empty/non-string `domain`, non-integer or negative `skipFrames`) are ignored and routed to `onError`. + ## Kept differences (documented, not changed) - `captureBreadcrumbs` defaults to `true`, not mobile's `false`: UI-interaction breadcrumbs (click / change / submit / diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 8aa4623d..d7c88d1c 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -620,6 +620,76 @@ describe('createClient — crash.json (SC3)', () => { }); }); + describe('logException domain and skipFrames', () => { + const stacked = () => { + const err = new Error('boom'); + err.stack = + 'Error: boom\n at wrapper (a.js:1:1)\n at inner (b.js:2:2)\n at app (c.js:3:3)'; + return err; + }; + const run = async (opts: Record) => { + const { uploadPipeline, enqueue } = fakeUpload(); + const onError = vi.fn(); + const client = createClient({ uploadPipeline, appToken: 'tok', getEnvironment, onError }); + await client.logException(stacked(), opts); + return { crash: crashOf(enqueue.mock.calls[0]?.[0] as Bundle), onError }; + }; + + it('writes exception.domain, the field the Android worker folds into the signature', async () => { + const { crash, onError } = await run({ domain: 'checkout ' }); + expect(crash.exception.domain).toBe('checkout '); // verbatim, not trimmed + expect(onError).not.toHaveBeenCalled(); + }); + + it('omits domain when not given', async () => { + const { crash } = await run({}); + expect('domain' in crash.exception).toBe(false); + expect(crash.exception.frames.some((f: { hidden?: boolean }) => f.hidden)).toBe(false); + }); + + it('ignores a non-string or empty domain and warns through onError', async () => { + for (const bad of [42, '', {}]) { + const { crash, onError } = await run({ domain: bad }); + expect('domain' in crash.exception).toBe(false); + expect(onError).toHaveBeenCalledTimes(1); + expect(String(onError.mock.calls[0]?.[0])).toContain('domain'); + } + }); + + it('marks the top skipFrames frames hidden, so grouping skips them but they stay visible', async () => { + const { crash } = await run({ skipFrames: 2 }); + const f = crash.exception.frames as { trace: string; hidden?: boolean }[]; + expect(f).toHaveLength(3); + expect(f.map((x) => x.hidden)).toEqual([true, true, undefined]); + }); + + it('skipFrames beyond the frame count hides all of them; 0 hides none', async () => { + const all = await run({ skipFrames: 99 }); + expect(all.crash.exception.frames.every((x: { hidden?: boolean }) => x.hidden)).toBe(true); + const none = await run({ skipFrames: 0 }); + expect(none.crash.exception.frames.some((x: { hidden?: boolean }) => x.hidden)).toBe(false); + expect(none.onError).not.toHaveBeenCalled(); + }); + + it('ignores an invalid skipFrames (negative, fractional, non-number) and warns', async () => { + for (const bad of [-1, 1.5, '2', Number.NaN]) { + const { crash, onError } = await run({ skipFrames: bad }); + expect(crash.exception.frames.some((x: { hidden?: boolean }) => x.hidden)).toBe(false); + expect(onError).toHaveBeenCalledTimes(1); + expect(String(onError.mock.calls[0]?.[0])).toContain('skipFrames'); + } + }); + + it('applies to a non-Error throwable too', async () => { + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ uploadPipeline, appToken: 'tok', getEnvironment }); + await client.logException('str', { domain: 'd', skipFrames: 1 }); + const crash = crashOf(enqueue.mock.calls[0]?.[0] as Bundle); + expect(crash.exception.domain).toBe('d'); + expect(crash.exception.frames[0].hidden).toBe(true); + }); + }); + it('still writes a crash.json for a non-Error logException, with a synthetic exception', async () => { // A bundle with no crash.json produced an issue the backend could only answer with "Crash data // for the issue was not found" — present, counted, unusable. Non-Error throwables are ordinary diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index d2552e0c..7312fd8d 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -37,7 +37,7 @@ import { type Client, type OptionsContainer, } from './contracts'; -import { buildCrashJson, type FrameEnricher } from './crash'; +import { applyExceptionOptions, buildCrashJson, type FrameEnricher } from './crash'; import { checkOrSetAlreadyCaught } from './dedup'; import { createDetectionCoordinator } from './detection-coordinator'; import type { IdentifiedBundle } from './durable-upload-pipeline'; @@ -163,6 +163,19 @@ export interface LogExceptionOptions { severity?: SeverityName; /** Extra labels for the issue. */ labels?: string[]; + /** + * Exception domain (Android `ExceptionOptions.Domain`, iOS `exceptionDomain`): a short category such as + * `checkout` that organises issues. Sent as `crash.json` `exception.domain`. A non-string or empty + * value is ignored and reported through `onError`. + */ + domain?: string; + /** + * Number of top stack frames to leave out of issue grouping (Android `ExceptionOptions.SkipFrames`, iOS + * `mergingRules[skipFrames]`). Use it when a wrapper or adapter logs on the app's behalf, so every error + * it reports does not group at the wrapper. The frames stay visible in the trace. Must be a non-negative + * integer; anything else is ignored and reported through `onError`. + */ + skipFrames?: number; } /** @@ -1121,6 +1134,30 @@ export function createClient(options: CreateClientOptions = {}): InternalBugseeC ...(options.enrichFrames !== undefined ? { enrichFrames: options.enrichFrames } : {}), ...(syntheticFrames !== undefined ? { syntheticFrames } : {}), }); + const crashOptions: { domain?: string; skipFrames?: number } = {}; + const domain = exceptionOptions?.domain; + if (domain !== undefined) { + if (typeof domain === 'string' && domain !== '') { + crashOptions.domain = domain; + } else { + onError( + new Error('logException: ignoring invalid `domain` (expected a non-empty string)'), + ); + } + } + const skipFrames = exceptionOptions?.skipFrames; + if (skipFrames !== undefined) { + if (Number.isInteger(skipFrames) && skipFrames >= 0) { + crashOptions.skipFrames = skipFrames; + } else { + onError( + new Error( + 'logException: ignoring invalid `skipFrames` (expected a non-negative integer)', + ), + ); + } + } + applyExceptionOptions(crash, crashOptions); const request = createReportingRequest({ source: { type: 'error', mechanism: exceptionOptions?.mechanism ?? 'programmatic' }, summary: message, diff --git a/packages/core/src/crash.ts b/packages/core/src/crash.ts index 7b86dae5..5f49b3fd 100644 --- a/packages/core/src/crash.ts +++ b/packages/core/src/crash.ts @@ -15,6 +15,11 @@ export interface CrashFrame { user: boolean; data?: { source?: string; member?: string; line?: number; column?: number }; debug_id?: string; + /** + * Excluded from the grouping location (the worker skips `hidden` frames when it picks the crash + * location) while still shown in the trace. Set by `logException({ skipFrames })`. + */ + hidden?: boolean; /** * Local variables in scope at this frame — `{ name: stringified-value }`, already scrubbed and capped * by the capturing tier. Absent unless the platform captured them. @@ -51,6 +56,8 @@ export interface CrashProvenance { export interface CrashException { name: string; reason?: string; + /** Application-supplied exception domain (`logException({ domain })`; Android `ExceptionOptions.Domain`). */ + domain?: string; frames: CrashFrame[]; cause?: CrashException; } @@ -293,6 +300,27 @@ function renderThrowable(value: unknown): string { return String(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` (Android/iOS `skipFrames`: the + * frames stay in the trace but stop deciding the grouping location, so a wrapper or adapter does not + * collapse every error it reports into one issue). Mutates and returns `crash`. + */ +export function applyExceptionOptions( + crash: CrashJson, + options: { domain?: string; skipFrames?: number }, +): CrashJson { + if (options.domain !== undefined) { + crash.exception.domain = options.domain; + } + if (options.skipFrames !== undefined) { + for (const frame of crash.exception.frames.slice(0, options.skipFrames)) { + frame.hidden = true; + } + } + return crash; +} + export function buildCrashJson(error: unknown, options: BuildCrashOptions = {}): CrashJson { if (!(error instanceof Error)) { return {