diff --git a/packages/browser-utils/src/coexistence.test.ts b/packages/browser-utils/src/coexistence.test.ts index b6c456c0..b815040c 100644 --- a/packages/browser-utils/src/coexistence.test.ts +++ b/packages/browser-utils/src/coexistence.test.ts @@ -406,6 +406,20 @@ describe('createCoexistence — bundle queue', () => { expect(pipeline.enqueue).not.toHaveBeenCalled(); // no ids discovered → nothing recovered }); + it('a throwing navigator.locks getter (sandboxed frame) degrades like absent locks instead of throwing', () => { + vi.stubGlobal('navigator', { + get locks(): never { + throw new Error('denied'); + }, + }); + const onError = vi.fn(); + expect(() => + createCoexistence({ appToken: TOK, persist: true, indexedDB: new IDBFactory(), onError }), + ).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); // the one-time unavailable warning + expect((onError.mock.calls[0]?.[0] as Error).message).toContain('navigator.locks unavailable'); + }); + it('degrades when Web Locks is unavailable: still persists locally, recovers no sibling', async () => { vi.stubGlobal('navigator', undefined); // no navigator.locks const idb = new IDBFactory(); diff --git a/packages/browser-utils/src/coexistence.ts b/packages/browser-utils/src/coexistence.ts index 767d7d9e..30e39361 100644 --- a/packages/browser-utils/src/coexistence.ts +++ b/packages/browser-utils/src/coexistence.ts @@ -106,6 +106,15 @@ export interface Coexistence { release(): void; } +/** The page's `navigator.locks`, or undefined when absent; a throwing getter (sandboxed frames) counts as absent. */ +function pageLocks(): LockManagerLike | undefined { + try { + return (globalThis as { navigator?: { locks?: LockManagerLike } }).navigator?.locks; + } catch { + return undefined; + } +} + /** Build the multi-instance-safe coexistence root for a launch (bundle queue + optional capture/markers). */ export function createCoexistence(options: CoexistenceOptions): Coexistence { const { appToken, persist, captureRecovery, bundleOverride, onError } = options; @@ -153,7 +162,7 @@ export function createCoexistence(options: CoexistenceOptions): Coexistence { // Hold the instance lock for this realm's lifetime iff there is per-instance durable data a sibling could // recover. The same lock covers bundles + chunks + markers (all keyed by this instanceId). const liveness = createWebLockLiveness( - options.locks ?? (globalThis as { navigator?: { locks?: LockManagerLike } }).navigator?.locks, + options.locks ?? pageLocks(), onError !== undefined ? (message) => onError(new Error(message)) : undefined, ); const coexisting = bundleShared !== undefined || captureShared !== undefined; diff --git a/packages/browser/README.md b/packages/browser/README.md index f47d2623..2c3c3f06 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -19,6 +19,12 @@ What it contains: - Repository: https://github.com/bugsee/bugsee-javascript/tree/main/packages/browser - Bugsee: https://bugsee.com +## Persistence + +By default the rolling capture window and the queue of reports waiting to upload are kept in IndexedDB, so, where the browser supports it, the recent past survives a page reload or a browser restart, and a report interrupted mid-upload is sent on the next visit. Data is stored in per-app databases named `bugsee-`, `bugsee-capture-` and `bugsee-markers-`. + +Pass `persist: false` to keep everything in memory instead. The default applies only when the page exposes both an IndexedDB object and Web Locks (`navigator.locks`, which needs a secure `https://` context; Safari before 15.4 and some embedded WebViews lack it): without locks a previous visit's data can never be recovered or cleaned up, so the SDK stays in memory instead. Pass `persist: true` to store anyway. If the browser refuses to store data (over quota, some private modes), writes fail best-effort and are reported to `onError`; the SDK does not switch to memory at that point. Any `persist` value other than `true` or `false` counts as off and is reported. + ## License MIT. See the LICENSE file. diff --git a/packages/browser/src/launch-options.invalid.test.ts b/packages/browser/src/launch-options.invalid.test.ts index 29d132c1..14b9c2fc 100644 --- a/packages/browser/src/launch-options.invalid.test.ts +++ b/packages/browser/src/launch-options.invalid.test.ts @@ -43,6 +43,7 @@ const start = async (over: Record) => { systemProbe: probe, systemMetricsSampler: () => [], captureNetwork: false, + persist: false, replay: false, onError, captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 18d69680..d40d191f 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -179,6 +179,7 @@ const baseOptions = (over: Partial = {}): BugseeLaunchOptio systemProbe: probe, systemMetricsSampler: () => [{ name: 'ram_js_heap_used', value: 42 }], captureNetwork: false, + persist: false, // tests opt in; the shipped default is on wherever IndexedDB exists deviceIdEnv: { localStorage: testDeviceIdLocalStorage(), randomUUID: () => TEST_DEVICE_ID, @@ -375,7 +376,10 @@ describe('launch', () => { }); it('does not register bundleStore in in-memory mode', async () => { - const client = await launchTracked('tok', baseOptions({ captureStore: memStore() })); + const client = await launchTracked( + 'tok', + baseOptions({ persist: false, captureStore: memStore() }), + ); expect(() => client.getService(BundleStoreToken)).toThrow(); }); @@ -1313,6 +1317,185 @@ describe('launch', () => { await new Promise((r) => setTimeout(r, 0)); // let async hydration settle }); + describe('persist default', () => { + const databaseNames = async (idb: IDBFactory): Promise => + (await idb.databases()).map((d) => d.name ?? ''); + + it('persists by default when IndexedDB is available: the capture database is created', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + await launchTracked('tok', baseOptions({ persist: undefined, locks: fakeWebLocks() })); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + it('treats an injected indexedDB as availability too', async () => { + const idb = new IDBFactory(); + await launchTracked( + 'tok', + baseOptions({ persist: undefined, indexedDB: idb, locks: fakeWebLocks() }), + ); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + // Reload survival and prefix GC both need Web Locks (recoverIfDead is a no-op without them), so the + // unattended default must not strand ~10 MB per launch in the origin's quota. + it('stays in memory by default when IndexedDB exists but Web Locks do not', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + vi.stubGlobal('navigator', {}); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(() => client.getService(BundleStoreToken)).toThrow(); + expect(() => client.getService(ReportMarkerStoreToken)).toThrow(); + expect(await databaseNames(idb)).not.toContain(captureDatabaseName('tok')); + }); + + it('persists by default when IndexedDB and the page navigator.locks are both present', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + vi.stubGlobal('navigator', { locks: fakeWebLocks() }); + await launchTracked('tok', baseOptions({ persist: undefined })); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + it('treats a throwing navigator getter as no Web Locks instead of failing launch', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { + get locks(): never { + throw new Error('denied'); + }, + }); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.isLaunched()).toBe(true); + expect(() => client.getService(BundleStoreToken)).toThrow(); + }); + + it('explicit persist:true still opts into the documented no-locks degrade', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + vi.stubGlobal('navigator', {}); + await launchTracked('tok', baseOptions({ persist: true })); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + it('an injected locks manager counts as Web Locks availability for the default', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('navigator', {}); + await launchTracked( + 'tok', + baseOptions({ persist: undefined, indexedDB: idb, locks: fakeWebLocks() }), + ); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + // The reported effective options must say what is actually in effect, not the static `false` default. + describe('the effective persist option follows the capability-based default', () => { + const PERSIST = 'com.bugsee.option.browser.persist'; + it('reports true when the default turns persistence on', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { locks: fakeWebLocks() }); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.getLaunchOptions()[PERSIST]).toBe(true); + }); + + it('reports false when Web Locks are missing and the default keeps memory', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', {}); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.getLaunchOptions()[PERSIST]).toBe(false); + }); + + it('reports false when IndexedDB is missing and the default keeps memory', async () => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('navigator', { locks: fakeWebLocks() }); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.getLaunchOptions()[PERSIST]).toBe(false); + }); + + it('leaves an explicit true alone, even without Web Locks', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', {}); + const client = await launchTracked('tok', baseOptions({ persist: true })); + expect(client.getLaunchOptions()[PERSIST]).toBe(true); + }); + + it('leaves an explicit false alone, even where the default would persist', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { locks: fakeWebLocks() }); + const client = await launchTracked('tok', baseOptions({ persist: false })); + expect(client.getLaunchOptions()[PERSIST]).toBe(false); + }); + }); + + it('stays in memory when persist is false, even with IndexedDB available', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + const client = await launchTracked('tok', baseOptions({ persist: false })); + expect(() => client.getService(BundleStoreToken)).toThrow(); + expect(() => client.getService(ReportMarkerStoreToken)).toThrow(); + }); + + it('stays in memory when there is no IndexedDB at all', async () => { + vi.stubGlobal('indexedDB', undefined); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(() => client.getService(BundleStoreToken)).toThrow(); + expect(() => client.getService(ReportMarkerStoreToken)).toThrow(); + }); + + it('treats a throwing indexedDB getter as absent instead of failing launch', async () => { + vi.stubGlobal('indexedDB', undefined); + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + get: () => { + throw new Error('denied'); + }, + }); + try { + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.isLaunched()).toBe(true); + expect(() => client.getService(BundleStoreToken)).toThrow(); + } finally { + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + writable: true, + value: undefined, + }); + } + }); + + // Persisting writes captured data at rest, so a non-boolean never turns it on, and it is reported. + it.each([ + 0, + '', + 'false', + 'true', + 1, + null, + ])('persist=%j (invalid) is OFF even with IndexedDB available, and reports', async (bad) => { + vi.stubGlobal('indexedDB', new IDBFactory()); + const onError = vi.fn(); + const client = await launchTracked( + 'tok', + baseOptions({ persist: bad as never, onError, locks: fakeWebLocks() }), + ); + expect(() => client.getService(BundleStoreToken)).toThrow(); + expect( + onError.mock.calls.filter((c) => + String((c[0] as Error).message).startsWith('persist must be a boolean'), + ), + ).toHaveLength(1); + }); + }); + it('threads onError into the persistent capture store', async () => { vi.stubGlobal('indexedDB', new IDBFactory()); const client = await launchTracked('tok', baseOptions({ persist: true, onError: vi.fn() })); @@ -1923,7 +2106,7 @@ describe('launch — capture recovery (multi-instance)', () => { }); it('builds no marker store without persist', async () => { - const client = await launchTracked('tok', baseOptions({})); // in-memory store + const client = await launchTracked('tok', baseOptions({ persist: false })); // in-memory store expect(() => client.getService(ReportMarkerStoreToken)).toThrow(); }); @@ -2486,7 +2669,12 @@ describe('launch — RelaunchedAfterCrash needs evidence', () => { lifecycleEventsListener: (type) => seen.push(type), }), ); - // let the dead-sibling recovery finish (its marker is swept when it is done) + // let the dead-sibling recovery finish (its marker is swept when it is done). The event can lag on a slow + // machine, so wait for it for a bounded time; the cases that expect silence simply run out the clock. + const deadline = Date.now() + 1500; + while (!seen.includes(LifecycleEvents.RelaunchedAfterCrash) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 25)); + } await new Promise((r) => setTimeout(r, 50)); return seen; }; diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 17dcd9aa..c0f236f1 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -123,6 +123,30 @@ const DEFAULT_MAX_DATA_SIZE_MB = 10; // (10 MB default vs node's 50 — its canonical identifier lives in @bugsee/protocol for wire parity). /** Canonical identifier of the (browser-local) `replay` switch: no cross-SDK identity, so it lives here. */ const REPLAY_OPTION_KEY = 'com.bugsee.option.replay.enabled'; +/** Canonical identifier of the (browser-local) `persist` switch: no cross-SDK identity, so it lives here. */ +const PERSIST_OPTION_KEY = 'com.bugsee.option.browser.persist'; + +/** Whether the page exposes an IndexedDB object; a throwing getter (sandboxed frames) counts as absent. */ +const indexedDbPresent = (): boolean => { + try { + return (globalThis as { indexedDB?: unknown }).indexedDB != null; + } catch { + return false; + } +}; + +/** + * Whether the page exposes Web Locks (`navigator.locks`, a secure-context API); a throwing getter counts as + * absent. Without it a previous launch's durable data is never recovered or swept (liveness cannot tell a + * dead sibling from a live one), so the unattended persist default requires it. + */ +const navigatorLocksPresent = (): boolean => { + try { + return (globalThis as { navigator?: { locks?: unknown } }).navigator?.locks != null; + } catch { + return false; + } +}; const BROWSER_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ ...COMMON_OPTION_DEFINITIONS, @@ -158,6 +182,9 @@ const BROWSER_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ default: true, ...FAIL_CLOSED_BOOLEAN_OR_BAG_OPTION, }, + // Persistence collects data at rest, so only boolean `true`/`false` count: anything else is OFF and reported + // (the launch substitutes the IndexedDB default only when the option is absent). + { friendly: 'persist', key: PERSIST_OPTION_KEY, default: false, ...FAIL_CLOSED_BOOLEAN_OPTION }, // The report dialog (`showReportDialog`) is browser/DOM-only. Defaults are the mobile SDKs': nothing // required, labels and the severity picker hidden, new bugs start at `high`. { @@ -360,9 +387,12 @@ export interface BugseeLaunchOptions { /** Max captured data kept in the rolling buffer, in megabytes. Default 10. */ maxDataSize?: number; /** - * Persist the bundle queue to IndexedDB so a crash report survives a reload/kill mid-upload and is - * re-uploaded on the next launch (guaranteed crash delivery). Off by default (in-memory only). An - * explicit `bundleStore` overrides this. + * Keep the rolling capture window and the report queue in IndexedDB, so the recent past survives a + * reload or a browser restart and a report interrupted mid-upload is re-uploaded on the next launch. + * On by default wherever the page has BOTH IndexedDB and Web Locks (`navigator.locks`, or an injected + * `locks`): without locks a previous launch's data is never recovered or cleaned up, so the default stays + * in memory and nothing survives a reload. `true` persists anyway (best effort, no cross-tab recovery); + * `false` keeps everything in memory. An explicit `bundleStore` or `captureStore` overrides the matching part. */ persist?: boolean; /** @@ -580,11 +610,25 @@ async function buildLaunch( // for liveness, and recovers only DEAD siblings (never one a live tab holds). `durableCapture` builds the // per-instance capture/marker VIEWS the launch wraps; `recoverEnabled` additionally records + recovers // markers. A fresh instanceId per launch ⇒ self's namespaces are empty, so a prior crash is a dead sibling. - const durableCapture = options.persist === true && options.captureStore === undefined; + // Durable by default wherever an IndexedDB object AND Web Locks exist, so the rolling window survives a + // reload or a browser restart. Locks are required because recovery and the sweep of a previous launch's + // prefix both run through the liveness lock: without them the old data is never read or deleted and each + // reload would strand up to `maxDataSize` in the origin's shared quota. An explicit `persist: true` still + // opts into that no-locks degrade. `persist: false` opts out; a non-boolean value is OFF and reported. + // Presence is all that is probed: if the store later cannot commit, writes fail into `onError`. + const persist = + options.persist === undefined + ? (options.indexedDB !== undefined || indexedDbPresent()) && + (options.locks !== undefined || navigatorLocksPresent()) + : resolved.options.get(PERSIST_OPTION_KEY, false); + // The canonical record is what the client and `sdk.options` report as in effect: say what the capability + // check decided instead of the static `false` default (for an explicit or invalid value this is a no-op). + resolved.canonical[PERSIST_OPTION_KEY] = persist; + const durableCapture = persist && options.captureStore === undefined; const recoverEnabled = (options.recover ?? true) && durableCapture; const coexistence = createCoexistence({ appToken, - persist: options.persist === true, + persist, captureRecovery: durableCapture, ...(options.bundleStore !== undefined ? { bundleOverride: options.bundleStore } : {}), ...(options.onError !== undefined ? { onError: options.onError } : {}), diff --git a/packages/browser/src/user-identifier.launch.test.ts b/packages/browser/src/user-identifier.launch.test.ts index 940b73c6..0b8f3cb1 100644 --- a/packages/browser/src/user-identifier.launch.test.ts +++ b/packages/browser/src/user-identifier.launch.test.ts @@ -79,6 +79,7 @@ const start = async (storage: Storage, over: Partial = {}, systemProbe: probe, systemMetricsSampler: () => [], captureNetwork: false, + persist: false, replay: false, captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), deviceIdEnv: { diff --git a/packages/bugsee/src/option-directions.test.ts b/packages/bugsee/src/option-directions.test.ts index 0a3c6911..23c861ae 100644 --- a/packages/bugsee/src/option-directions.test.ts +++ b/packages/bugsee/src/option-directions.test.ts @@ -96,7 +96,6 @@ interface RawEntry { check?: [pkg: string, pattern: RegExp]; } const RAW_STRICT: Record = { - 'browser:persist': { safe: 'off', why: 'read `=== true`: OFF unless boolean true' }, 'browser:recover': { safe: 'n/a', why: 'durability of already-persisted data' }, 'browser:holdBlackout': { safe: 'on', @@ -107,7 +106,6 @@ const RAW_STRICT: Record = { safe: 'on', why: 'INTERNAL hold, forwarded to the browser read above', }, - 'electron:persist': { safe: 'off', why: 'browser option, read `=== true`' }, 'electron:recover': { safe: 'n/a', why: 'durability behaviour' }, 'node:recover': { safe: 'n/a', why: 'durability of already-persisted data' }, 'node:exitOnUncaught': { safe: 'n/a', why: 'process policy' }, @@ -124,7 +122,6 @@ const RAW_STRICT: Record = { check: ['cloudflare', /options\.partitionCaptureByTenant === undefined\s*\?\s*true/], }, 'webview:reportTrigger': { safe: 'n/a', why: 'enables native report gestures (a UI trigger)' }, - 'bugsee:persist': { safe: 'off', why: 'browser option, read `=== true`' }, 'bugsee:recover': { safe: 'n/a', why: 'durability behaviour' }, 'bugsee:holdBlackout': { safe: 'on', why: 'INTERNAL hold, forwarded to the browser read' }, 'bugsee:exitOnUncaught': { safe: 'n/a', why: 'process policy' }, @@ -135,7 +132,6 @@ const RAW_STRICT: Record = { 'nuxt:exitOnUncaught': { safe: 'n/a', why: 'process policy' }, 'nuxt:recover': { safe: 'n/a', why: 'durability behaviour' }, 'nuxt:otelConsume': { safe: 'off', why: 'read `=== true`: OFF unless boolean true' }, - 'nuxt:persist': { safe: 'off', why: 'browser option, read `=== true`' }, 'nuxt:holdBlackout': { safe: 'on', why: 'INTERNAL hold, forwarded to the browser read' }, 'nuxt:partitionCaptureByTenant': { safe: 'on',