{"version":3,"sources":["../src/modules/error-tracking.ts"],"sourcesContent":["/**\n * Always-on debugger module. Hooks `window.error` and `unhandledrejection`,\n * then ships each fresh runtime error as a synthetic FeedbackReport via\n * the existing widget submit pipeline.\n *\n * Same opt-in pattern as `withReplay` / `withWebVitals` — host apps that\n * want curated-feedback-only simply don't import it.\n *\n * Design choices (see PR description):\n *  - Per-fingerprint cooldown (default 5 min) absorbs render loops without\n *    flooding the operator dashboard or burning the per-key write throttle.\n *  - Server-side fingerprinting groups across sessions; the client-side\n *    fingerprint exists only to dedup within a single page lifetime, so\n *    it doesn't need to match the server's hash.\n *  - Recursion guard: an error thrown *inside* the submit pipeline must\n *    not re-enter the handler.\n *  - Description is the error message truncated at 200 chars — the full\n *    stack lives in technical_context.errors[].\n *  - No screenshot is taken (buildAndSubmit honors `synthetic: true`):\n *    html2canvas is slow + the DOM is unreliable mid-error.\n */\n\nimport type { FeedbackApi } from '../types'\n\nexport interface ErrorTrackingOptions {\n  /** Opt-out kill switch without removing the import. Default `true`. */\n  enabled?: boolean\n  /**\n   * Drop repeats of the same fingerprint within this window.\n   * Default 300_000 ms (5 minutes). Set to 0 to disable dedup\n   * (every fired error becomes a report — useful only for tests).\n   */\n  perFingerprintCooldownMs?: number\n  /**\n   * Probabilistic head sampling: 1.0 captures everything, 0.5 drops half.\n   * Default 1.0. Sampled-out errors don't count toward the cooldown — if\n   * you sample at 0.1, you'll still see ~10% of the volume per fingerprint.\n   */\n  sampleRate?: number\n  /** Max characters in the auto-generated description. Default 200. */\n  maxDescriptionLen?: number\n  /**\n   * Global ceiling on synthetic submissions per minute, regardless of\n   * fingerprint. Defends against a hostile / buggy page that throws\n   * errors with attacker-varying `message` strings (counter, nonce) —\n   * each unique message creates a fresh fingerprint, bypassing the\n   * per-fingerprint cooldown, and would otherwise burn the project's\n   * backend write quota.\n   * Default 30. Set to 0 to disable the global cap (not recommended).\n   */\n  globalRatePerMinute?: number\n  /**\n   * Max number of distinct fingerprints emitted per page lifetime. Once\n   * exceeded, NEW fingerprints are dropped (the cooldown still applies\n   * to already-seen ones, so legitimate repeating errors keep cycling).\n   * Defense against a page that synthesizes an unbounded number of\n   * unique error shapes.\n   * Default 200. Set to 0 to disable.\n   */\n  maxDistinctFingerprints?: number\n}\n\ninterface InternalApi extends FeedbackApi {\n  _registerTransformer?: (fn: unknown) => void\n  /** Set once withErrorTracking has armed this instance (idempotency guard). */\n  _errorTrackingArmed?: boolean\n}\n\ninterface NormalizedError {\n  name: string\n  message: string\n  stack?: string\n}\n\nconst DEFAULTS = {\n  enabled: true,\n  perFingerprintCooldownMs: 5 * 60 * 1000,\n  sampleRate: 1,\n  maxDescriptionLen: 200,\n  globalRatePerMinute: 30,\n  maxDistinctFingerprints: 200,\n} as const satisfies Required<ErrorTrackingOptions>\n\n/**\n * Pull a normalized {name, message, stack} from whatever the runtime\n * handed us. Browsers throw genuine Error subclasses for uncaught\n * exceptions but `unhandledrejection.reason` is sometimes a string,\n * a plain object, or undefined.\n */\nfunction normalize(thrown: unknown): NormalizedError {\n  if (thrown instanceof Error) {\n    return {\n      name: thrown.name || 'Error',\n      message: String(thrown.message ?? ''),\n      ...(thrown.stack && { stack: String(thrown.stack) }),\n    }\n  }\n  if (typeof thrown === 'string') return { name: 'Error', message: thrown }\n  if (thrown && typeof thrown === 'object') {\n    const o = thrown as Record<string, unknown>\n    return {\n      name: (typeof o.name === 'string' && o.name) || 'Error',\n      message:\n        (typeof o.message === 'string' && o.message) ||\n        (() => {\n          try { return JSON.stringify(thrown) } catch { return String(thrown) }\n        })(),\n      ...(typeof o.stack === 'string' && { stack: o.stack }),\n    }\n  }\n  return { name: 'Error', message: String(thrown) }\n}\n\n/**\n * Stable per-session signature. Server still computes its own fingerprint\n * across sessions — this only needs to dedup within one page lifetime.\n * Stack frames are the strongest discriminator; pathname keeps two distinct\n * routes from collapsing onto the same key.\n *\n * SECURITY: we include only a 30-char message prefix, not the full message.\n * A hostile or buggy page that throws errors with counter-suffixed messages\n * (e.g. `Error 1`, `Error 2`, …) would otherwise produce a fresh fingerprint\n * per throw and bypass the per-fingerprint cooldown. The stack frames are\n * the same across those counter variants, so collapsing the message into a\n * prefix preserves real-error distinction while killing the counter bypass.\n */\nfunction clientFingerprint(err: NormalizedError, pathname: string): string {\n  const firstFrames = (err.stack ?? '').split('\\n').slice(0, 4).join('\\n')\n  const messagePrefix = err.message.slice(0, 30)\n  return `${err.name}:${messagePrefix}|${pathname}|${firstFrames}`\n}\n\nfunction truncate(s: string, max: number): string {\n  if (s.length <= max) return s\n  return s.slice(0, Math.max(0, max - 1)) + '…'\n}\n\nexport function withErrorTracking(\n  fb: FeedbackApi,\n  options: ErrorTrackingOptions = {},\n): FeedbackApi {\n  const opts = { ...DEFAULTS, ...options }\n  if (!opts.enabled) return fb\n  if (typeof window === 'undefined') return fb\n\n  const internal = fb as InternalApi\n\n  // Idempotency guard. The same instance can now be armed from two places:\n  // the host-side <FeedbackProvider> (opt-out default) AND the runtime\n  // widget bundle (which self-arms on the loader path). A client who both\n  // upgrades the provider and receives the self-arming bundle would\n  // otherwise double-register the window listeners and file every error\n  // twice. First arm wins; later calls are no-ops.\n  if (internal._errorTrackingArmed) return fb\n  internal._errorTrackingArmed = true\n\n  // Recursion guard, scoped per-fingerprint: while we're posting a synthetic\n  // report for fingerprint X, a re-entry for the SAME X (e.g. an error\n  // raised by the submit pipeline itself) is dropped. Distinct errors that\n  // fire concurrently still both go through.\n  const inFlight = new Set<string>()\n\n  // fingerprint -> last-sent epoch ms. Periodically pruned in shouldSend().\n  const lastSent = new Map<string, number>()\n  // Sliding-window of recent send timestamps for the global rate cap.\n  // Trimmed in shouldSend(); never grows beyond globalRatePerMinute entries.\n  const recentSendTimes: number[] = []\n\n  function shouldSend(fp: string, now: number): boolean {\n    // (1) Global rate ceiling — runs first because it doesn't depend on\n    //     fingerprint state. Defends against the \"counter-suffixed error\n    //     messages\" attack that would otherwise produce one unique\n    //     fingerprint per throw.\n    if (opts.globalRatePerMinute > 0) {\n      const windowStart = now - 60_000\n      // Drop expired entries from the front of the sliding window.\n      while (recentSendTimes.length > 0 && recentSendTimes[0]! < windowStart) {\n        recentSendTimes.shift()\n      }\n      if (recentSendTimes.length >= opts.globalRatePerMinute) {\n        return false\n      }\n    }\n    // (2) Per-page cap on the number of distinct fingerprints. A hostile\n    //     page that synthesizes an unbounded number of unique error\n    //     shapes runs out of new slots after this many — already-seen\n    //     fingerprints keep cycling under the per-fp cooldown.\n    if (\n      opts.maxDistinctFingerprints > 0 &&\n      !lastSent.has(fp) &&\n      lastSent.size >= opts.maxDistinctFingerprints\n    ) {\n      return false\n    }\n    if (opts.perFingerprintCooldownMs <= 0) {\n      recentSendTimes.push(now)\n      return true\n    }\n    const prev = lastSent.get(fp)\n    if (prev !== undefined && now - prev < opts.perFingerprintCooldownMs) {\n      return false\n    }\n    // Cheap stale-entry sweep so the Map doesn't grow unboundedly on a\n    // long-lived session with many distinct errors. First pass: drop\n    // entries past `cooldown * 2`. If we're still above a hard cap of\n    // 256 — i.e. an SPA that's been live for hours and keeps emitting\n    // *fresh* fingerprints faster than they go stale — drop the oldest\n    // entries until we're back to a comfortable ceiling. Map iteration\n    // order is insertion order so the first N keys are the oldest.\n    const HARD_CAP = 256\n    const TARGET_AFTER_TRIM = 192\n    if (lastSent.size > HARD_CAP) {\n      const cutoff = now - opts.perFingerprintCooldownMs * 2\n      for (const [k, v] of lastSent) {\n        if (v < cutoff) lastSent.delete(k)\n      }\n      if (lastSent.size > HARD_CAP) {\n        const toDrop = lastSent.size - TARGET_AFTER_TRIM\n        let dropped = 0\n        for (const k of lastSent.keys()) {\n          if (dropped >= toDrop) break\n          lastSent.delete(k)\n          dropped++\n        }\n      }\n    }\n    recentSendTimes.push(now)\n    return true\n  }\n\n  async function report(err: NormalizedError) {\n    if (opts.sampleRate < 1 && Math.random() >= opts.sampleRate) return\n\n    const fp = clientFingerprint(err, window.location.pathname)\n    if (inFlight.has(fp)) return\n    const now = Date.now()\n    if (!shouldSend(fp, now)) return\n    lastSent.set(fp, now)\n\n    const description = truncate(\n      err.message ? `${err.name}: ${err.message}` : err.name,\n      opts.maxDescriptionLen,\n    )\n\n    inFlight.add(fp)\n    try {\n      await internal.submit({\n        description,\n        feedback_type: 'bug',\n        severity: 'high',\n        synthetic: true,\n      } as Parameters<FeedbackApi['submit']>[0] & { synthetic: boolean })\n    } catch {\n      // Swallow submit errors — propagating them would just trigger another\n      // unhandledrejection and feed the loop. The host's `onError` callback\n      // (configured on createFeedback) still fires for visibility.\n    } finally {\n      inFlight.delete(fp)\n    }\n  }\n\n  function onError(event: ErrorEvent) {\n    const candidate: unknown = event.error ?? {\n      name: 'Error',\n      message: event.message,\n      stack: `at ${event.filename}:${event.lineno}:${event.colno}`,\n    }\n    void report(normalize(candidate))\n  }\n\n  function onUnhandledRejection(event: PromiseRejectionEvent) {\n    void report(normalize(event.reason))\n  }\n\n  window.addEventListener('error', onError)\n  window.addEventListener('unhandledrejection', onUnhandledRejection)\n\n  // Wrap shutdown so listeners come down with the widget. Without this, a\n  // host that re-mounts the widget under React StrictMode would double the\n  // listeners and emit each error twice.\n  const originalShutdown = internal.shutdown.bind(internal)\n  internal.shutdown = () => {\n    window.removeEventListener('error', onError)\n    window.removeEventListener('unhandledrejection', onUnhandledRejection)\n    lastSent.clear()\n    originalShutdown()\n  }\n\n  return fb\n}\n"],"mappings":"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","names":[]}