Repository navigation
T9: persist the user identifier across page loads (browser) #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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. */ | ||
|
|
@@ -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, | ||
| ...(options.onError !== undefined ? { onError: options.onError } : {}), | ||
| }) | ||
| : undefined; | ||
|
Comment on lines
+622
to
+627
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 — Opt-out does not purge an already-stored identifier.
Scenario: Default launch persists Fix: On opt-out, open the store only to |
||
| 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); | ||
|
|
@@ -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: { | ||
|
|
@@ -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) => | ||
|
|
||
| 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'); | ||
| } | ||
| }); | ||
| }); |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 — Failed
Scenario: First-time write failure is fine (next launch sees nothing). An identity change is the bug. Fix: On |
||
| } catch (error) { | ||
| report(error); | ||
| } | ||
| }, | ||
| clear() { | ||
| try { | ||
| resolve()?.removeItem(key); | ||
| } catch (error) { | ||
| report(error); | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3 — Reusing
deviceIdEnv.localStoragefor 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_IDinreport-dialog.launch.test.ts/mobile-options.launch.test.ts/launch-lifecycle.test.ts). After this change thatgetItemanswers 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 uploadsrequest.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.localStorageis key-aware, so this is not an end-user bug unless a caller injects a similarly key-blinddeviceIdEnv.localStorage.Fix: Default identifier storage to
globalThis.localStorage; do not share the device-id seam. Or make those fakes key-aware, matchingtestDeviceIdLocalStorage()inlaunch.test.ts.