// 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, ): 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. } }