Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/browser-utils/src/coexistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 10 additions & 1 deletion packages/browser-utils/src/coexistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>`, `bugsee-capture-<hash>` and `bugsee-markers-<hash>`.

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.
1 change: 1 addition & 0 deletions packages/browser/src/launch-options.invalid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const start = async (over: Record<string, unknown>) => {
systemProbe: probe,
systemMetricsSampler: () => [],
captureNetwork: false,
persist: false,
replay: false,
onError,
captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }),
Expand Down
194 changes: 191 additions & 3 deletions packages/browser/src/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ const baseOptions = (over: Partial<BugseeLaunchOptions> = {}): 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,
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -1313,6 +1317,185 @@ describe('launch', () => {
await new Promise((r) => setTimeout(r, 0)); // let async hydration settle
});

describe('persist default', () => {
Comment thread
cursor[bot] marked this conversation as resolved.
const databaseNames = async (idb: IDBFactory): Promise<string[]> =>
(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() }));
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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;
};
Expand Down
54 changes: 49 additions & 5 deletions packages/browser/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`.
{
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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<boolean>(PERSIST_OPTION_KEY, false);
Comment on lines +619 to +623

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Medium — persist-by-default probes IndexedDB presence, but reload survival and prefix GC both require Web Locks.

Problem: When options.persist is absent this becomes indexedDB != null (or an injected factory). That turns on createCoexistence({ persist, captureRecovery: durableCapture }), which writes the rolling window (up to maxDataSize, default 10 MB) under a fresh instanceId every launch. The only code that ever reads or deletes a previous launch's prefix is recoverDeadSiblings → liveness.recoverIfDead. With no navigator.locks (and no options.locks), createWebLockLiveness(undefined) sets recoverIfDead to () => Promise.resolve() (packages/browser-utils/src/web-lock-liveness.ts:49). The scan still discovers sibling ids; it never runs the callback, so nothing is recovered and nothing is swept. cleanOtherGenerations cannot help: self's prefix is empty by construction (launch.ts comment at 650–651).

This was an accepted degrade for explicit persist: true (design BD6). Making it the unattended default is new. The README (packages/browser/README.md:24) still says the recent past survives a reload or a browser restart; without locks it does not — the next launch starts an empty namespace and leaves the old 10 MB in origin quota forever.

Impact: IndexedDB quota is origin-scoped and shared with the host app. Ten reloads is ~100 MB of dead SDK data, after which the page's own IDB writes start failing. The host gets the storage cost of persist and none of the crash-delivery benefit. Default onError is a no-op, so the one-time WEB_LOCKS_UNAVAILABLE_WARNING is swallowed.

Scenario: launch(token) on a non-secure http:// origin (navigator.locks is [SecureContext]), Safari < 15.4, or an embedded WebView without locks. IndexedDB is still an object, so persist turns on. Reload. Previous capture/markers/bundles stay under the dead instanceId. Repeat. The new default-on tests always pass locks: fakeWebLocks(), so this path is not covered.

Fix: Treat locks the same way as IndexedDB in the absent-option probe:

const persist =
  options.persist === undefined
    ? (options.indexedDB !== undefined || indexedDbPresent()) &&
      (options.locks !== undefined || navigatorLocksPresent())
    : resolved.options.get<boolean>(PERSIST_OPTION_KEY, false);

persist: true can still opt into the documented no-locks degrade. Add a default-on test with IndexedDB present and navigator.locks absent that asserts memory mode (getService(BundleStoreToken) throws) rather than injecting fakeWebLocks().

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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 } : {}),
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/user-identifier.launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const start = async (storage: Storage, over: Partial<BugseeLaunchOptions> = {},
systemProbe: probe,
systemMetricsSampler: () => [],
captureNetwork: false,
persist: false,
replay: false,
captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }),
deviceIdEnv: {
Expand Down
Loading
Loading