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
25 changes: 22 additions & 3 deletions apps/dashboard/app/error-tracking/nextjs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import { marketingSiteOrigin } from "@/lib/marketing-json-ld";
import {
nextDocsCheckButton,
nextEnvLocal,
nextEnvLocalServer,
nextErrorBoundary,
nextInstall,
nextInstrumentation,
nextProviderSetup,
nextTestError,
nextTrackPageView,
Expand All @@ -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({
Expand Down Expand Up @@ -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",
Expand All @@ -80,7 +82,8 @@ export default function NextJsErrorTrackingPage() {
<code>@telemetry-tracker/next</code> wraps the core SDK for App Router apps: a provider
that calls <code>init()</code>, an error boundary for React render errors, and{" "}
<code>useTrackPage</code> for route changes. Uncaught browser errors and unhandled promise
rejections are reported after init.
rejections are reported after init. Optionally, <code>createOnRequestError</code> captures
server-side App Router errors.
</p>
}
>
Expand Down Expand Up @@ -143,6 +146,22 @@ export default function NextJsErrorTrackingPage() {
<CodeBlock code={nextTestError} lang="typescript" caption="trackError call" />
<CodeBlock code={nextErrorBoundary} lang="tsx" caption="Error boundary" />

<h2>Server errors (optional)</h2>
<p>
Skip this section for browser-only setup.{" "}
<code>@telemetry-tracker/next/server</code> (published with{" "}
<code>@telemetry-tracker/next@1.3.2</code>) exports <code>createOnRequestError</code> for{" "}
<code>instrumentation.ts</code>. Next.js calls it for uncaught App Router errors in Server
Components, Route Handlers, and Server Actions. Use <code>TELEMETRY_API_KEY</code> (server-only) —
do not expose a server-only secret via <code>NEXT_PUBLIC_*</code>. See the{" "}
<Link href="/docs/nextjs" className="text-brand hover:underline">
full Next.js docs
</Link>{" "}
for more details.
</p>
<CodeBlock code={nextEnvLocalServer} lang="bash" caption=".env.local (optional server)" />
<CodeBlock code={nextInstrumentation} lang="ts" caption="instrumentation.ts (optional)" />

<h2>What you will see</h2>
<p>
Open <strong>Issues</strong> in the dashboard. Matching stack traces are grouped into one
Expand Down
6 changes: 3 additions & 3 deletions apps/dashboard/app/sentry-alternative/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ export default function SentryAlternativePage() {

<h2>Supported SDKs</h2>
<p>
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.
</p>
<ul>
<li>
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
Loading
Loading