From f38adf76ff709e4f9d14a9585de7a346ff544446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sanja=20Malovi=C4=87?= Date: Wed, 30 Sep 2026 13:20:53 +0200 Subject: [PATCH 1/2] fix(sdk): sanitize Script error. reporting in @telemetry-tracker/core 1.5.1 Stop fabricating SDK stacks for browser-sanitized Script errors, rate-limit identical floods, and label them clearly in the dashboard error detail view. Co-authored-by: Cursor --- CHANGELOG.md | 3 + .../app/dashboard/errors/[id]/page.tsx | 68 +++- packages/telemetry-core/dist/index.d.ts | 1 + packages/telemetry-core/dist/index.d.ts.map | 2 +- packages/telemetry-core/dist/index.js | 85 +++- .../dist/sanitized-script-error.d.ts | 36 ++ .../dist/sanitized-script-error.d.ts.map | 1 + .../dist/sanitized-script-error.js | 70 ++++ packages/telemetry-core/dist/version.d.ts | 2 +- packages/telemetry-core/dist/version.js | 2 +- packages/telemetry-core/package.json | 2 +- packages/telemetry-core/src/index.ts | 109 +++++- .../src/sanitized-script-error.test.ts | 365 ++++++++++++++++++ .../src/sanitized-script-error.ts | 111 ++++++ packages/telemetry-core/src/version.ts | 2 +- 15 files changed, 813 insertions(+), 46 deletions(-) create mode 100644 packages/telemetry-core/dist/sanitized-script-error.d.ts create mode 100644 packages/telemetry-core/dist/sanitized-script-error.d.ts.map create mode 100644 packages/telemetry-core/dist/sanitized-script-error.js create mode 100644 packages/telemetry-core/src/sanitized-script-error.test.ts create mode 100644 packages/telemetry-core/src/sanitized-script-error.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d91fef..46214520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve ### Added +- **`@telemetry-tracker/core` 1.5.1** — sanitized browser `Script error.` handling: no fabricated SDK stacks, bounded per-window dedupe, and `context.sanitized` / `browser_error` metadata so one quirky session cannot flood App Health +- **Dashboard** — error detail distinguishes sanitized browser Script errors from normal exceptions (badge + stack panel copy) + ### Fixed ### Changed diff --git a/apps/dashboard/app/dashboard/errors/[id]/page.tsx b/apps/dashboard/app/dashboard/errors/[id]/page.tsx index 4804f196..08d5e648 100644 --- a/apps/dashboard/app/dashboard/errors/[id]/page.tsx +++ b/apps/dashboard/app/dashboard/errors/[id]/page.tsx @@ -53,6 +53,36 @@ type ErrorGroup = { occurrences_list?: Occurrence[]; }; + +function isScriptErrorMessage(message: string | null | undefined): boolean { + const msg = message?.trim() ?? ""; + return msg === "Script error." || msg === "Script error"; +} + +/** + * Sanitized cross-origin browser errors (or legacy groups that look like them). + * Prefer explicit SDK context; fall back to message + missing/useless stack. + */ +function isSanitizedBrowserScriptErrorGroup(group: ErrorGroup): boolean { + if (!isScriptErrorMessage(group.message)) return false; + + for (const occ of group.occurrences_list ?? []) { + const ctx = occ.context; + if (ctx && typeof ctx === "object" && !Array.isArray(ctx)) { + const record = ctx as Record; + if (record.sanitized === true || record.browser_error === "sanitized_script_error") { + return true; + } + } + } + + const top = group.top_stack?.trim() ?? ""; + if (!top || top === "Error: Script error." || top === "Script error.") { + return true; + } + return false; +} + async function getErrorGroup( rawId: string, scope: { @@ -158,22 +188,29 @@ export default async function ErrorDetailPage({ } const resolved = Boolean(group.resolved_at); - const hasStackTrace = Boolean(group.symbolicated_top_stack || group.top_stack); + const sanitizedScriptError = isSanitizedBrowserScriptErrorGroup(group); + const hasStackTrace = + !sanitizedScriptError && + Boolean(group.symbolicated_top_stack || group.top_stack); - const stackTrace = - group.symbolicated_top_stack ? ( - - ) : group.top_stack ? ( - - ) : ( - - ); + const stackTrace = sanitizedScriptError ? ( + + ) : group.symbolicated_top_stack ? ( + + ) : group.top_stack ? ( + + ) : ( + + ); const occurrences = group.occurrences_list?.length ? (
    @@ -292,6 +329,7 @@ export default async function ErrorDetailPage({ {group.environment ? {group.environment} : null} {group.platform ? {group.platform} : null} {group.release ? {group.release} : null} + {sanitizedScriptError ? Sanitized browser error : null} {resolved ? : null} } diff --git a/packages/telemetry-core/dist/index.d.ts b/packages/telemetry-core/dist/index.d.ts index 7e35c763..bc422014 100644 --- a/packages/telemetry-core/dist/index.d.ts +++ b/packages/telemetry-core/dist/index.d.ts @@ -3,6 +3,7 @@ export { SDK_VERSION }; export { toReportableError } from "./to-reportable-error.js"; export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js"; export { WEB_VITAL_EVENT_NAME, installWebVitals, rateWebVital, buildWebVitalProperties, setWebVitalsCaptureEnabled, isWebVitalsCaptureEnabled, type WebVitalEventProperties, type WebVitalMetricName, type WebVitalRating, } from "./web-vitals.js"; +export { SANITIZED_GLOBAL_ERROR_DEDUPE_WINDOW_MS, SANITIZED_GLOBAL_ERROR_MAX_PER_WINDOW, isSanitizedBrowserScriptError, sanitizedGlobalErrorDedupeKey, shouldReportSanitizedGlobalError, clearSanitizedGlobalErrorDedupe, createSanitizedGlobalErrorDedupeStore, buildSanitizedScriptErrorContext, } from "./sanitized-script-error.js"; export declare function getAnonymousId(): string; export type TelemetryPiiScrubConfig = boolean | { /** Extra property/context keys to redact (case-insensitive). */ diff --git a/packages/telemetry-core/dist/index.d.ts.map b/packages/telemetry-core/dist/index.d.ts.map index a1e66d4d..f2ac2911 100644 --- a/packages/telemetry-core/dist/index.d.ts.map +++ b/packages/telemetry-core/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAG3C,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,uBAAuB,EACvB,0BAA0B,EAC1B,yBAAyB,EACzB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AAyCzB,wBAAgB,cAAc,IAAI,MAAM,CAkBvC;AAED,MAAM,MAAM,uBAAuB,GAC/B,OAAO,GACP;IACE,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB,CAAC;AAEN,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,uBAAuB,CAAC;CACpC,CAAC;AA0HF,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED,kEAAkE;AAClE,wBAAgB,UAAU,IAAI,IAAI,CAOjC;AA4CD,wBAAgB,IAAI,CAAC,CAAC,EAAE,eAAe,GAAG,IAAI,CAwB7C;AAED;;;GAGG;AACH,wBAAgB,QAAQ,IAAI,IAAI,CAS/B;AAQD,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,IAAI,CASzE;AAYD,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAO/F;AA6ED,iBAAS,qBAAqB,CAC5B,GAAG,EAAE,eAAe,GAAG,IAAI,GAC1B;IAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAahC;AAED,mCAAmC;AACnC,OAAO,EAAE,qBAAqB,EAAE,CAAC;AAWjC,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,IAAI,CAsBN;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,IAAI,CAEN;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CA+Cf;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,SAAS,IAAI,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,eAAe,IAAI,eAAe,GAAG,IAAI,CAExD"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/telemetry-core/dist/index.js b/packages/telemetry-core/dist/index.js index 02f15ad8..bfd4d0c7 100644 --- a/packages/telemetry-core/dist/index.js +++ b/packages/telemetry-core/dist/index.js @@ -1,12 +1,14 @@ import { readDeviceContext } from "./device-context.js"; import { installWebVitals, setWebVitalsCaptureEnabled, WEB_VITAL_EVENT_NAME, } from "./web-vitals.js"; import { scrubPiiRecord, scrubPiiText } from "./pii-scrub.js"; +import { buildSanitizedScriptErrorContext, clearSanitizedGlobalErrorDedupe, createSanitizedGlobalErrorDedupeStore, isSanitizedBrowserScriptError, sanitizedGlobalErrorDedupeKey, shouldReportSanitizedGlobalError, } from "./sanitized-script-error.js"; import { SDK_VERSION } from "./version.js"; import { toReportableError } from "./to-reportable-error.js"; export { SDK_VERSION }; export { toReportableError } from "./to-reportable-error.js"; export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js"; export { WEB_VITAL_EVENT_NAME, installWebVitals, rateWebVital, buildWebVitalProperties, setWebVitalsCaptureEnabled, isWebVitalsCaptureEnabled, } from "./web-vitals.js"; +export { SANITIZED_GLOBAL_ERROR_DEDUPE_WINDOW_MS, SANITIZED_GLOBAL_ERROR_MAX_PER_WINDOW, isSanitizedBrowserScriptError, sanitizedGlobalErrorDedupeKey, shouldReportSanitizedGlobalError, clearSanitizedGlobalErrorDedupe, createSanitizedGlobalErrorDedupeStore, buildSanitizedScriptErrorContext, } from "./sanitized-script-error.js"; const ANON_STORAGE_KEY = "tacko_telemetry_anon_id"; /** In-flight ingest promises so a later fatal flush can await trackError(e); throw e. */ const inFlightIngest = new WeakMap(); @@ -69,6 +71,10 @@ let browserHandlersInstalled = false; let sessionLifecycleInstalled = false; let sessionId = null; let sessionStartedAt = null; +/** Rate-limits identical sanitized "Script error." reports within a time window. */ +let sanitizedGlobalErrorDedupe = createSanitizedGlobalErrorDedupeStore(); +/** Prevents window.onerror from re-entering while we report an error. */ +let reportingGlobalError = false; const DEFAULT_BATCH_INTERVAL = 5000; const DEFAULT_BATCH_SIZE = 10; const eventQueue = []; @@ -134,11 +140,13 @@ function closeSessionKeepalive(endedAt) { postSessionKeepalive(endedAt); sessionId = null; sessionStartedAt = null; + clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe); } function startSession() { const cfg = getConfigOrNull(); if (!cfg) return; + clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe); sessionId = generateUUID(); sessionStartedAt = new Date(); void postSession(cfg); @@ -182,6 +190,27 @@ export function endSession() { void postSession(cfg, ended); sessionId = null; sessionStartedAt = null; + clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe); +} +/** + * Report a browser-sanitized Script error without shipping a fabricated SDK stack. + * Rate-limited so one quirk cannot flood ingest. + */ +function reportSanitizedScriptError(message, filename, lineno, colno, nowMs = Date.now()) { + const key = sanitizedGlobalErrorDedupeKey(message, filename, lineno, colno); + if (!shouldReportSanitizedGlobalError(sanitizedGlobalErrorDedupe, key, nowMs)) { + return; + } + // Keep message via Error for ingestError/PII scrub, but clear stack so the + // handler's own frame is never persisted as the throw site. + const err = new Error(message); + try { + err.stack = undefined; + } + catch { + /* some engines freeze stack — still better than inventing frames in context */ + } + trackError(err, buildSanitizedScriptErrorContext(filename, lineno, colno)); } /** Only install in real browser environments; skip in React Native / Node even if `window` is polyfilled. */ function installBrowserErrorHandlers() { @@ -195,24 +224,51 @@ function installBrowserErrorHandlers() { const cfg = getConfigOrNull(); if (!cfg) return false; - const err = error && error instanceof Error - ? error - : new Error(typeof message === "string" ? message : String(message)); - trackError(err, { - source: "window.onerror", - filename: source, - lineno, - colno, - }); + if (reportingGlobalError) + return false; + reportingGlobalError = true; + try { + if (isSanitizedBrowserScriptError(message, source, lineno, colno, error)) { + const msg = typeof message === "string" ? message.trim() : "Script error."; + reportSanitizedScriptError(msg, source ?? "", lineno ?? 0, colno ?? 0); + return false; + } + const err = error && error instanceof Error + ? error + : new Error(typeof message === "string" ? message : String(message)); + trackError(err, { + source: "window.onerror", + filename: source, + lineno, + colno, + }); + } + catch (_) { + // Never let reporting throw back into the page or re-enter onerror. + } + finally { + reportingGlobalError = false; + } return false; // let other handlers run }; window.addEventListener("unhandledrejection", (event) => { const cfg = getConfigOrNull(); if (!cfg) return; - const reason = event.reason; - const err = reason instanceof Error ? reason : new Error(reason != null ? String(reason) : "Unhandled rejection"); - trackError(err, { source: "unhandledrejection" }); + if (reportingGlobalError) + return; + reportingGlobalError = true; + try { + const reason = event.reason; + const err = reason instanceof Error ? reason : new Error(reason != null ? String(reason) : "Unhandled rejection"); + trackError(err, { source: "unhandledrejection" }); + } + catch (_) { + // Swallow — do not rethrow into another global handler. + } + finally { + reportingGlobalError = false; + } }); } export function init(c) { @@ -253,6 +309,7 @@ export function shutdown() { } endSession(); config = null; + clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe); setWebVitalsCaptureEnabled(false); } function installBrowserWebVitals() { @@ -430,6 +487,10 @@ export function ingestError(error, context) { let message = err.message; let stack = err.stack; let scrubbedContext = context ?? undefined; + // Sanitized browser Script errors must never persist a synthetic handler stack. + if (scrubbedContext && scrubbedContext.sanitized === true) { + stack = undefined; + } const scrubOpts = resolveClientPiiScrub(cfg); if (scrubOpts) { message = scrubPiiText(message); diff --git a/packages/telemetry-core/dist/sanitized-script-error.d.ts b/packages/telemetry-core/dist/sanitized-script-error.d.ts new file mode 100644 index 00000000..a73f7e85 --- /dev/null +++ b/packages/telemetry-core/dist/sanitized-script-error.d.ts @@ -0,0 +1,36 @@ +/** + * Browser-sanitized cross-origin "Script error." handling. + * + * When a script from another origin throws, browsers often invoke window.onerror + * with message "Script error.", no Error object, empty filename, and line/col 0. + * Fabricating `new Error("Script error.")` in that handler produces a misleading + * stack that points at the SDK itself and defeats instance-based dedupe. + */ +/** Default rate-limit window for identical sanitized global errors. */ +export declare const SANITIZED_GLOBAL_ERROR_DEDUPE_WINDOW_MS = 60000; +/** Max reports of the same sanitized key inside one dedupe window. */ +export declare const SANITIZED_GLOBAL_ERROR_MAX_PER_WINDOW = 1; +export type SanitizedGlobalErrorDedupeEntry = { + windowStartMs: number; + count: number; +}; +export type SanitizedGlobalErrorDedupeStore = { + entries: Map; +}; +export declare function createSanitizedGlobalErrorDedupeStore(): SanitizedGlobalErrorDedupeStore; +/** + * Classic sanitized Script error: no Error object and wiped location metadata. + * Errors that include a real Error instance are never treated as sanitized. + */ +export declare function isSanitizedBrowserScriptError(message: string | Event, filename?: string | null, lineno?: number | null, colno?: number | null, error?: Error | null): boolean; +export declare function sanitizedGlobalErrorDedupeKey(message: string, filename: string, lineno: number, colno: number): string; +/** + * Returns true when this sanitized global error should be reported. + * Identical keys are rate-limited within `windowMs` (default 60s, max 1). + * After the window elapses, reporting is allowed again. + */ +export declare function shouldReportSanitizedGlobalError(store: SanitizedGlobalErrorDedupeStore, key: string, nowMs: number, windowMs?: number, maxPerWindow?: number): boolean; +export declare function clearSanitizedGlobalErrorDedupe(store: SanitizedGlobalErrorDedupeStore): void; +/** Context fields attached to sanitized Script error ingest payloads. */ +export declare function buildSanitizedScriptErrorContext(filename: string, lineno: number, colno: number): Record; +//# sourceMappingURL=sanitized-script-error.d.ts.map \ No newline at end of file diff --git a/packages/telemetry-core/dist/sanitized-script-error.d.ts.map b/packages/telemetry-core/dist/sanitized-script-error.d.ts.map new file mode 100644 index 00000000..48a2c7ce --- /dev/null +++ b/packages/telemetry-core/dist/sanitized-script-error.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"sanitized-script-error.d.ts","sourceRoot":"","sources":["../src/sanitized-script-error.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,uEAAuE;AACvE,eAAO,MAAM,uCAAuC,QAAS,CAAC;AAE9D,sEAAsE;AACtE,eAAO,MAAM,qCAAqC,IAAI,CAAC;AAIvD,MAAM,MAAM,+BAA+B,GAAG;IAC5C,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,+BAA+B,GAAG;IAC5C,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,+BAA+B,CAAC,CAAC;CACvD,CAAC;AAEF,wBAAgB,qCAAqC,IAAI,+BAA+B,CAEvF;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,MAAM,GAAG,KAAK,EACvB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,EACxB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,EACtB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,EACrB,KAAK,CAAC,EAAE,KAAK,GAAG,IAAI,GACnB,OAAO,CAeT;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GACZ,MAAM,CAER;AAED;;;;GAIG;AACH,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,+BAA+B,EACtC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,QAAQ,GAAE,MAAgD,EAC1D,YAAY,GAAE,MAA8C,GAC3D,OAAO,CAWT;AAED,wBAAgB,+BAA+B,CAC7C,KAAK,EAAE,+BAA+B,GACrC,IAAI,CAEN;AAED,yEAAyE;AACzE,wBAAgB,gCAAgC,CAC9C,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GACZ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB"} \ No newline at end of file diff --git a/packages/telemetry-core/dist/sanitized-script-error.js b/packages/telemetry-core/dist/sanitized-script-error.js new file mode 100644 index 00000000..09c170c0 --- /dev/null +++ b/packages/telemetry-core/dist/sanitized-script-error.js @@ -0,0 +1,70 @@ +/** + * Browser-sanitized cross-origin "Script error." handling. + * + * When a script from another origin throws, browsers often invoke window.onerror + * with message "Script error.", no Error object, empty filename, and line/col 0. + * Fabricating `new Error("Script error.")` in that handler produces a misleading + * stack that points at the SDK itself and defeats instance-based dedupe. + */ +/** Default rate-limit window for identical sanitized global errors. */ +export const SANITIZED_GLOBAL_ERROR_DEDUPE_WINDOW_MS = 60000; +/** Max reports of the same sanitized key inside one dedupe window. */ +export const SANITIZED_GLOBAL_ERROR_MAX_PER_WINDOW = 1; +const SCRIPT_ERROR_MESSAGES = new Set(["Script error.", "Script error"]); +export function createSanitizedGlobalErrorDedupeStore() { + return { entries: new Map() }; +} +/** + * Classic sanitized Script error: no Error object and wiped location metadata. + * Errors that include a real Error instance are never treated as sanitized. + */ +export function isSanitizedBrowserScriptError(message, filename, lineno, colno, error) { + if (error != null && error instanceof Error) { + return false; + } + if (typeof message !== "string") { + return false; + } + const trimmed = message.trim(); + if (!SCRIPT_ERROR_MESSAGES.has(trimmed)) { + return false; + } + const file = filename ?? ""; + const line = lineno ?? 0; + const col = colno ?? 0; + return file === "" && line === 0 && col === 0; +} +export function sanitizedGlobalErrorDedupeKey(message, filename, lineno, colno) { + return `${message}\0${filename}\0${lineno}\0${colno}`; +} +/** + * Returns true when this sanitized global error should be reported. + * Identical keys are rate-limited within `windowMs` (default 60s, max 1). + * After the window elapses, reporting is allowed again. + */ +export function shouldReportSanitizedGlobalError(store, key, nowMs, windowMs = SANITIZED_GLOBAL_ERROR_DEDUPE_WINDOW_MS, maxPerWindow = SANITIZED_GLOBAL_ERROR_MAX_PER_WINDOW) { + const entry = store.entries.get(key); + if (!entry || nowMs - entry.windowStartMs >= windowMs) { + store.entries.set(key, { windowStartMs: nowMs, count: 1 }); + return true; + } + if (entry.count < maxPerWindow) { + entry.count += 1; + return true; + } + return false; +} +export function clearSanitizedGlobalErrorDedupe(store) { + store.entries.clear(); +} +/** Context fields attached to sanitized Script error ingest payloads. */ +export function buildSanitizedScriptErrorContext(filename, lineno, colno) { + return { + source: "window.onerror", + filename, + lineno, + colno, + sanitized: true, + browser_error: "sanitized_script_error", + }; +} diff --git a/packages/telemetry-core/dist/version.d.ts b/packages/telemetry-core/dist/version.d.ts index 7b808bf3..8ebafda4 100644 --- a/packages/telemetry-core/dist/version.d.ts +++ b/packages/telemetry-core/dist/version.d.ts @@ -1,3 +1,3 @@ /** Injected at build time from package.json. Do not edit manually. */ -export declare const SDK_VERSION = "1.5.0"; +export declare const SDK_VERSION = "1.5.1"; //# sourceMappingURL=version.d.ts.map \ No newline at end of file diff --git a/packages/telemetry-core/dist/version.js b/packages/telemetry-core/dist/version.js index 73cde4c0..5fbd58bb 100644 --- a/packages/telemetry-core/dist/version.js +++ b/packages/telemetry-core/dist/version.js @@ -1,2 +1,2 @@ /** Injected at build time from package.json. Do not edit manually. */ -export const SDK_VERSION = "1.5.0"; +export const SDK_VERSION = "1.5.1"; diff --git a/packages/telemetry-core/package.json b/packages/telemetry-core/package.json index 30be960d..56da69a4 100644 --- a/packages/telemetry-core/package.json +++ b/packages/telemetry-core/package.json @@ -1,6 +1,6 @@ { "name": "@telemetry-tracker/core", - "version": "1.5.0", + "version": "1.5.1", "description": "Lightweight telemetry client: events, errors, sessions. Framework-agnostic core.", "license": "MIT", "repository": { diff --git a/packages/telemetry-core/src/index.ts b/packages/telemetry-core/src/index.ts index e2dbcd38..5b993499 100644 --- a/packages/telemetry-core/src/index.ts +++ b/packages/telemetry-core/src/index.ts @@ -6,6 +6,15 @@ import { type WebVitalEventProperties, } from "./web-vitals.js"; import { scrubPiiRecord, scrubPiiText } from "./pii-scrub.js"; +import { + buildSanitizedScriptErrorContext, + clearSanitizedGlobalErrorDedupe, + createSanitizedGlobalErrorDedupeStore, + isSanitizedBrowserScriptError, + sanitizedGlobalErrorDedupeKey, + shouldReportSanitizedGlobalError, + type SanitizedGlobalErrorDedupeStore, +} from "./sanitized-script-error.js"; import { SDK_VERSION } from "./version.js"; import { toReportableError } from "./to-reportable-error.js"; @@ -24,6 +33,16 @@ export { type WebVitalMetricName, type WebVitalRating, } from "./web-vitals.js"; +export { + SANITIZED_GLOBAL_ERROR_DEDUPE_WINDOW_MS, + SANITIZED_GLOBAL_ERROR_MAX_PER_WINDOW, + isSanitizedBrowserScriptError, + sanitizedGlobalErrorDedupeKey, + shouldReportSanitizedGlobalError, + clearSanitizedGlobalErrorDedupe, + createSanitizedGlobalErrorDedupeStore, + buildSanitizedScriptErrorContext, +} from "./sanitized-script-error.js"; const ANON_STORAGE_KEY = "tacko_telemetry_anon_id"; @@ -120,6 +139,11 @@ let browserHandlersInstalled = false; let sessionLifecycleInstalled = false; let sessionId: string | null = null; let sessionStartedAt: Date | null = null; +/** Rate-limits identical sanitized "Script error." reports within a time window. */ +let sanitizedGlobalErrorDedupe: SanitizedGlobalErrorDedupeStore = + createSanitizedGlobalErrorDedupeStore(); +/** Prevents window.onerror from re-entering while we report an error. */ +let reportingGlobalError = false; const DEFAULT_BATCH_INTERVAL = 5000; const DEFAULT_BATCH_SIZE = 10; @@ -193,11 +217,13 @@ function closeSessionKeepalive(endedAt: Date): void { postSessionKeepalive(endedAt); sessionId = null; sessionStartedAt = null; + clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe); } function startSession(): void { const cfg = getConfigOrNull(); if (!cfg) return; + clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe); sessionId = generateUUID(); sessionStartedAt = new Date(); void postSession(cfg); @@ -245,6 +271,33 @@ export function endSession(): void { void postSession(cfg, ended); sessionId = null; sessionStartedAt = null; + clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe); +} + +/** + * Report a browser-sanitized Script error without shipping a fabricated SDK stack. + * Rate-limited so one quirk cannot flood ingest. + */ +function reportSanitizedScriptError( + message: string, + filename: string, + lineno: number, + colno: number, + nowMs: number = Date.now() +): void { + const key = sanitizedGlobalErrorDedupeKey(message, filename, lineno, colno); + if (!shouldReportSanitizedGlobalError(sanitizedGlobalErrorDedupe, key, nowMs)) { + return; + } + // Keep message via Error for ingestError/PII scrub, but clear stack so the + // handler's own frame is never persisted as the throw site. + const err = new Error(message); + try { + err.stack = undefined; + } catch { + /* some engines freeze stack — still better than inventing frames in context */ + } + trackError(err, buildSanitizedScriptErrorContext(filename, lineno, colno)); } /** Only install in real browser environments; skip in React Native / Node even if `window` is polyfilled. */ @@ -266,26 +319,49 @@ function installBrowserErrorHandlers(): void { ): boolean => { const cfg = getConfigOrNull(); if (!cfg) return false; - const err = - error && error instanceof Error - ? error - : new Error(typeof message === "string" ? message : String(message)); - trackError(err, { - source: "window.onerror", - filename: source, - lineno, - colno, - }); + if (reportingGlobalError) return false; + + reportingGlobalError = true; + try { + if (isSanitizedBrowserScriptError(message, source, lineno, colno, error)) { + const msg = typeof message === "string" ? message.trim() : "Script error."; + reportSanitizedScriptError(msg, source ?? "", lineno ?? 0, colno ?? 0); + return false; + } + + const err = + error && error instanceof Error + ? error + : new Error(typeof message === "string" ? message : String(message)); + trackError(err, { + source: "window.onerror", + filename: source, + lineno, + colno, + }); + } catch (_) { + // Never let reporting throw back into the page or re-enter onerror. + } finally { + reportingGlobalError = false; + } return false; // let other handlers run }; window.addEventListener("unhandledrejection", (event: PromiseRejectionEvent): void => { const cfg = getConfigOrNull(); if (!cfg) return; - const reason = event.reason; - const err = - reason instanceof Error ? reason : new Error(reason != null ? String(reason) : "Unhandled rejection"); - trackError(err, { source: "unhandledrejection" }); + if (reportingGlobalError) return; + reportingGlobalError = true; + try { + const reason = event.reason; + const err = + reason instanceof Error ? reason : new Error(reason != null ? String(reason) : "Unhandled rejection"); + trackError(err, { source: "unhandledrejection" }); + } catch (_) { + // Swallow — do not rethrow into another global handler. + } finally { + reportingGlobalError = false; + } }); } @@ -327,6 +403,7 @@ export function shutdown(): void { } endSession(); config = null; + clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe); setWebVitalsCaptureEnabled(false); } @@ -530,6 +607,10 @@ export function ingestError( let message = err.message; let stack = err.stack; let scrubbedContext = context ?? undefined; + // Sanitized browser Script errors must never persist a synthetic handler stack. + if (scrubbedContext && scrubbedContext.sanitized === true) { + stack = undefined; + } const scrubOpts = resolveClientPiiScrub(cfg); if (scrubOpts) { message = scrubPiiText(message); diff --git a/packages/telemetry-core/src/sanitized-script-error.test.ts b/packages/telemetry-core/src/sanitized-script-error.test.ts new file mode 100644 index 00000000..a592c032 --- /dev/null +++ b/packages/telemetry-core/src/sanitized-script-error.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { + buildSanitizedScriptErrorContext, + clearSanitizedGlobalErrorDedupe, + createSanitizedGlobalErrorDedupeStore, + isSanitizedBrowserScriptError, + sanitizedGlobalErrorDedupeKey, + shouldReportSanitizedGlobalError, +} from "./sanitized-script-error.js"; + +describe("isSanitizedBrowserScriptError", () => { + it("detects classic sanitized Script error. (empty location, no Error)", () => { + expect( + isSanitizedBrowserScriptError("Script error.", "", 0, 0, undefined) + ).toBe(true); + expect(isSanitizedBrowserScriptError("Script error", "", 0, 0)).toBe(true); + }); + + it("rejects Script error. when a real Error object is present", () => { + expect( + isSanitizedBrowserScriptError( + "Script error.", + "", + 0, + 0, + new Error("Script error.") + ) + ).toBe(false); + }); + + it("rejects when filename/line/col indicate a real location", () => { + expect( + isSanitizedBrowserScriptError( + "Script error.", + "https://app.example/app.js", + 12, + 4 + ) + ).toBe(false); + expect(isSanitizedBrowserScriptError("Script error.", "", 10, 0)).toBe( + false + ); + }); + + it("rejects unrelated messages", () => { + expect(isSanitizedBrowserScriptError("TypeError: x", "", 0, 0)).toBe( + false + ); + expect(isSanitizedBrowserScriptError(new Event("error"), "", 0, 0)).toBe( + false + ); + }); +}); + +describe("shouldReportSanitizedGlobalError", () => { + it("allows the first report and suppresses identical ones in the window", () => { + const store = createSanitizedGlobalErrorDedupeStore(); + const key = sanitizedGlobalErrorDedupeKey("Script error.", "", 0, 0); + const t0 = 1_000_000; + const windowMs = 60_000; + + expect(shouldReportSanitizedGlobalError(store, key, t0, windowMs, 1)).toBe( + true + ); + // 212 identical fires inside the same window (Besedolov-style flood) → only first reports + let allowed = 0; + for (let i = 1; i < 212; i++) { + if ( + shouldReportSanitizedGlobalError( + store, + key, + t0 + i * 50, // stay well inside the 60s window + windowMs, + 1 + ) + ) { + allowed += 1; + } + } + expect(allowed).toBe(0); + }); + + it("allows reporting again after the dedupe window expires", () => { + const store = createSanitizedGlobalErrorDedupeStore(); + const key = sanitizedGlobalErrorDedupeKey("Script error.", "", 0, 0); + const windowMs = 60_000; + const t0 = 5_000_000; + + expect(shouldReportSanitizedGlobalError(store, key, t0, windowMs, 1)).toBe( + true + ); + expect( + shouldReportSanitizedGlobalError(store, key, t0 + windowMs - 1, windowMs, 1) + ).toBe(false); + expect( + shouldReportSanitizedGlobalError(store, key, t0 + windowMs, windowMs, 1) + ).toBe(true); + }); + + it("treats different keys independently", () => { + const store = createSanitizedGlobalErrorDedupeStore(); + const a = sanitizedGlobalErrorDedupeKey("Script error.", "", 0, 0); + // Not classic sanitized, but still a distinct key for the store + const b = sanitizedGlobalErrorDedupeKey("Script error.", "x.js", 1, 2); + const t0 = 9_000_000; + + expect(shouldReportSanitizedGlobalError(store, a, t0, 60_000, 1)).toBe( + true + ); + expect(shouldReportSanitizedGlobalError(store, b, t0, 60_000, 1)).toBe( + true + ); + expect(shouldReportSanitizedGlobalError(store, a, t0 + 10, 60_000, 1)).toBe( + false + ); + }); + + it("respects maxPerWindow > 1", () => { + const store = createSanitizedGlobalErrorDedupeStore(); + const key = sanitizedGlobalErrorDedupeKey("Script error.", "", 0, 0); + const t0 = 1000; + expect(shouldReportSanitizedGlobalError(store, key, t0, 60_000, 2)).toBe( + true + ); + expect(shouldReportSanitizedGlobalError(store, key, t0 + 1, 60_000, 2)).toBe( + true + ); + expect(shouldReportSanitizedGlobalError(store, key, t0 + 2, 60_000, 2)).toBe( + false + ); + }); + + it("clears state on reset", () => { + const store = createSanitizedGlobalErrorDedupeStore(); + const key = sanitizedGlobalErrorDedupeKey("Script error.", "", 0, 0); + const t0 = 1000; + expect(shouldReportSanitizedGlobalError(store, key, t0, 60_000, 1)).toBe( + true + ); + clearSanitizedGlobalErrorDedupe(store); + expect(shouldReportSanitizedGlobalError(store, key, t0 + 1, 60_000, 1)).toBe( + true + ); + }); +}); + +describe("buildSanitizedScriptErrorContext", () => { + it("preserves onerror metadata and marks the payload sanitized", () => { + expect(buildSanitizedScriptErrorContext("", 0, 0)).toEqual({ + source: "window.onerror", + filename: "", + lineno: 0, + colno: 0, + sanitized: true, + browser_error: "sanitized_script_error", + }); + }); +}); + +describe("window.onerror sanitized Script error integration", () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => "" }); + let onerrorHandler: + | (( + message: string | Event, + source?: string, + lineno?: number, + colno?: number, + error?: Error + ) => boolean) + | null = null; + const listeners = new Map>(); + + beforeEach(async () => { + vi.resetModules(); + fetchMock.mockClear(); + onerrorHandler = null; + listeners.clear(); + + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("localStorage", { + getItem: () => null, + setItem: () => {}, + }); + vi.stubGlobal("document", { + visibilityState: "visible" as DocumentVisibilityState, + addEventListener(type: string, listener: EventListener) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }, + }); + const windowStub: { + addEventListener: (type: string, listener: EventListener) => void; + onerror: + | (( + message: string | Event, + source?: string, + lineno?: number, + colno?: number, + error?: Error + ) => boolean) + | null; + } = { + addEventListener(type: string, listener: EventListener) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }, + onerror: null, + }; + Object.defineProperty(windowStub, "onerror", { + configurable: true, + get() { + return onerrorHandler; + }, + set(fn) { + onerrorHandler = fn; + }, + }); + vi.stubGlobal("window", windowStub); + vi.stubGlobal("setInterval", () => 0 as unknown as ReturnType); + + const { init } = await import("./index.js"); + init({ + ingestUrl: "http://localhost:3001", + app: "test-app", + apiKey: "tt_live_pub_secret", + batchInterval: 0, + environment: "test", + webVitals: false, + }); + }); + + afterEach(async () => { + const { shutdown } = await import("./index.js"); + shutdown(); + vi.unstubAllGlobals(); + }); + + async function flushMicrotasks(): Promise { + await new Promise((r) => setTimeout(r, 10)); + } + + function errorBodies(): Array<{ + message?: string; + stack?: string; + context?: Record; + }> { + return fetchMock.mock.calls + .filter(([url]) => String(url).includes("/ingest/error")) + .map(([, opts]) => JSON.parse(String((opts as RequestInit).body))); + } + + it("reports sanitized Script error. once without a fabricated stack (212-fire scenario)", async () => { + expect(onerrorHandler).toBeTypeOf("function"); + + for (let i = 0; i < 212; i++) { + onerrorHandler!("Script error.", "", 0, 0, undefined); + } + await flushMicrotasks(); + + const bodies = errorBodies(); + expect(bodies).toHaveLength(1); + expect(bodies[0]?.message).toBe("Script error."); + expect(bodies[0]?.stack).toBeUndefined(); + expect(bodies[0]?.context).toMatchObject({ + source: "window.onerror", + filename: "", + lineno: 0, + colno: 0, + sanitized: true, + browser_error: "sanitized_script_error", + }); + // Must not invent an Error stack pointing into the SDK handler + expect(bodies[0]?.stack ?? "").not.toMatch(/at /); + }); + + it("still reports legitimate repeated errors with real Error objects", async () => { + for (let i = 0; i < 5; i++) { + onerrorHandler!( + "TypeError: boom", + "https://app.example/app.js", + 10, + 4, + new Error("TypeError: boom") + ); + } + await flushMicrotasks(); + + const bodies = errorBodies(); + expect(bodies).toHaveLength(5); + for (const body of bodies) { + expect(body.message).toBe("TypeError: boom"); + expect(body.stack).toBeTruthy(); + expect(body.context).toMatchObject({ + source: "window.onerror", + filename: "https://app.example/app.js", + lineno: 10, + colno: 4, + }); + expect(body.context?.sanitized).toBeUndefined(); + } + }); + + it("does not treat Script error. with a real Error object as sanitized", async () => { + const err = new Error("Script error."); + onerrorHandler!("Script error.", "", 0, 0, err); + onerrorHandler!("Script error.", "", 0, 0, err); // same instance → instance dedupe + await flushMicrotasks(); + + const bodies = errorBodies(); + expect(bodies).toHaveLength(1); + expect(bodies[0]?.stack).toBeTruthy(); + expect(bodies[0]?.context?.sanitized).toBeUndefined(); + }); + + it("does not change unhandledrejection behavior", async () => { + const rejectionListeners = listeners.get("unhandledrejection"); + expect(rejectionListeners?.size).toBeGreaterThan(0); + const reason = new Error("promise failed"); + for (const listener of rejectionListeners!) { + listener({ reason } as PromiseRejectionEvent); + } + await flushMicrotasks(); + + const bodies = errorBodies(); + expect(bodies).toHaveLength(1); + expect(bodies[0]?.message).toBe("promise failed"); + expect(bodies[0]?.context).toEqual({ source: "unhandledrejection" }); + }); + + it("re-allows sanitized Script error. after shutdown/init (dedupe reset)", async () => { + onerrorHandler!("Script error.", "", 0, 0, undefined); + await flushMicrotasks(); + expect(errorBodies()).toHaveLength(1); + + const { shutdown, init } = await import("./index.js"); + shutdown(); + fetchMock.mockClear(); + + // Handlers stay installed (by design) but no-op without config; re-init + init({ + ingestUrl: "http://localhost:3001", + app: "test-app", + apiKey: "tt_live_pub_secret", + batchInterval: 0, + environment: "test", + webVitals: false, + }); + + onerrorHandler!("Script error.", "", 0, 0, undefined); + await flushMicrotasks(); + expect(errorBodies()).toHaveLength(1); + }); + + it("swallows reporting failures without recursive onerror storms", async () => { + fetchMock.mockImplementation(() => { + throw new Error("ingest blew up"); + }); + // If reporting re-entered onerror recursively, this would stack-overflow. + expect(() => { + for (let i = 0; i < 20; i++) { + onerrorHandler!("Script error.", "", 0, 0, undefined); + } + }).not.toThrow(); + }); +}); diff --git a/packages/telemetry-core/src/sanitized-script-error.ts b/packages/telemetry-core/src/sanitized-script-error.ts new file mode 100644 index 00000000..6255ba5e --- /dev/null +++ b/packages/telemetry-core/src/sanitized-script-error.ts @@ -0,0 +1,111 @@ +/** + * Browser-sanitized cross-origin "Script error." handling. + * + * When a script from another origin throws, browsers often invoke window.onerror + * with message "Script error.", no Error object, empty filename, and line/col 0. + * Fabricating `new Error("Script error.")` in that handler produces a misleading + * stack that points at the SDK itself and defeats instance-based dedupe. + */ + +/** Default rate-limit window for identical sanitized global errors. */ +export const SANITIZED_GLOBAL_ERROR_DEDUPE_WINDOW_MS = 60_000; + +/** Max reports of the same sanitized key inside one dedupe window. */ +export const SANITIZED_GLOBAL_ERROR_MAX_PER_WINDOW = 1; + +const SCRIPT_ERROR_MESSAGES = new Set(["Script error.", "Script error"]); + +export type SanitizedGlobalErrorDedupeEntry = { + windowStartMs: number; + count: number; +}; + +export type SanitizedGlobalErrorDedupeStore = { + entries: Map; +}; + +export function createSanitizedGlobalErrorDedupeStore(): SanitizedGlobalErrorDedupeStore { + return { entries: new Map() }; +} + +/** + * Classic sanitized Script error: no Error object and wiped location metadata. + * Errors that include a real Error instance are never treated as sanitized. + */ +export function isSanitizedBrowserScriptError( + message: string | Event, + filename?: string | null, + lineno?: number | null, + colno?: number | null, + error?: Error | null +): boolean { + if (error != null && error instanceof Error) { + return false; + } + if (typeof message !== "string") { + return false; + } + const trimmed = message.trim(); + if (!SCRIPT_ERROR_MESSAGES.has(trimmed)) { + return false; + } + const file = filename ?? ""; + const line = lineno ?? 0; + const col = colno ?? 0; + return file === "" && line === 0 && col === 0; +} + +export function sanitizedGlobalErrorDedupeKey( + message: string, + filename: string, + lineno: number, + colno: number +): string { + return `${message}\0${filename}\0${lineno}\0${colno}`; +} + +/** + * Returns true when this sanitized global error should be reported. + * Identical keys are rate-limited within `windowMs` (default 60s, max 1). + * After the window elapses, reporting is allowed again. + */ +export function shouldReportSanitizedGlobalError( + store: SanitizedGlobalErrorDedupeStore, + key: string, + nowMs: number, + windowMs: number = SANITIZED_GLOBAL_ERROR_DEDUPE_WINDOW_MS, + maxPerWindow: number = SANITIZED_GLOBAL_ERROR_MAX_PER_WINDOW +): boolean { + const entry = store.entries.get(key); + if (!entry || nowMs - entry.windowStartMs >= windowMs) { + store.entries.set(key, { windowStartMs: nowMs, count: 1 }); + return true; + } + if (entry.count < maxPerWindow) { + entry.count += 1; + return true; + } + return false; +} + +export function clearSanitizedGlobalErrorDedupe( + store: SanitizedGlobalErrorDedupeStore +): void { + store.entries.clear(); +} + +/** Context fields attached to sanitized Script error ingest payloads. */ +export function buildSanitizedScriptErrorContext( + filename: string, + lineno: number, + colno: number +): Record { + return { + source: "window.onerror", + filename, + lineno, + colno, + sanitized: true, + browser_error: "sanitized_script_error", + }; +} diff --git a/packages/telemetry-core/src/version.ts b/packages/telemetry-core/src/version.ts index 73cde4c0..5fbd58bb 100644 --- a/packages/telemetry-core/src/version.ts +++ b/packages/telemetry-core/src/version.ts @@ -1,2 +1,2 @@ /** Injected at build time from package.json. Do not edit manually. */ -export const SDK_VERSION = "1.5.0"; +export const SDK_VERSION = "1.5.1"; From 3fecc74686a0f244a89091f6e1ba811d872a7c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sanja=20Malovi=C4=87?= Date: Wed, 30 Sep 2026 13:24:08 +0200 Subject: [PATCH 2/2] fix(sdk): use const for sanitized Script error dedupe store Co-authored-by: Cursor --- packages/telemetry-core/dist/index.js | 2 +- packages/telemetry-core/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/telemetry-core/dist/index.js b/packages/telemetry-core/dist/index.js index bfd4d0c7..31b5e4b7 100644 --- a/packages/telemetry-core/dist/index.js +++ b/packages/telemetry-core/dist/index.js @@ -72,7 +72,7 @@ let sessionLifecycleInstalled = false; let sessionId = null; let sessionStartedAt = null; /** Rate-limits identical sanitized "Script error." reports within a time window. */ -let sanitizedGlobalErrorDedupe = createSanitizedGlobalErrorDedupeStore(); +const sanitizedGlobalErrorDedupe = createSanitizedGlobalErrorDedupeStore(); /** Prevents window.onerror from re-entering while we report an error. */ let reportingGlobalError = false; const DEFAULT_BATCH_INTERVAL = 5000; diff --git a/packages/telemetry-core/src/index.ts b/packages/telemetry-core/src/index.ts index 5b993499..7d2bc58b 100644 --- a/packages/telemetry-core/src/index.ts +++ b/packages/telemetry-core/src/index.ts @@ -140,7 +140,7 @@ let sessionLifecycleInstalled = false; let sessionId: string | null = null; let sessionStartedAt: Date | null = null; /** Rate-limits identical sanitized "Script error." reports within a time window. */ -let sanitizedGlobalErrorDedupe: SanitizedGlobalErrorDedupeStore = +const sanitizedGlobalErrorDedupe: SanitizedGlobalErrorDedupeStore = createSanitizedGlobalErrorDedupeStore(); /** Prevents window.onerror from re-entering while we report an error. */ let reportingGlobalError = false;