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.
  }
}