Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 53 additions & 15 deletions apps/dashboard/app/dashboard/errors/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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: {
Expand Down Expand Up @@ -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 ? (
<StackTraceView
source={group.symbolicated_top_stack}
title="Top frame (symbolicated, newest occurrence)"
/>
) : group.top_stack ? (
<StackTraceView source={group.top_stack} title="Top stack (group)" />
) : (
<EmptyState
title="No stack trace"
message="This error group has no stack trace on record."
/>
);
const stackTrace = sanitizedScriptError ? (
<EmptyState
title="Sanitized browser Script error"
message="The browser hid the real throw site (cross-origin or extension). This is not an application stack frame — filename/line/column are empty, and any older stack pointing at the Telemetry SDK is from synthetic Error construction, not the underlying bug."
/>
) : group.symbolicated_top_stack ? (
<StackTraceView
source={group.symbolicated_top_stack}
title="Top frame (symbolicated, newest occurrence)"
/>
) : group.top_stack ? (
<StackTraceView source={group.top_stack} title="Top stack (group)" />
) : (
<EmptyState
title="No stack trace"
message="This error group has no stack trace on record."
/>
);

const occurrences = group.occurrences_list?.length ? (
<ul className="space-y-3">
Expand Down Expand Up @@ -292,6 +329,7 @@ export default async function ErrorDetailPage({
{group.environment ? <Badge>{group.environment}</Badge> : null}
{group.platform ? <Badge>{group.platform}</Badge> : null}
{group.release ? <Badge>{group.release}</Badge> : null}
{sanitizedScriptError ? <Badge>Sanitized browser error</Badge> : null}
{resolved ? <ResolvedBadge /> : null}
</>
}
Expand Down
1 change: 1 addition & 0 deletions packages/telemetry-core/dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
2 changes: 1 addition & 1 deletion packages/telemetry-core/dist/index.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 73 additions & 12 deletions packages/telemetry-core/dist/index.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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. */
const 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 = [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand All @@ -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) {
Expand Down Expand Up @@ -253,6 +309,7 @@ export function shutdown() {
}
endSession();
config = null;
clearSanitizedGlobalErrorDedupe(sanitizedGlobalErrorDedupe);
setWebVitalsCaptureEnabled(false);
}
function installBrowserWebVitals() {
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions packages/telemetry-core/dist/sanitized-script-error.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, SanitizedGlobalErrorDedupeEntry>;
};
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<string, unknown>;
//# sourceMappingURL=sanitized-script-error.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading