File size: 2,760 Bytes
65d68f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | // Forwards unhandled UI errors to the backend so they land in the same
// error-tracking pane as backend errors. Fire-and-forget: never throws, never
// blocks render. No-op on the server.
import { track } from "@vercel/analytics";
import { getApiBaseUrl } from "@/lib/api/client";
const SENSITIVE_ASSIGNMENT = /\b(authorization|cookie|password|token|secret|api[_-]?key|service[_-]?role)\b(\s*[:=]\s*)([^\s,;]+)/gi;
const BEARER_VALUE = /\bbearer\s+[a-z0-9._~+/=-]+/gi;
export function sanitizeClientErrorText(value: unknown, limit: number): string {
return String(value || "Unknown client error")
.replace(BEARER_VALUE, "Bearer [REDACTED]")
.replace(SENSITIVE_ASSIGNMENT, (_match, key: string, separator: string) => `${key}${separator}[REDACTED]`)
.slice(0, limit);
}
export function reportClientError(
error: unknown,
context: string,
extra?: { digest?: string },
): void {
if (typeof window === "undefined") return;
try {
const err = error as { message?: string; stack?: string } | undefined;
const body = JSON.stringify({
message: sanitizeClientErrorText(err?.message || error, 2000),
stack: sanitizeClientErrorText(err?.stack || "", 8000),
url: `${window.location.origin}${window.location.pathname}`,
digest: extra?.digest,
user_agent: navigator.userAgent,
context,
});
// A relative fallback keeps same-origin deployments observable. If the
// API is intentionally hosted elsewhere, configure the public base URL;
// never send an error to an accidental empty/undefined host.
const url = `${getApiBaseUrl()}/telemetry/client-error`;
// sendBeacon survives page unload; fall back to fetch keepalive.
if (navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
} else {
void fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {});
}
} catch {
// swallow — telemetry must never break the error UI
}
}
/** Privacy-safe funnel telemetry for non-payment product actions. */
export function trackClientEvent(
name: string,
properties?: Record<string, string | number | boolean>,
): void {
if (typeof window === "undefined") return;
try {
const safeName = name.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
const safeProperties = Object.fromEntries(
Object.entries(properties ?? {}).filter(
([key, value]) =>
/^[a-zA-Z0-9_.-]{1,40}$/.test(key) &&
(typeof value === "string" ? value.length <= 120 : true),
),
);
track(safeName, safeProperties);
} catch {
// Analytics must never affect the student workflow.
}
}
|