@@ -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/apps/dashboard/app/error-tracking/nextjs/page.tsx b/apps/dashboard/app/error-tracking/nextjs/page.tsx
index 8aeda355..f5e58f0f 100644
--- a/apps/dashboard/app/error-tracking/nextjs/page.tsx
+++ b/apps/dashboard/app/error-tracking/nextjs/page.tsx
@@ -14,8 +14,10 @@ import { marketingSiteOrigin } from "@/lib/marketing-json-ld";
import {
nextDocsCheckButton,
nextEnvLocal,
+ nextEnvLocalServer,
nextErrorBoundary,
nextInstall,
+ nextInstrumentation,
nextProviderSetup,
nextTestError,
nextTrackPageView,
@@ -24,7 +26,7 @@ import {
const PATH = "/error-tracking/nextjs";
const TITLE = "Next.js Error Tracking";
const DESCRIPTION =
- "Install @telemetry-tracker/next, wrap your app with TelemetryProvider, and see grouped Next.js errors in the dashboard. Free plan, no credit card.";
+ "Install @telemetry-tracker/next, wrap your app with TelemetryProvider, and optionally capture server errors with instrumentation.ts. Free plan, no credit card.";
export function generateMetadata() {
return marketingGuideMetadata({
@@ -62,7 +64,7 @@ export default function NextJsErrorTrackingPage() {
},
{
name: "Wrap the app with TelemetryProvider",
- text: "Set NEXT_PUBLIC_TELEMETRY_INGEST_URL to https://api.telemetry-tracker.com, NEXT_PUBLIC_TELEMETRY_API_KEY from Settings → API keys, and use the server layout plus client TrackPageView from the docs.",
+ text: "Set NEXT_PUBLIC_TELEMETRY_INGEST_URL to https://api.telemetry-tracker.com, NEXT_PUBLIC_TELEMETRY_API_KEY from Settings → API keys, and use the server layout plus client TrackPageView from the docs. Optionally add instrumentation.ts for server-side error capture.",
},
{
name: "Send a test error",
@@ -80,7 +82,8 @@ export default function NextJsErrorTrackingPage() {
@telemetry-tracker/next wraps the core SDK for App Router apps: a provider
that calls init(), an error boundary for React render errors, and{" "}
useTrackPage for route changes. Uncaught browser errors and unhandled promise
- rejections are reported after init.
+ rejections are reported after init. Optionally, createOnRequestError captures
+ server-side App Router errors.
+ Skip this section for browser-only setup.{" "}
+ @telemetry-tracker/next/server (published with{" "}
+ @telemetry-tracker/next@1.3.2) exports createOnRequestError for{" "}
+ instrumentation.ts. Next.js calls it for uncaught App Router errors in Server
+ Components, Route Handlers, and Server Actions. Use TELEMETRY_API_KEY (server-only) —
+ do not expose a server-only secret via NEXT_PUBLIC_*. See the{" "}
+
+ full Next.js docs
+ {" "}
+ for more details.
+
+
+
+
What you will see
Open Issues in the dashboard. Matching stack traces are grouped into one
diff --git a/apps/dashboard/app/sentry-alternative/page.tsx b/apps/dashboard/app/sentry-alternative/page.tsx
index 557350da..ede8ed53 100644
--- a/apps/dashboard/app/sentry-alternative/page.tsx
+++ b/apps/dashboard/app/sentry-alternative/page.tsx
@@ -94,9 +94,9 @@ export default function SentryAlternativePage() {
Supported SDKs
- First-class packages today: Next.js, React (core), Node.js, NestJS (via the Node
- package), Vue and Nuxt (core), and React Native. There is no Python, Go, PHP, Ruby, or
- native iOS/Android SDK.
+ First-class packages today: Next.js (client-side and App Router server errors when
+ configured), React (core), Node.js, NestJS (via the Node package), Vue and Nuxt (core),
+ and React Native. There is no Python, Go, PHP, Ruby, or native iOS/Android SDK.
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..31b5e4b7 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. */
+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 = [];
@@ -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..7d2bc58b 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. */
+const 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";