@bravely-studios/account-web 0.4.1 → 0.5.0
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.
- package/dist/BravelyAccountManager.d.ts.map +1 -1
- package/dist/BravelyAccountManager.js +48 -2
- package/dist/BravelyAccountManager.js.map +1 -1
- package/dist/EntitlementCache.d.ts +1 -1
- package/dist/EntitlementCache.d.ts.map +1 -1
- package/dist/EntitlementCache.js +1 -1
- package/dist/EntitlementCache.js.map +1 -1
- package/dist/error-firehose-client.d.ts +256 -0
- package/dist/error-firehose-client.d.ts.map +1 -0
- package/dist/error-firehose-client.js +823 -0
- package/dist/error-firehose-client.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|