{"version":3,"sources":["../src/modules/telemetry.ts"],"sourcesContent":["/**\n * withTelemetryStream — opt-in module that streams already-captured browser\n * events (JS errors, failed network requests, web-vitals) to the backend\n * telemetry ingestion endpoint in batches.\n *\n * Same opt-in wrapper pattern as withErrorTracking / withWebVitals / withReplay.\n * Host apps that want curated-feedback-only simply don't import this.\n *\n * Design notes:\n * - Installs its own lightweight capture listeners (same events as the ring\n *   buffers in capture/) rather than reading snapshots from them. The ring\n *   buffer API is internal to core.ts with no public subscription hook, so\n *   the correct pattern — established by withErrorTracking — is to install\n *   independent listeners that observe the same global surfaces.\n * - Network capture: only failed requests (status 0 or >=400) are emitted to\n *   keep volume low. Successful requests are high-cardinality noise in a\n *   telemetry stream.\n * - Scrubbing: error messages/stacks are scrubbed via the shared\n *   scrubCredentials helper. URLs are sanitized via sanitizeUrl (same helper\n *   the ring-buffer patch uses). Data entering this module's buffers is already\n *   scrubbed at the source by the global patches; we add a second pass for the\n *   events we capture independently.\n * - Flush: periodic (default 10 s) + visibilitychange→hidden/pagehide via\n *   keepalive fetch. We do NOT use navigator.sendBeacon — it can't carry the\n *   Authorization header, and the backend authenticates the pk_proj_ key only\n *   from `Authorization: Bearer`. The key never travels in a query string.\n * - Per-flush cap (default 50 events) prevents a single bad page from\n *   flooding the backend on flush.\n * - No rrweb / session replay. Events only.\n */\n\nimport type { FeedbackApi } from '../types'\nimport { scrubCredentials } from '../capture/scrub'\nimport { sanitizeUrl as defaultSanitizeUrl } from '../capture/urlSanitizer'\n\n// ---- Public option types ---------------------------------------------------\n\nexport interface TelemetryStreamOptions {\n  /** Kill switch without removing the import. Default `true`. */\n  enabled?: boolean\n  /** Flush interval in ms. Default 10 000 (10 s). */\n  flushIntervalMs?: number\n  /** Max events per flush. Older events are dropped first. Default 50. */\n  maxBatchSize?: number\n  /**\n   * Custom URL sanitizer for network event URLs. Defaults to the same\n   * sanitizeUrl used by the widget's network ring-buffer patch.\n   */\n  sanitizeUrl?: (url: string) => string\n}\n\n// ---- Internal event shapes (match the backend contract) -------------------\n\ninterface ErrorEvent_ {\n  kind: 'error'\n  ts: number\n  message: string\n  stack?: string\n}\n\ninterface NetworkEvent {\n  kind: 'network'\n  ts: number\n  method: string\n  url: string\n  status: number\n  durationMs: number\n}\n\ninterface WebVitalEvent {\n  kind: 'web-vital'\n  ts: number\n  name: string\n  value: number\n  rating: string\n}\n\ntype TelemetryEvent = ErrorEvent_ | NetworkEvent | WebVitalEvent\n\n// ---- InternalApi compat (read endpoint + apiKey from the fb instance) -----\n\n/**\n * The public FeedbackApi doesn't expose endpoint/apiKey. We reach into the\n * FeedbackApiInternal config via the same cast pattern error-tracking.ts uses\n * for _registerTransformer — typed minimally so we're not coupled to\n * unexported internals more than necessary.\n */\ninterface InternalApi extends FeedbackApi {\n  _telemetryConfig?: { endpoint: string; apiKey: string }\n  shutdown(): void\n}\n\n// We need endpoint + apiKey from the fb closure. Since FeedbackApi doesn't\n// expose them directly, withTelemetryStream accepts them explicitly. This\n// mirrors how host apps have them in scope at the call site (they just passed\n// them to createFeedback a line above).\nexport interface TelemetryStreamConfig {\n  endpoint: string\n  apiKey: string\n}\n\n// ---- Constants -------------------------------------------------------------\n\nconst DEFAULTS = {\n  enabled: true,\n  flushIntervalMs: 10_000,\n  maxBatchSize: 50,\n} as const satisfies Required<Omit<TelemetryStreamOptions, 'sanitizeUrl'>>\n\n// ---- Main export ----------------------------------------------------------\n\nexport function withTelemetryStream(\n  fb: FeedbackApi,\n  config: TelemetryStreamConfig,\n  options: TelemetryStreamOptions = {},\n): FeedbackApi {\n  const opts = { ...DEFAULTS, ...options }\n  if (!opts.enabled) return fb\n  if (typeof window === 'undefined') return fb\n\n  const endpoint = config.endpoint.replace(/\\/+$/, '')\n  const apiKey = config.apiKey\n  const sanitize = opts.sanitizeUrl ?? defaultSanitizeUrl\n\n  // ---- Event queue ---------------------------------------------------------\n  // Simple FIFO array. When we accumulate more than maxBatchSize * 2 between\n  // flushes (e.g. a tight error loop), we evict from the front to keep memory\n  // bounded. The per-flush cap is enforced at flush time.\n  const queue: TelemetryEvent[] = []\n  const HARD_CAP = opts.maxBatchSize * 4\n\n  function enqueue(event: TelemetryEvent): void {\n    queue.push(event)\n    // Evict from the front — oldest events are least actionable.\n    while (queue.length > HARD_CAP) queue.shift()\n  }\n\n  // ---- Error capture -------------------------------------------------------\n  function onWindowError(ev: globalThis.ErrorEvent): void {\n    const thrown = ev.error\n    let message: string\n    let stack: string | undefined\n    if (thrown instanceof Error) {\n      message = scrubCredentials(thrown.message || ev.message || 'Unknown error')\n      stack = thrown.stack ? scrubCredentials(thrown.stack) : undefined\n    } else {\n      message = scrubCredentials(ev.message || String(thrown) || 'Unknown error')\n    }\n    enqueue({ kind: 'error', ts: Date.now(), message, ...(stack !== undefined && { stack }) })\n  }\n\n  function onUnhandledRejection(ev: PromiseRejectionEvent): void {\n    const reason = ev.reason\n    let message: string\n    let stack: string | undefined\n    if (reason instanceof Error) {\n      message = scrubCredentials(reason.message || 'Unhandled rejection')\n      stack = reason.stack ? scrubCredentials(reason.stack) : undefined\n    } else if (typeof reason === 'string') {\n      message = scrubCredentials(reason)\n    } else {\n      message = scrubCredentials(\n        (() => { try { return JSON.stringify(reason) } catch { return String(reason) } })(),\n      )\n    }\n    enqueue({ kind: 'error', ts: Date.now(), message, ...(stack !== undefined && { stack }) })\n  }\n\n  // ---- Network capture (failed requests only) ------------------------------\n  const originalFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : null\n\n  let fetchPatched = false\n  if (originalFetch) {\n    window.fetch = async function telemetryFetch(\n      input: RequestInfo | URL,\n      init?: RequestInit,\n    ) {\n      const start = performance.now()\n      const url =\n        typeof input === 'string'\n          ? input\n          : input instanceof URL\n            ? input.toString()\n            : (input as Request).url\n      const method = (\n        init?.method ?? (input instanceof Request ? input.method : 'GET')\n      ).toUpperCase()\n      // Belt-and-suspenders: never capture telemetry endpoint requests to\n      // prevent a self-amplifying loop if the telemetry POST itself fails.\n      const isTelemetryUrl = url.includes('/api/feedback/v1/telemetry/')\n      try {\n        const response = await originalFetch(input, init)\n        if (!isTelemetryUrl && response.status >= 400) {\n          enqueue({\n            kind: 'network',\n            ts: Date.now(),\n            method,\n            url: sanitize(url),\n            status: response.status,\n            durationMs: Math.round(performance.now() - start),\n          })\n        }\n        return response\n      } catch (err) {\n        if (!isTelemetryUrl) {\n          enqueue({\n            kind: 'network',\n            ts: Date.now(),\n            method,\n            url: sanitize(url),\n            status: 0,\n            durationMs: Math.round(performance.now() - start),\n          })\n        }\n        throw err\n      }\n    } as typeof fetch\n    fetchPatched = true\n  }\n\n  // ---- Web vitals capture --------------------------------------------------\n  // Dynamically import web-vitals so the module stays tree-shakeable and\n  // the import doesn't fail in environments where the package isn't present.\n  void (async () => {\n    try {\n      const { onCLS, onFCP, onINP, onLCP, onTTFB } = await import('web-vitals/attribution')\n      const record = (metric: { name: string; value: number; rating: string }) => {\n        enqueue({\n          kind: 'web-vital',\n          ts: Date.now(),\n          name: metric.name,\n          value: metric.value,\n          rating: metric.rating,\n        })\n      }\n      onCLS(record)\n      onINP(record)\n      onLCP(record)\n      onFCP(record)\n      onTTFB(record)\n    } catch { /* web-vitals unavailable or not installed */ }\n  })()\n\n  // ---- Flush logic ---------------------------------------------------------\n  const TELEMETRY_URL = `${endpoint}/api/feedback/v1/telemetry/`\n  const authHeader = `Bearer ${apiKey}`\n\n  /**\n   * Drain up to maxBatchSize events from the front of the queue and POST\n   * them to the backend.\n   *\n   * Always uses keepalive fetch — including on visibilitychange→hidden /\n   * pagehide. We deliberately do NOT use navigator.sendBeacon: it can't\n   * carry the Authorization header, and the backend's BearerApiKeyAuthentication\n   * reads the pk_proj_ key only from `Authorization: Bearer`. The key must\n   * never travel in a query string. keepalive fetch supports headers and is\n   * delivered during unload (subject to the browser's 64 KB body cap, which\n   * is comfortably above our capped batch size). Telemetry is best-effort:\n   * losing an occasional unload batch is acceptable; a silent 401 from a\n   * mis-authed beacon is not.\n   */\n  function flush(): void {\n    if (queue.length === 0) return\n\n    const batch = queue.splice(0, opts.maxBatchSize)\n    const body = JSON.stringify({ events: batch })\n\n    // Use originalFetch (the native, pre-patch fetch) so the telemetry POST\n    // is never captured by our own network interceptor — preventing a\n    // self-amplifying loop where a failing telemetry POST re-enqueues itself.\n    const flushFetch = originalFetch ?? window.fetch.bind(window)\n    void flushFetch(TELEMETRY_URL, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        Authorization: authHeader,\n      },\n      body,\n      keepalive: true,\n    }).catch(() => { /* best-effort; swallow silently */ })\n  }\n\n  // Periodic flush\n  const intervalId = setInterval(() => { flush() }, opts.flushIntervalMs)\n\n  // Flush on page hide — keepalive fetch is delivered during unload.\n  function onVisibilityChange(): void {\n    if (document.visibilityState === 'hidden') flush()\n  }\n  function onPageHide(): void {\n    flush()\n  }\n\n  window.addEventListener('error', onWindowError)\n  window.addEventListener('unhandledrejection', onUnhandledRejection)\n  document.addEventListener('visibilitychange', onVisibilityChange)\n  window.addEventListener('pagehide', onPageHide)\n\n  // ---- Cleanup -------------------------------------------------------------\n  const originalShutdown = (fb as InternalApi).shutdown.bind(fb)\n  ;(fb as InternalApi).shutdown = () => {\n    clearInterval(intervalId)\n    window.removeEventListener('error', onWindowError)\n    window.removeEventListener('unhandledrejection', onUnhandledRejection)\n    document.removeEventListener('visibilitychange', onVisibilityChange)\n    window.removeEventListener('pagehide', onPageHide)\n    // Restore original fetch if we patched it (only ours; check identity).\n    if (fetchPatched && originalFetch) {\n      window.fetch = originalFetch\n    }\n    originalShutdown()\n  }\n\n  return fb\n}\n"],"mappings":"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","names":[]}