Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Read this first; then `docs/design/sdk-design.md` (Draft v3) for the full archit
- `createUiBreadcrumbSource` / `createUiBreadcrumbProvider` (**UI breadcrumbs**, 2026-08-31): the state-change half of the DOM interaction split — capture-phase/passive listeners for `change`/`submit`/`focusin` → `client.addBreadcrumb` (so the app's `breadcrumbFilter` runs) in Android `BreadcrumbInputGesture`'s shape: `{type:'user', category:'ui.change'|'ui.submit'|'ui.focus', level:'info', data:{'view.id','view.class','view.tag'}, timestamp: the DOM event's own moment (`performance.timeOrigin + Event.timeStamp`, clamped to now)}`. `type:'user'` is Android's literal for a user-ORIGINATED breadcrumb — **not** the `*.user` stream (these are `breadcrumbs` entries). Why not `input`: `InputEvent.type` is Android's `InputEventStage` (`unknown|begin|move|end|scroll|keydown|keyup`), which a DOM-only value cannot join, and Android already splits its input dispatcher (→ `input.json`) from its gesture dispatcher (→ breadcrumbs). Reuses `describeTarget`; a sensitive/app-masked target **drops the breadcrumb outright** (the focus/change rhythm on a masked field is itself a side channel). Gated by `captureInteractions` (the option means "do not watch what I click and type", whatever stream it lands on). Wired into `@bugsee/browser` **and** `@bugsee/webview` (which also now declares `breadcrumbs` in `hello.caps`).
- `createDomSnapshot` / `createViewtreeSnapshotSource` (view hierarchy: an at-report DOM-tree snapshot → `viewtree`, via the core `reportSnapshots` pull-seam) — reuses `describeTarget` per node + rounded `getBoundingClientRect`; masked subtrees collapse to `{tag, masked, rect}`; bounded (maxNodes/maxDepth) + per-node throw-isolated. Gated by `captureViewHierarchy`. See §7.
- **`client.showReportDialog(options?)`** (2026-10-11 → `docs/design/report-dialog.md`): the web counterpart of the mobile `showReportDialog()` — a built-in bug-report form shown as a modal over a shaded page, sent as a `code_dialog` report through the new core **`client.upload(options)`** (Android `Bugsee.upload`: not deduped or rate-limited, still runs the report handler). Same fields/order/strings/colours/validation as mobile (summary, description, severity, labels, email, `v<sdk>` footer); the reporting-UI launch options are Android's canonical `com.bugsee.option.reporting.ui.*` keys with the same defaults (`reportSummaryRequired`, `reportDescriptionRequired`, `reportEmailRequired`, `reportLabelsEnabled`, `reportLabelsRequired`, `reportPrioritySelectorEnabled`, `reportDefaultBugPriority`). Built with DOM APIs only inside ONE shadow root on ONE host element (page CSS cannot reach it; user text is never parsed as HTML) and shown with `<dialog>.showModal()` so it sits in the TOP LAYER — above any z-index and above a modal the page already has open (verified in real Chromium); a fixed max-z-index overlay is the fallback. Esc / a click on the shading is a cancel with a discard prompt for an edited form; focus and scroll are restored on close; a changed email is remembered as the user identifier. **No screenshot in v1** (the report already carries the DOM snapshot). Resolves `null` on cancel / already open / not running / no DOM. Tested under jsdom (`report-dialog.test.ts`, mutation-checked) and end to end through a real `launch()` (`report-dialog.launch.test.ts`).
- **User identifier persistence (T9, browser tier only):** iOS persists `setUserIdentifier` across launches (Android does not); the browser now does too. `setUserIdentifier` writes to `localStorage` (`bugsee.user_identifier.<appToken>`), `clearUserIdentifier` removes it, and launch restores it SYNCHRONOUSLY right after the client is created and before any provider, service manifest or report, so the first report already carries it and anything the app sets afterwards simply overwrites it (`user-identifier-store.ts`, wired in `launch.ts`; the report dialog uses the same persisting setters). Deliberately NOT the multi-store device-id machinery: an email is PII, so exactly one first-party store, never a cookie (sent to the server on every request), and "clear site data" forgets it. All storage access is try/catch: failures go to `onError` and the in-memory Environment stays authoritative. Cross-tab is last-writer-wins, picked up at next launch (no live `storage`-event sync). Opt out with `persistUserIdentifier: false` (canonical `com.bugsee.option.user.persist-identifier`, default true as on iOS), which also ignores an already-stored value. Node/Bun/Deno/server tiers keep the identifier in memory by design (a server process's identifier tied to disk would be wrong). Tests: `user-identifier-store.test.ts`, `user-identifier.launch.test.ts`, real-Chromium `instrumentation-tests/test/user-identifier.e2e.ts` (set -> reload, clear -> reload, opt-out).
- **Mobile-parity options (T6, `docs/design/mobile-api-alignment.md`):** `captureLogsLevel`, `captureBreadcrumbs` (JS default stays `true`), `detectHttpErrors` (`createHttpErrorDetectionProvider` in `@bugsee/capture`, mechanism `http-error`, own rate budget), `captureNetworkDefaultSanitizer`, `reportDefaultCrashPriority` / `reportDefaultErrorPriority` (applied in the client before the report handler, only to severities that were derived), and `getDefaultNetworkSanitizer()` for composing with a custom network filter.
- **`launch(appToken, options)`** — the browser composition root (fetch/DOM analog of node's): fetch transport (internal-tagged), api/uploader/upload pipeline, browser env, in-memory store (or IndexedDB-backed when `persist:true`), gated capture providers (console→log; network umbrella, NO `node:http`; system traces; system events; user-interaction input) + detection providers, `client.launch()`. **No `process.exit` path** (the browser flushes via the pipeline/`pagehide`; `stop()` only clears the carrier). `persist:true` builds an IndexedDB durable bundle queue (crash recovery across reload — `recover()` deferred to the store's `whenReady`) + the durable IndexedDB chunk capture store (`createIdbChunkCaptureStore` in its own `bugsee-capture` db). `maxDataSize` defaults to 10 MB. **Session replay is ON by default** (2026-08-27) — parity with the iOS/Android SDKs, which record by default; `replay: false` is the opt-out and is the ONLY value on which `import('@bugsee/replay')` is never evaluated, so an errors-only integration still pays nothing for rrweb (the lazy chunk + the opt-out carry the bundle-size budget, not the default). Masking stays fail-closed on the default path (mask all text/inputs, block all media); `replay.canvas` stays opt-in. Returns the started `BugseeClient`.

Expand Down
6 changes: 4 additions & 2 deletions docs/design/report-dialog.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ Canonical `com.bugsee.option.reporting.*` keys, defaults equal to mobile (all of
presses Enter after the summary does not send a half-written report. An IME's Enter is left alone.
- **No email format check.** The "email" is the app's user identifier, which is often not an address;
neither mobile SDK validates it. Erasing it clears the identifier (iOS `clearEmail`), a changed value is
remembered for the session. (The mobile SDKs also persist it across launches; the JS identifier is held
in memory only today, so it is not persisted across page loads.)
remembered as the user identifier. The identifier is persisted across page loads like the mobile SDKs
(browser tier, `localStorage`, per app token; see PROGRESS.md "User identifier persistence"), so the
next page load pre-fills the field. The dialog goes through the same persisting setters. It is PII at
rest: `persistUserIdentifier: false` keeps it in memory only.
- **A browser-initiated close is a cancel.** Chrome makes a second Escape non-cancellable; the dialog's
`close` listener tears everything down (host, scroll lock, launcher state) instead of leaving the page
locked.
Expand Down
41 changes: 39 additions & 2 deletions packages/browser/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import { parseStack } from './stack';
import { createBrowserSystemEventsSource } from './system-events';
import { createBrowserSystemTracesSampler } from './system-metrics';
import { createUiBreadcrumbProvider, createUiBreadcrumbSource } from './ui-breadcrumb-source';
import { createUserIdentifierStore } from './user-identifier-store';
import { createBrowserViewportSource } from './viewport-source';
import { createViewtreeSnapshotSource } from './viewtree';

Expand Down Expand Up @@ -142,6 +143,9 @@ const BROWSER_OPTION_DEFINITIONS = [
key: BugseeOption.ReportingDefaultBugPriority,
default: 'high',
},
// iOS persists the user identifier across launches; the browser does too (localStorage, per app token).
// Browser-only: server tiers keep it in memory. Opt out when the identifier is PII not to be stored.
{ friendly: 'persistUserIdentifier', key: BugseeOption.PersistUserIdentifier, default: true },
];

/** Session-replay options (a structural subset of `@bugsee/replay`'s options — no runtime dep here; replay
Expand Down Expand Up @@ -234,6 +238,13 @@ export interface BugseeLaunchOptions {
reportPrioritySelectorEnabled?: boolean;
/** Severity a dialog report starts with: `low | medium | high | critical | blocker`. Default `high`. */
reportDefaultBugPriority?: SeverityName;
/**
* Persist the user identifier (`setUserIdentifier`) in `localStorage` — one entry per app token — and
* restore it on the next page load, before any report, as the iOS SDK does across app launches.
* `clearUserIdentifier()` removes it. Default true; pass `false` to keep the identifier (often an email,
* i.e. PII) in memory only. Storage failures never break the SDK; they go to `onError`.
*/
persistUserIdentifier?: boolean;
/** Detect window errors + unhandled rejections. Default true. */
detectCrashes?: boolean;
/** Report failed fetch/XHR exchanges (status >= 400, or no response) as `error` reports. Default false. */
Expand Down Expand Up @@ -605,6 +616,20 @@ async function buildLaunch(

clientRef = client;

// Restore the persisted user identifier NOW — synchronously, before any provider starts, any service
// manifest runs or the client is handed back — so the first report already carries it and an identifier
// the app (or a manifest) sets afterwards simply overwrites it. Skipped entirely when opted out.
const identifierStore = resolved.options.get(BugseeOption.PersistUserIdentifier, true)
? createUserIdentifierStore(appToken, {
storage: options.deviceIdEnv?.localStorage,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — Reusing deviceIdEnv.localStorage for a different key space.

This is how the new tests inject storage, but several existing launch harnesses already pass a device-id-only fake (getItem: () => TEST_DEVICE_ID in report-dialog.launch.test.ts / mobile-options.launch.test.ts / launch-lifecycle.test.ts). After this change that getItem answers the identifier key too, so launch restores the device UUID as the user identifier.

Scenario: report-dialog.launch.test.ts "sends a dialog report that reaches the collector as code_dialog" never fills email. The field is pre-filled with the UUID; send uploads request.json.email = a1b2c3d4-…. The test still passes (it does not assert email is absent). I re-ran that file; 13 tests green, leak is silent.

Production globalThis.localStorage is key-aware, so this is not an end-user bug unless a caller injects a similarly key-blind deviceIdEnv.localStorage.

Fix: Default identifier storage to globalThis.localStorage; do not share the device-id seam. Or make those fakes key-aware, matching testDeviceIdLocalStorage() in launch.test.ts.

...(options.onError !== undefined ? { onError: options.onError } : {}),
})
: undefined;
Comment on lines +622 to +627

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 — Opt-out does not purge an already-stored identifier.

persistUserIdentifier: false skips creating the store, so it neither restores nor removes. user-identifier.launch.test.ts pins the stale key remaining. The option docs (BugseeOption.PersistUserIdentifier, BugseeLaunchOptions.persistUserIdentifier) say to pass false when the identifier is PII you do not want at rest.

Scenario: Default launch persists ann@example.com. A later launch with persistUserIdentifier: false ignores it for that session. A following default launch (flag omitted) restores ann@example.com onto the Environment, the first report, and the dialog pre-fill.

Fix: On opt-out, open the store only to clear() the existing key (do not read()). If leaving the value is intentional so toggling the flag is non-destructive, the docs need to say it stays at rest and will return when the flag is omitted.

const restoredIdentifier = identifierStore?.read();
if (restoredIdentifier !== null && restoredIdentifier !== undefined) {
client.setUserIdentifier(restoredIdentifier);
}

// Run every contributed service manifest against the now-populated container.
for (const manifest of getServiceManifests(carrier)) {
manifest(client);
Expand Down Expand Up @@ -818,14 +843,24 @@ async function buildLaunch(
),
);
}
// The public setters: update the Environment (authoritative, in memory), then persist best-effort. The
// report dialog goes through these too, so what the reporter types survives a reload.
const setUserIdentifier = (id: string): void => {
client.setUserIdentifier(id);
identifierStore?.write(id);
};
const clearUserIdentifier = (): void => {
client.clearUserIdentifier();
identifierStore?.clear();
};
const showReportDialog = createReportDialogLauncher({
// No DOM (a worker, SSR) → there is nothing to draw on; the launcher then resolves null.
document: domDocument as Document,
prepare: () => asInternalClient(client).prepareUpload(),
isLaunched: () => domDocument !== undefined && client.isLaunched(),
getUserIdentifier: () => client.getUserIdentifier(),
setUserIdentifier: (email) => client.setUserIdentifier(email),
clearUserIdentifier: () => client.clearUserIdentifier(),
setUserIdentifier: (email) => setUserIdentifier(email),
clearUserIdentifier: () => clearUserIdentifier(),
sdkVersion: SDK_VERSION,
emitLifecycleEvent: (eventType, data) => client.emitLifecycleEvent(eventType, data),
ui: {
Expand All @@ -840,6 +875,8 @@ async function buildLaunch(
});
const publicClient: Bugsee = {
...client,
setUserIdentifier,
clearUserIdentifier,
showReportDialog,
// Android applies `ReportingDefaultBugPriority` to every bug report, not only the dialog's.
upload: (uploadOptions) =>
Expand Down
118 changes: 118 additions & 0 deletions packages/browser/src/user-identifier-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest';
import {
createUserIdentifierStore,
USER_IDENTIFIER_KEY_PREFIX,
type UserIdentifierStorage,
} from './user-identifier-store';

function memoryStorage(initial: Record<string, string> = {}): UserIdentifierStorage & {
data: Map<string, string>;
} {
const data = new Map(Object.entries(initial));
return {
data,
getItem: (k) => data.get(k) ?? null,
setItem: (k, v) => {
data.set(k, v);
},
removeItem: (k) => {
data.delete(k);
},
};
}

const boom = (): never => {
throw new Error('SecurityError');
};
const throwingStorage: UserIdentifierStorage = {
getItem: boom,
setItem: boom,
removeItem: boom,
};

describe('createUserIdentifierStore', () => {
it('round-trips an identifier under a per-token key', () => {
const storage = memoryStorage();
const store = createUserIdentifierStore('tok-a', { storage });
store.write('a@b.c');
expect([...storage.data.entries()]).toEqual([[`${USER_IDENTIFIER_KEY_PREFIX}tok-a`, 'a@b.c']]);
expect(store.read()).toBe('a@b.c');
});

it('isolates app tokens from each other', () => {
const storage = memoryStorage();
createUserIdentifierStore('tok-a', { storage }).write('one');
expect(createUserIdentifierStore('tok-b', { storage }).read()).toBeNull();
});

it('clear removes the stored value', () => {
const storage = memoryStorage();
const store = createUserIdentifierStore('t', { storage });
store.write('x');
store.clear();
expect(storage.data.size).toBe(0);
expect(store.read()).toBeNull();
});

it('treats an empty stored value as absent', () => {
const storage = memoryStorage({ [`${USER_IDENTIFIER_KEY_PREFIX}t`]: '' });
expect(createUserIdentifierStore('t', { storage }).read()).toBeNull();
});

it('never throws on a blocked storage; each failure goes to onError', () => {
const onError = vi.fn();
const store = createUserIdentifierStore('t', { storage: throwingStorage, onError });
expect(store.read()).toBeNull();
expect(() => store.write('x')).not.toThrow();
expect(() => store.clear()).not.toThrow();
expect(onError).toHaveBeenCalledTimes(3);
});

it('tolerates failures with no onError configured', () => {
const store = createUserIdentifierStore('t', { storage: throwingStorage });
expect(store.read()).toBeNull();
expect(() => store.write('x')).not.toThrow();
expect(() => store.clear()).not.toThrow();
});

it('is inert when there is no storage at all', () => {
const store = createUserIdentifierStore('t', { storage: null });
expect(store.read()).toBeNull();
expect(() => store.write('x')).not.toThrow();
expect(() => store.clear()).not.toThrow();
});

it('falls back to globalThis.localStorage by default', () => {
const storage = memoryStorage();
vi.stubGlobal('localStorage', storage);
try {
createUserIdentifierStore('t').write('g');
expect(storage.data.get(`${USER_IDENTIFIER_KEY_PREFIX}t`)).toBe('g');
} finally {
vi.unstubAllGlobals();
}
});

it('is inert when the host has no localStorage global', () => {
vi.stubGlobal('localStorage', undefined);
try {
expect(createUserIdentifierStore('t').read()).toBeNull();
} finally {
vi.unstubAllGlobals();
}
});

it('survives a throwing localStorage getter (blocked cookies)', () => {
const onError = vi.fn();
const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'localStorage', { get: boom, configurable: true });
try {
const store = createUserIdentifierStore('t', { onError });
expect(store.read()).toBeNull();
expect(onError).toHaveBeenCalledTimes(1);
} finally {
if (original !== undefined) Object.defineProperty(globalThis, 'localStorage', original);
else Reflect.deleteProperty(globalThis, 'localStorage');
}
});
});
73 changes: 73 additions & 0 deletions packages/browser/src/user-identifier-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Durable storage for the user identifier (iOS persists `setUserIdentifier` across launches; web parity).
//
// Deliberately NARROWER than the device-id machinery (device-id.ts), which mirrors one value across
// localStorage + cookie + Cookie Store + IndexedDB + Cache API so it survives a "clear site data". An
// identifier is the app's PII (often an email): it must live in exactly ONE first-party store, never in a
// cookie (cookies are sent to the server on every request), and clearing site data SHOULD forget it. So:
// per-app-token localStorage only, last-writer-wins across tabs, read once at launch.
//
// Every access is wrapped. A blocked/throwing storage (private mode, quota, disabled cookies) reports
// through `onError` and degrades to the in-memory Environment value, which is always authoritative.

export const USER_IDENTIFIER_KEY_PREFIX = 'bugsee.user_identifier.';

/** The localStorage subset used. Injectable for tests. */
export interface UserIdentifierStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}

export interface UserIdentifierStore {
/** The persisted identifier, or null when absent / unreadable. Never throws. */
read(): string | null;
/** Persist `id`. Never throws. */
write(id: string): void;
/** Remove the persisted identifier. Never throws. */
clear(): void;
}

export interface UserIdentifierStoreOptions {
/** Storage seam. `undefined` → `globalThis.localStorage`; `null` → no storage (inert store). */
storage?: UserIdentifierStorage | null;
onError?: (error: unknown) => void;
}

export function createUserIdentifierStore(
appToken: string,
options: UserIdentifierStoreOptions = {},
): UserIdentifierStore {
const key = `${USER_IDENTIFIER_KEY_PREFIX}${appToken}`;
const report = (error: unknown): void => options.onError?.(error);
// Resolved lazily and inside try: merely touching `globalThis.localStorage` throws SecurityError when
// storage is blocked.
const resolve = (): UserIdentifierStorage | null =>
options.storage === undefined
? ((globalThis as { localStorage?: UserIdentifierStorage }).localStorage ?? null)
: options.storage;
return {
read() {
try {
const value = resolve()?.getItem(key);
return typeof value === 'string' && value !== '' ? value : null;
} catch (error) {
report(error);
return null;
}
},
write(id) {
try {
resolve()?.setItem(key, id);
Comment on lines +58 to +60

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 — Failed setItem leaves the previous identifier on disk.

write is best-effort, but setItem throwing (QuotaExceededError is the realistic case) is not symmetric with read: getItem still works, so the next launch restores the old value after in-memory already moved to the new one.

Scenario: setUserIdentifier('alice@…') persisted. Later setUserIdentifier('bob@…') hits quota; onError fires; getUserIdentifier() is bob@. Reload restores alice@. Reports in the failed session and the next session disagree about who the user is (request.json.email).

First-time write failure is fine (next launch sees nothing). An identity change is the bug.

Fix: On write failure, try removeItem so a stale identity cannot come back (and/or read-back after setItem). Worth doing the same for clear() if removeItem throws while getItem still works — that is the logout path.

} catch (error) {
report(error);
}
},
clear() {
try {
resolve()?.removeItem(key);
} catch (error) {
report(error);
}
},
};
}
Loading
Loading