@bravely-studios/account-web 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,823 @@
1
+ // Error Firehose Client — Phase 2 (bravely-account-web)
2
+ //
3
+ // Implements the client-side error telemetry pipeline as specified in
4
+ // SYNTHESIS.md §A-§E (DECISIONS-LOCKED.md 2026-06-13).
5
+ //
6
+ // PUBLIC API
7
+ // ErrorFirehoseClient.instance(config) — get/init the singleton
8
+ // ErrorFirehoseClient.observe(...) — side-tap from the facade
9
+ // ErrorFirehoseClient.getConsent() — read consent state
10
+ // ErrorFirehoseClient.setConsent(bool) — write consent (true=on)
11
+ // ErrorFirehoseClient.shouldShowOptOutNote() — one-time note gate
12
+ // ErrorFirehoseClient.ackOptOutNote() — mark note shown
13
+ // ErrorFirehoseClient.flush() — force a flush (testing / shutdown)
14
+ //
15
+ // Key design decisions (spec refs):
16
+ // §B fingerprint = SHA-256(slug|platform|severity|category|normalizedMsg|frameDigest)[:16]
17
+ // §C CoalescingBuffer: 60s window OR 50 distinct fps, per-fp circuit-breaker >100/10s→5min silence
18
+ // §C Global cap 50 distinct fps/60s; overflow → synthetic client.overflow event
19
+ // §D Consent key bravely.error_telemetry_enabled (default false until geo resolution)
20
+ // §E POST identity.bravely.dev/api/error-events, no auth header, backoff 30/60/120s ±10s
21
+ import { getOrMintInstallId } from "./installMetrics.js";
22
+ import { defaultStorage } from "./storage.js";
23
+ /** Simple in-memory ConsentStorage shim for test environments. */
24
+ export function memoryConsentStorage() {
25
+ const m = new Map();
26
+ return {
27
+ getItem: (k) => m.get(k) ?? null,
28
+ setItem: (k, v) => { m.set(k, v); },
29
+ removeItem: (k) => { m.delete(k); },
30
+ };
31
+ }
32
+ // ---- Consent storage keys (§D) ---------------------------------------------
33
+ export const CONSENT_KEY = "bravely.error_telemetry_enabled";
34
+ export const OPT_OUT_SHOWN_KEY = "bravely.error_telemetry_opt_out_shown";
35
+ export const GEO_CACHE_KEY = "bravely.error_geo_eu";
36
+ export const OPT_IN_SHOWN_KEY = "bravely.error_telemetry_opt_in_shown";
37
+ export const GEO_PROBE_URL = "https://identity.bravely.dev/api/geo";
38
+ const ENDPOINT = "https://identity.bravely.dev/api/error-events";
39
+ // ---- Normalization + fingerprinting (§B) -----------------------------------
40
+ /** 7-pattern scrubber — strip PII before buffering. */
41
+ export function redactMessage(msg) {
42
+ return msg
43
+ // ba_-prefixed IDs
44
+ .replace(/\bba_[A-Za-z0-9_-]{1,128}/g, "ba_[redacted]")
45
+ // UUIDs
46
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "[uuid]")
47
+ // ISO-8601 timestamps
48
+ .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g, "[ts]")
49
+ // Hex strings >=8 chars (token-like)
50
+ .replace(/\b[0-9a-f]{8,}\b/gi, "[hex]")
51
+ // Integers >=4 digits
52
+ .replace(/\b\d{4,}\b/g, "[int]")
53
+ // Absolute paths (Unix/Windows)
54
+ .replace(/(?:\/(?:[a-zA-Z0-9_.-]+\/)+[a-zA-Z0-9_.-]*|[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*)/g, "[path]")
55
+ // Email addresses
56
+ .replace(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, "[email]");
57
+ }
58
+ /**
59
+ * Normalize a message for fingerprinting (§B):
60
+ * strip hex>=8, UUIDs, ints>=4 digits, ISO timestamps, ba_ ids, abs paths,
61
+ * lowercase, collapse whitespace.
62
+ */
63
+ export function normalizeForFingerprint(msg) {
64
+ let s = redactMessage(msg);
65
+ s = s.toLowerCase();
66
+ s = s.replace(/\s+/g, " ").trim();
67
+ return s;
68
+ }
69
+ /** Compute SHA-256 and return the first `len` hex chars. */
70
+ async function sha256Hex(input, len) {
71
+ const c = globalThis.crypto;
72
+ if (c?.subtle) {
73
+ const enc = new TextEncoder();
74
+ const buf = await c.subtle.digest("SHA-256", enc.encode(input));
75
+ const hex = Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
76
+ return hex.slice(0, len);
77
+ }
78
+ // Fallback: djb2 hash (deterministic, non-cryptographic) when SubtleCrypto is absent.
79
+ let h = 5381;
80
+ for (let i = 0; i < input.length; i++)
81
+ h = ((h << 5) + h) ^ input.charCodeAt(i);
82
+ const unsigned = (h >>> 0).toString(16).padStart(8, "0");
83
+ // Extend to requested length by repeating/hashing the input segments.
84
+ let full = unsigned;
85
+ let seed = input + unsigned;
86
+ while (full.length < len) {
87
+ let h2 = 5381;
88
+ for (let i = 0; i < seed.length; i++)
89
+ h2 = ((h2 << 5) + h2) ^ seed.charCodeAt(i);
90
+ full += (h2 >>> 0).toString(16).padStart(8, "0");
91
+ seed = full;
92
+ }
93
+ return full.slice(0, len);
94
+ }
95
+ /**
96
+ * Compute the daily_token for the given install_id.
97
+ * daily_token = sha256(install_id + "|" + UTC_date(YYYY-MM-DD) + "|" + "bravely-error-v1")[:16 hex]
98
+ *
99
+ * - Same device same UTC day → same token (daily-distinct + rate-limit)
100
+ * - Cannot correlate across days (different date → different token)
101
+ * - Cannot reverse to install_id (SHA-256 is one-way, high-entropy input)
102
+ * - install_id NEVER leaves the device
103
+ */
104
+ export async function computeDailyToken(installId, utcDate) {
105
+ const date = utcDate ?? new Date().toISOString().slice(0, 10); // YYYY-MM-DD
106
+ const input = installId + "|" + date + "|" + "bravely-error-v1";
107
+ return sha256Hex(input, 16);
108
+ }
109
+ /**
110
+ * Compute the 16-char fingerprint per §B.
111
+ *
112
+ * fingerprint = SHA-256(app_slug|platform|severity|category|normalizedMsg|frameDigest)[:16]
113
+ *
114
+ * Over-grouping protection: category is always in the key.
115
+ * Under-grouping: integer stripping means "failed after 3 retries" and
116
+ * "failed after 847 retries" get the same normalized template.
117
+ * Short-message guard: if normalized result is <8 chars, append exception_type.
118
+ */
119
+ export async function computeFingerprint(opts) {
120
+ let norm = normalizeForFingerprint(opts.message);
121
+ if (norm.length < 8 && opts.exception_type) {
122
+ norm = norm + " " + opts.exception_type.toLowerCase();
123
+ }
124
+ const key = [
125
+ opts.app_slug,
126
+ opts.platform,
127
+ opts.severity,
128
+ opts.category,
129
+ norm,
130
+ opts.frame_digest ?? "",
131
+ ].join("|");
132
+ return sha256Hex(key, 16);
133
+ }
134
+ /** Compute frame_digest: SHA-256[:12] of top-3 normalized frame symbols (no line numbers). */
135
+ export async function computeFrameDigest(frames) {
136
+ const normalized = frames.slice(0, 3).map((f) =>
137
+ // strip line numbers like :42 or (42) or L42
138
+ f.replace(/(?::\d+|\(\d+\)|L\d+)\s*$/g, "").trim());
139
+ return sha256Hex(normalized.join("|"), 12);
140
+ }
141
+ /**
142
+ * Probe the geo endpoint once and cache the eu flag in consentStorage.
143
+ * - Makes ONE anonymous GET request to /api/geo (no PII, no body, stores nothing server-side)
144
+ * - Caches the eu flag in consentStorage under GEO_CACHE_KEY
145
+ * - On network failure, falls back to OS/browser locale heuristic (EU locale → eu=true)
146
+ * - Returns { country, eu } — never throws
147
+ */
148
+ export async function probeGeo(consentStorage, probeUrlOverride) {
149
+ // Check cache first
150
+ try {
151
+ const cached = consentStorage.getItem(GEO_CACHE_KEY);
152
+ if (cached !== null) {
153
+ return JSON.parse(cached);
154
+ }
155
+ }
156
+ catch {
157
+ // cache miss or parse error — proceed to probe
158
+ }
159
+ // Try the network probe
160
+ try {
161
+ const url = probeUrlOverride ?? GEO_PROBE_URL;
162
+ const res = await fetch(url, { method: "GET" });
163
+ if (res.ok) {
164
+ const data = (await res.json());
165
+ // Cache result
166
+ try {
167
+ consentStorage.setItem(GEO_CACHE_KEY, JSON.stringify(data));
168
+ }
169
+ catch {
170
+ // storage unavailable — no-op
171
+ }
172
+ return data;
173
+ }
174
+ }
175
+ catch {
176
+ // network failure — fall through to locale fallback
177
+ }
178
+ // Offline fallback: use browser/OS locale as a conservative proxy
179
+ const euCountryCodes = new Set([
180
+ "AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU",
181
+ "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK",
182
+ // EEA
183
+ "IS", "LI", "NO",
184
+ // UK
185
+ "GB",
186
+ ]);
187
+ let country = "XX";
188
+ try {
189
+ // navigator.language gives e.g. "en-GB", "de-DE", "fr-FR"
190
+ if (typeof navigator !== "undefined" && navigator.language) {
191
+ const parts = navigator.language.split("-");
192
+ if (parts.length >= 2)
193
+ country = parts[parts.length - 1].toUpperCase();
194
+ }
195
+ }
196
+ catch {
197
+ // SSR or restricted — country stays XX
198
+ }
199
+ const euFallback = euCountryCodes.has(country);
200
+ const result = { country, eu: euFallback };
201
+ try {
202
+ consentStorage.setItem(GEO_CACHE_KEY, JSON.stringify(result));
203
+ }
204
+ catch {
205
+ // no-op
206
+ }
207
+ return result;
208
+ }
209
+ /**
210
+ * Resolve and persist the consent default for a new install.
211
+ *
212
+ * Call ONCE on first launch, BEFORE any observe() call.
213
+ * - If the user has already made a choice (CONSENT_KEY is set), does nothing.
214
+ * - Otherwise: probes geo, sets the default (EU→false, non-EU→true),
215
+ * and returns whether an opt-in prompt should be shown to the user.
216
+ *
217
+ * EU flow (eu=true):
218
+ * - default = OFF (consent NOT set / left at null → no data transmitted)
219
+ * - shouldShowOptIn = true → host should show:
220
+ * "Help improve <AppName> with anonymous error reports? [Enable] [Not now]"
221
+ * - When user clicks Enable: call setConsent(true); ackOptInPrompt()
222
+ * - When user clicks Not now: call ackOptInPrompt() (consent stays off)
223
+ *
224
+ * Non-EU flow (eu=false):
225
+ * - default = ON (consent set to "true")
226
+ * - shouldShowOptIn = false
227
+ * - shouldShowOptOutNote() on the client returns true → show graceful opt-out note
228
+ * (this is the existing OPT_OUT_SHOWN_KEY / ackOptOutNote() path)
229
+ */
230
+ export async function resolveConsentDefault(consentStorage, probeUrlOverride) {
231
+ // If user already made a choice, respect it
232
+ try {
233
+ const existing = consentStorage.getItem(CONSENT_KEY);
234
+ if (existing !== null) {
235
+ // Already resolved; just return current state for the host
236
+ const geo = await probeGeo(consentStorage, probeUrlOverride);
237
+ return { eu: geo.eu, shouldShowOptIn: false };
238
+ }
239
+ }
240
+ catch {
241
+ // storage error — treat as no prior choice
242
+ }
243
+ const geo = await probeGeo(consentStorage, probeUrlOverride);
244
+ if (geo.eu) {
245
+ // EU: do NOT set consent (stays null = off); host must show opt-in prompt
246
+ return { eu: true, shouldShowOptIn: true };
247
+ }
248
+ else {
249
+ // Non-EU: default ON
250
+ try {
251
+ consentStorage.setItem(CONSENT_KEY, "true");
252
+ }
253
+ catch {
254
+ // no-op
255
+ }
256
+ return { eu: false, shouldShowOptIn: false };
257
+ }
258
+ }
259
+ /**
260
+ * Record that the one-time EU opt-in prompt has been shown.
261
+ * Call after showing the opt-in dialog regardless of the user's choice.
262
+ */
263
+ export function ackOptInPrompt(consentStorage) {
264
+ try {
265
+ consentStorage.setItem(OPT_IN_SHOWN_KEY, "true");
266
+ }
267
+ catch {
268
+ // no-op
269
+ }
270
+ }
271
+ /**
272
+ * True iff the one-time EU opt-in prompt has NOT yet been shown.
273
+ */
274
+ export function shouldShowOptInPrompt(consentStorage) {
275
+ try {
276
+ return consentStorage.getItem(OPT_IN_SHOWN_KEY) !== "true";
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ }
282
+ // ---- UUID helper ------------------------------------------------------------
283
+ function mintUuid() {
284
+ const c = globalThis.crypto;
285
+ if (c && typeof c.randomUUID === "function")
286
+ return c.randomUUID();
287
+ const bytes = new Uint8Array(16);
288
+ if (c && typeof c.getRandomValues === "function") {
289
+ c.getRandomValues(bytes);
290
+ }
291
+ else {
292
+ for (let i = 0; i < 16; i++)
293
+ bytes[i] = Math.floor(Math.random() * 256);
294
+ }
295
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
296
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
297
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
298
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
299
+ }
300
+ // ---- ErrorFirehoseClient singleton ------------------------------------------
301
+ /** Max distinct fingerprints per 60s window. */
302
+ const MAX_DISTINCT_FP = 50;
303
+ /** Flush interval in ms. */
304
+ const FLUSH_INTERVAL_MS = 60_000;
305
+ /** Max batch size (events). */
306
+ const MAX_BATCH_EVENTS = 50;
307
+ /** Max batch size (bytes). */
308
+ const MAX_BATCH_BYTES = 256 * 1024;
309
+ /** Max pending batches in the in-memory queue. */
310
+ const MAX_PENDING_BATCHES = 10;
311
+ /** Per-fingerprint circuit-breaker: window duration. */
312
+ const CB_WINDOW_MS = 10_000;
313
+ /** Per-fingerprint circuit-breaker: trip threshold per window. */
314
+ const CB_TRIP_COUNT = 100;
315
+ /** Per-fingerprint circuit-breaker: silence duration. */
316
+ const CB_SILENCE_MS = 5 * 60_000;
317
+ /** Backoff schedule (ms before jitter). */
318
+ const BACKOFF_MS = [30_000, 60_000, 120_000];
319
+ /** Jitter range ±ms. */
320
+ const JITTER_MS = 10_000;
321
+ export class ErrorFirehoseClient {
322
+ static _instance = null;
323
+ cfg;
324
+ storage;
325
+ consentStore;
326
+ /** fingerprint → CoalescedEntry */
327
+ buffer = new Map();
328
+ /** Count of distinct fingerprints admitted in the current window (excl. overflow). */
329
+ windowFpCount = 0;
330
+ /** Overflow accumulator: count of fps dropped due to the global cap. */
331
+ overflowDropped = 0;
332
+ /** Wall-clock start of the current 60s window. */
333
+ windowStart = 0;
334
+ /** Timer handle for the flush interval. */
335
+ flushTimer = null;
336
+ /** Pending batch queue (in-memory only, NOT persisted). */
337
+ pendingQueue = [];
338
+ /** True when a flush/drain cycle is running. */
339
+ flushing = false;
340
+ /** Cached daily_token for the current UTC date. */
341
+ _cachedDailyToken = null;
342
+ _cachedDailyTokenDate = null;
343
+ constructor(cfg) {
344
+ this.cfg = cfg;
345
+ this.storage = cfg.storage ?? defaultStorage();
346
+ this.consentStore = cfg.consentStorage ?? defaultConsentStorage();
347
+ this.windowStart = Date.now();
348
+ this._startFlushTimer();
349
+ }
350
+ /**
351
+ * Get or init the singleton. Pass config on first call; subsequent calls
352
+ * with no config return the existing instance.
353
+ */
354
+ static instance(cfg) {
355
+ if (!ErrorFirehoseClient._instance) {
356
+ if (!cfg)
357
+ throw new Error("ErrorFirehoseClient: config required on first call");
358
+ ErrorFirehoseClient._instance = new ErrorFirehoseClient(cfg);
359
+ }
360
+ return ErrorFirehoseClient._instance;
361
+ }
362
+ /**
363
+ * Return the singleton if already initialized, or null.
364
+ * Used by the BravelyAccountManager side-tap — safe no-op when the host
365
+ * has not initialized the firehose yet.
366
+ */
367
+ static _tryInstance() {
368
+ return ErrorFirehoseClient._instance;
369
+ }
370
+ /**
371
+ * Reset the singleton (for testing only).
372
+ * @internal
373
+ */
374
+ static _reset() {
375
+ if (ErrorFirehoseClient._instance) {
376
+ ErrorFirehoseClient._instance._stopFlushTimer();
377
+ ErrorFirehoseClient._instance = null;
378
+ }
379
+ }
380
+ // ---- Consent API (§D) ---------------------------------------------------
381
+ /**
382
+ * Read the current consent state.
383
+ * Default is false until resolveConsentDefault() runs.
384
+ */
385
+ getConsent() {
386
+ try {
387
+ const val = this.consentStore.getItem(CONSENT_KEY);
388
+ if (val === null)
389
+ return false; // default: OFF until resolveConsentDefault() runs
390
+ return val !== "false";
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
396
+ /** Write consent state. */
397
+ setConsent(enabled) {
398
+ try {
399
+ this.consentStore.setItem(CONSENT_KEY, enabled ? "true" : "false");
400
+ }
401
+ catch {
402
+ // storage unavailable — no-op cleanly
403
+ }
404
+ }
405
+ /**
406
+ * True iff the one-time opt-out note should be shown.
407
+ * Returns false after it has been acknowledged once (§D).
408
+ */
409
+ shouldShowOptOutNote() {
410
+ try {
411
+ return this.consentStore.getItem(OPT_OUT_SHOWN_KEY) !== "true";
412
+ }
413
+ catch {
414
+ return false;
415
+ }
416
+ }
417
+ /** Mark the opt-out note as shown (never show again). */
418
+ ackOptOutNote() {
419
+ try {
420
+ this.consentStore.setItem(OPT_OUT_SHOWN_KEY, "true");
421
+ }
422
+ catch {
423
+ // no-op
424
+ }
425
+ }
426
+ // ---- Core observe() (§C) -----------------------------------------------
427
+ /**
428
+ * Side-tap from the facade warn()/error() methods — SECOND path alongside
429
+ * the ring buffer, never replacing it.
430
+ *
431
+ * Steps:
432
+ * 1. Consent gate: return immediately if disabled.
433
+ * 2. Redact message (7-pattern scrubber).
434
+ * 3. Compute fingerprint.
435
+ * 4. Per-fingerprint circuit-breaker check.
436
+ * 5. Global cap check (max 50 distinct fps/window).
437
+ * 6. Upsert into CoalescingBuffer.
438
+ */
439
+ async observe(severity, category, message, error, spec_id) {
440
+ // §C Consent gate — first check
441
+ if (!this.getConsent())
442
+ return;
443
+ const now = Date.now();
444
+ // Reset window if 60s elapsed
445
+ if (now - this.windowStart >= FLUSH_INTERVAL_MS) {
446
+ this._rotateWindow();
447
+ }
448
+ const redacted = redactMessage(message);
449
+ const truncatedMsg = redacted.slice(0, 256);
450
+ const exceptionType = error?.constructor?.name !== "Error" ? error?.constructor?.name : undefined;
451
+ const topFrames = extractTopFrames(error);
452
+ const frameDigest = topFrames.length > 0 ? await computeFrameDigest(topFrames) : undefined;
453
+ const fp = await computeFingerprint({
454
+ app_slug: this.cfg.app_slug,
455
+ platform: "web",
456
+ severity,
457
+ category,
458
+ message: truncatedMsg,
459
+ frame_digest: frameDigest,
460
+ exception_type: exceptionType,
461
+ });
462
+ // Per-fingerprint circuit-breaker (§C)
463
+ const existing = this.buffer.get(fp);
464
+ if (existing) {
465
+ // Check if in silence period
466
+ if (now < existing._silencedUntil) {
467
+ // Still silenced — count it but don't update the buffer
468
+ existing.count += 1;
469
+ return;
470
+ }
471
+ // Update circuit-breaker window
472
+ if (now - existing._cbWindowStart >= CB_WINDOW_MS) {
473
+ existing._cbWindowStart = now;
474
+ existing._cbWindowCount = 0;
475
+ existing._emittedOnSilence = false;
476
+ }
477
+ existing._cbWindowCount += 1;
478
+ if (existing._cbWindowCount > CB_TRIP_COUNT) {
479
+ // Trip: silence for 5 minutes, but first emit once with current count
480
+ if (!existing._emittedOnSilence) {
481
+ existing._silencedUntil = now + CB_SILENCE_MS;
482
+ existing._emittedOnSilence = true;
483
+ // The entry already has count — it will be included in next flush
484
+ }
485
+ return;
486
+ }
487
+ // Normal upsert
488
+ existing.count += 1;
489
+ existing.last_seen = now;
490
+ const ctx = this.cfg.getContext?.();
491
+ if (ctx) {
492
+ existing.signed_in = ctx.signed_in;
493
+ existing.entitled = ctx.entitled;
494
+ existing.online = ctx.online;
495
+ if (ctx.session_uptime_seconds !== undefined) {
496
+ existing.session_uptime_seconds = Math.round(ctx.session_uptime_seconds / 30) * 30;
497
+ }
498
+ }
499
+ }
500
+ else {
501
+ // New fingerprint — check global cap
502
+ if (this.windowFpCount >= MAX_DISTINCT_FP) {
503
+ // Global overflow: accumulate and emit synthetic event later
504
+ this.overflowDropped += 1;
505
+ return;
506
+ }
507
+ this.windowFpCount += 1;
508
+ const ctx = this.cfg.getContext?.();
509
+ const uptimeRounded = ctx?.session_uptime_seconds !== undefined
510
+ ? Math.round(ctx.session_uptime_seconds / 30) * 30
511
+ : undefined;
512
+ this.buffer.set(fp, {
513
+ severity,
514
+ category,
515
+ spec_id,
516
+ fingerprint: fp,
517
+ message_template: truncatedMsg,
518
+ exception_type: exceptionType,
519
+ top_frames: topFrames.length > 0 ? topFrames : undefined,
520
+ frame_digest: frameDigest,
521
+ count: 1,
522
+ first_seen: now,
523
+ last_seen: now,
524
+ signed_in: ctx?.signed_in ?? false,
525
+ entitled: ctx?.entitled ?? false,
526
+ online: ctx?.online ?? navigator?.onLine ?? true,
527
+ session_uptime_seconds: uptimeRounded,
528
+ _cbWindowStart: now,
529
+ _cbWindowCount: 1,
530
+ _silencedUntil: 0,
531
+ _emittedOnSilence: false,
532
+ });
533
+ }
534
+ }
535
+ /**
536
+ * Force a flush (for testing and graceful shutdown).
537
+ */
538
+ async flush() {
539
+ return this._flush();
540
+ }
541
+ // ---- Private internals --------------------------------------------------
542
+ _startFlushTimer() {
543
+ if (typeof setInterval === "undefined")
544
+ return;
545
+ this.flushTimer = setInterval(() => {
546
+ void this._flush();
547
+ }, FLUSH_INTERVAL_MS);
548
+ // Don't prevent Node process from exiting in tests
549
+ if (this.flushTimer && typeof this.flushTimer === "object" && "unref" in this.flushTimer) {
550
+ this.flushTimer.unref();
551
+ }
552
+ }
553
+ _stopFlushTimer() {
554
+ if (this.flushTimer !== null) {
555
+ clearInterval(this.flushTimer);
556
+ this.flushTimer = null;
557
+ }
558
+ }
559
+ /**
560
+ * Rotate to a new 60s window. Called lazily from observe() when >=60s has
561
+ * elapsed since windowStart. Resets the fp admission counter and the
562
+ * overflow accumulator. Does NOT clear the buffer — ongoing entries carry
563
+ * over to the new window if not yet flushed.
564
+ */
565
+ _rotateWindow() {
566
+ this.windowStart = Date.now();
567
+ this.windowFpCount = 0;
568
+ this.overflowDropped = 0;
569
+ }
570
+ /**
571
+ * Drain the current buffer into the pending queue, then attempt to drain
572
+ * the pending queue (oldest-first).
573
+ */
574
+ async _flush() {
575
+ if (this.flushing)
576
+ return;
577
+ this.flushing = true;
578
+ try {
579
+ await this._drainBufferToQueue();
580
+ await this._drainPendingQueue();
581
+ }
582
+ finally {
583
+ this.flushing = false;
584
+ }
585
+ }
586
+ async _drainBufferToQueue() {
587
+ if (this.buffer.size === 0 && this.overflowDropped === 0)
588
+ return;
589
+ const now = Date.now();
590
+ const events = [];
591
+ for (const entry of this.buffer.values()) {
592
+ events.push({
593
+ client_event_id: mintUuid(),
594
+ severity: entry.severity,
595
+ category: entry.category,
596
+ spec_id: entry.spec_id,
597
+ fingerprint: entry.fingerprint,
598
+ message_template: entry.message_template,
599
+ exception_type: entry.exception_type,
600
+ top_frames: entry.top_frames,
601
+ frame_digest: entry.frame_digest,
602
+ breadcrumbs: entry.breadcrumbs,
603
+ count: entry.count,
604
+ first_seen: new Date(entry.first_seen).toISOString(),
605
+ last_seen: new Date(entry.last_seen).toISOString(),
606
+ signed_in: entry.signed_in,
607
+ entitled: entry.entitled,
608
+ online: entry.online,
609
+ session_uptime_seconds: entry.session_uptime_seconds,
610
+ });
611
+ }
612
+ // Snapshot and clear the overflow accumulator BEFORE clearing the buffer.
613
+ // The overflow count is cleared here so the next batch doesn't double-count.
614
+ const droppedCount = this.overflowDropped;
615
+ this.overflowDropped = 0;
616
+ // Synthetic overflow event
617
+ if (droppedCount > 0) {
618
+ events.push({
619
+ client_event_id: mintUuid(),
620
+ severity: "warn",
621
+ category: "client",
622
+ fingerprint: "client.overflow",
623
+ message_template: "client.distinct_fingerprint_overflow",
624
+ count: droppedCount,
625
+ first_seen: new Date(this.windowStart).toISOString(),
626
+ last_seen: new Date(now).toISOString(),
627
+ signed_in: false,
628
+ entitled: false,
629
+ online: true,
630
+ });
631
+ }
632
+ // Clear the buffer entries but DO NOT rotate the window — windowFpCount
633
+ // and windowStart are time-based and remain valid until the 60s window
634
+ // elapses (checked lazily in observe()). This ensures that fps admitted in
635
+ // this window continue to coalesce rather than re-enter after a drain.
636
+ this.buffer.clear();
637
+ // After draining, windowFpCount reflects "fps admitted so far this window".
638
+ // We reset it to 0 so that the buffer is empty but we allow new fps in.
639
+ // However, if we're still within the window and at cap, new fps should
640
+ // still overflow. The correct invariant: windowFpCount = buffer.size at all
641
+ // times within a window. After drain, buffer.size = 0, so reset to 0.
642
+ this.windowFpCount = 0;
643
+ if (events.length === 0)
644
+ return;
645
+ // Chunk into batches of max MAX_BATCH_EVENTS events / MAX_BATCH_BYTES bytes
646
+ const batches = chunkIntoBatches(events, MAX_BATCH_EVENTS, MAX_BATCH_BYTES);
647
+ const dailyToken = await this._getDailyToken();
648
+ for (const chunk of batches) {
649
+ if (this.pendingQueue.length >= MAX_PENDING_BATCHES) {
650
+ // Drop oldest to make room
651
+ this.pendingQueue.shift();
652
+ }
653
+ this.pendingQueue.push({
654
+ batch: {
655
+ schema_version: 1,
656
+ batch_id: mintUuid(),
657
+ daily_token: dailyToken,
658
+ app_slug: this.cfg.app_slug,
659
+ platform: "web",
660
+ app_version: this.cfg.app_version,
661
+ os_version: this.cfg.os_version ?? detectOsVersion(),
662
+ events: chunk,
663
+ },
664
+ attempts: 0,
665
+ nextRetryAt: 0,
666
+ });
667
+ }
668
+ }
669
+ async _drainPendingQueue() {
670
+ const now = Date.now();
671
+ // Process ready batches (nextRetryAt <= now)
672
+ for (let i = 0; i < this.pendingQueue.length;) {
673
+ const item = this.pendingQueue[i];
674
+ if (item.nextRetryAt > now) {
675
+ i++;
676
+ continue;
677
+ }
678
+ const success = await this._postBatch(item.batch);
679
+ if (success) {
680
+ this.pendingQueue.splice(i, 1);
681
+ // don't increment i — next item shifted down
682
+ }
683
+ else {
684
+ item.attempts += 1;
685
+ if (item.attempts >= 4) {
686
+ // Max 3 retries exhausted (initial + 30s + 60s + 120s → drop): drop
687
+ this.pendingQueue.splice(i, 1);
688
+ }
689
+ else {
690
+ // Backoff with ±10s jitter: 30s → 60s → 120s (BACKOFF_MS indices 0, 1, 2)
691
+ const base = BACKOFF_MS[item.attempts - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
692
+ const jitter = (Math.random() * 2 - 1) * JITTER_MS;
693
+ item.nextRetryAt = Date.now() + base + jitter;
694
+ i++;
695
+ }
696
+ }
697
+ }
698
+ }
699
+ /**
700
+ * POST a batch to the ingest endpoint. Returns true on success (2xx).
701
+ * Never throws.
702
+ */
703
+ async _postBatch(batch) {
704
+ const endpoint = this.cfg.endpointOverride ?? ENDPOINT;
705
+ try {
706
+ const body = JSON.stringify(batch);
707
+ const res = await fetch(endpoint, {
708
+ method: "POST",
709
+ headers: { "Content-Type": "application/json" },
710
+ body,
711
+ // Anonymous: NO Authorization header (§E)
712
+ });
713
+ // 200 or 202 = accepted (200 + accepted:0 on kill-switch is still ok)
714
+ // 400 = schema_invalid — don't retry (permanent failure)
715
+ // 413 = too large — don't retry
716
+ // 429 = rate_limited — retry with backoff
717
+ if (res.status === 400 || res.status === 413)
718
+ return true; // treat as "drop" not "retry"
719
+ return res.ok;
720
+ }
721
+ catch {
722
+ // Network failure — will retry with backoff
723
+ return false;
724
+ }
725
+ }
726
+ async _getDailyToken() {
727
+ const today = new Date().toISOString().slice(0, 10);
728
+ if (this._cachedDailyToken && this._cachedDailyTokenDate === today) {
729
+ return this._cachedDailyToken;
730
+ }
731
+ const installId = await getOrMintInstallId(this.storage);
732
+ const token = await computeDailyToken(installId, today);
733
+ this._cachedDailyToken = token;
734
+ this._cachedDailyTokenDate = today;
735
+ return token;
736
+ }
737
+ }
738
+ // ---- Consent storage helpers -----------------------------------------------
739
+ /**
740
+ * Return the production ConsentStorage (localStorage when available, memory fallback).
741
+ * localStorage methods may not be available in SSR or restricted environments.
742
+ */
743
+ function defaultConsentStorage() {
744
+ try {
745
+ const ls = globalThis.localStorage;
746
+ if (ls && typeof ls.setItem === "function")
747
+ return ls;
748
+ }
749
+ catch {
750
+ // Access denied (private mode, etc.)
751
+ }
752
+ // Fallback: in-memory (consent not persisted — no-op for SSR)
753
+ return memoryConsentStorage();
754
+ }
755
+ // ---- Helpers ---------------------------------------------------------------
756
+ /** Extract up to 3 frame symbols from an Error stack trace. */
757
+ function extractTopFrames(error) {
758
+ if (!error?.stack)
759
+ return [];
760
+ const lines = error.stack.split("\n");
761
+ const frames = [];
762
+ for (const line of lines) {
763
+ const trimmed = line.trim();
764
+ // Skip the error message line (not at)
765
+ if (trimmed.startsWith("at ") || trimmed.match(/^[A-Za-z]/)) {
766
+ // Extract function/method name only (no file paths or line numbers)
767
+ const match = trimmed.match(/^at\s+([^\s(]+)/);
768
+ if (match) {
769
+ frames.push(match[1]);
770
+ if (frames.length === 3)
771
+ break;
772
+ }
773
+ }
774
+ }
775
+ return frames;
776
+ }
777
+ /** Split events into chunks respecting both the count and byte size limits. */
778
+ function chunkIntoBatches(events, maxCount, maxBytes) {
779
+ const chunks = [];
780
+ let current = [];
781
+ let currentBytes = 0;
782
+ for (const ev of events) {
783
+ const evBytes = JSON.stringify(ev).length;
784
+ if (current.length > 0 &&
785
+ (current.length >= maxCount || currentBytes + evBytes > maxBytes)) {
786
+ chunks.push(current);
787
+ current = [];
788
+ currentBytes = 0;
789
+ }
790
+ current.push(ev);
791
+ currentBytes += evBytes;
792
+ }
793
+ if (current.length > 0)
794
+ chunks.push(current);
795
+ return chunks;
796
+ }
797
+ /** Detect OS version string from the browser environment. */
798
+ function detectOsVersion() {
799
+ if (typeof navigator === "undefined")
800
+ return "unknown";
801
+ const ua = navigator.userAgent;
802
+ // macOS
803
+ const mac = ua.match(/Mac OS X ([0-9_]+)/);
804
+ if (mac)
805
+ return `macOS ${mac[1].replace(/_/g, ".")}`;
806
+ // Windows
807
+ const win = ua.match(/Windows NT ([0-9.]+)/);
808
+ if (win)
809
+ return `Windows NT ${win[1]}`;
810
+ // iOS
811
+ const ios = ua.match(/iPhone OS ([0-9_]+)/);
812
+ if (ios)
813
+ return `iOS ${ios[1].replace(/_/g, ".")}`;
814
+ // Android
815
+ const android = ua.match(/Android ([0-9.]+)/);
816
+ if (android)
817
+ return `Android ${android[1]}`;
818
+ // Linux
819
+ if (ua.includes("Linux"))
820
+ return "Linux";
821
+ return "unknown";
822
+ }
823
+ //# sourceMappingURL=error-firehose-client.js.map