jacquardSnapshot

← snapshot

2725 bytes
"use client";

import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";

/**
 * Frontend crashes, relayed to s10.
 *
 * The browser cannot ship these itself — the ingest key never reaches client
 * code — so the page hands each failure to jac-serve, which emits it under the
 * same tenant as the request that caused it. That is the whole point: a Lovelace
 * crash and the `http.server.request` span behind it land on one timeline
 * instead of in two places nobody correlates.
 *
 * What is sent: the error's own message and stack, the Next route, and how it
 * surfaced. Those name code paths. Never repository content, never a file a
 * visitor was reading, never anything a person typed.
 */

/** The same failure repeated is one bug, not four hundred. */
const SEEN_CAP = 40;

export function BugReporter() {
  const pathname = usePathname() || "/";
  const here = useRef(pathname);
  here.current = pathname;

  useEffect(() => {
    const seen = new Set<string>();

    const report = (
      message: string,
      stack: string | undefined,
      kind: "error" | "unhandledrejection",
    ) => {
      const trimmed = message.trim();
      if (!trimmed) return;
      // Dedupe on message + first stack frame: a render loop can throw the
      // same error every frame, and reporting each one would drown the rest.
      const key = `${trimmed}|${(stack ?? "").split("\n")[1] ?? ""}`;
      if (seen.has(key)) return;
      if (seen.size < SEEN_CAP) seen.add(key);

      void fetch("/api/telemetry/bug", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          message: trimmed,
          stack,
          pathname: here.current,
          kind,
        }),
        // Survives the page being torn down by the same failure.
        keepalive: true,
      }).catch(() => {
        /* reporting a bug must never itself become one */
      });
    };

    const onError = (e: ErrorEvent) => {
      report(e.message || String(e.error), e.error?.stack, "error");
    };
    const onRejection = (e: PromiseRejectionEvent) => {
      const reason = e.reason as { message?: string; stack?: string } | string;
      const message =
        typeof reason === "string" ? reason : (reason?.message ?? "unhandled rejection");
      report(message, typeof reason === "string" ? undefined : reason?.stack, "unhandledrejection");
    };

    window.addEventListener("error", onError);
    window.addEventListener("unhandledrejection", onRejection);
    return () => {
      window.removeEventListener("error", onError);
      window.removeEventListener("unhandledrejection", onRejection);
    };
  }, []);

  return null;
}