From c56d8a43fc2c3d3858910d00bde714ecd5bb66e8 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 16:37:41 +0000 Subject: [PATCH 1/3] docs: disclose analytics and error processors --- src/locales/en.json | 2 +- src/locales/es.json | 2 +- src/locales/hu.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 92c4cdc51..179d84c6a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1068,7 +1068,7 @@ "s6": "Your Rights", "p6": "You may access, export, or delete your data at any time from your account settings. Account deletion removes all associated data.", "s7": "Cookies and Tracking", - "p7": "We use essential cookies for authentication and preferences. We use PostHog for product analytics and Sentry for error reporting. PostHog receives feature, format, status, and timing data, not debate content, chat messages, source titles, or email addresses; automatic interaction capture and session recording are disabled. Sentry is configured not to send default personal information.", + "p7": "We use essential cookies and browser storage for authentication and preferences. We use PostHog for privacy-preserving product analytics and Sentry for error reporting. We do not send debate content, chat messages, source titles, or email addresses in analytics event properties. PostHog autocapture, pageview capture, session recording, and persistent browser storage are disabled, and Sentry's default personal-data collection is disabled.", "s8": "Children's Privacy", "p8": "DebateLab is designed for users 13 and older. We do not knowingly collect data from children under 13.", "s9": "Changes to This Policy", diff --git a/src/locales/es.json b/src/locales/es.json index 9d668d8f4..3e5eb5c97 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1068,7 +1068,7 @@ "s6": "Tus Derechos", "p6": "Puedes acceder, exportar o eliminar tus datos en cualquier momento desde la configuración de tu cuenta.", "s7": "Cookies y Seguimiento", - "p7": "Usamos cookies esenciales para la autenticación y las preferencias. Usamos PostHog para analítica de producto y Sentry para informar de errores. PostHog recibe datos sobre la función, el formato, el estado y la duración, pero no el contenido de los debates, los mensajes de chat, los títulos de las fuentes ni las direcciones de correo electrónico; la captura automática de interacciones y la grabación de sesiones están desactivadas. Sentry está configurado para no enviar información personal de forma predeterminada.", + "p7": "Usamos cookies y almacenamiento del navegador esenciales para la autenticación y las preferencias. Usamos PostHog para analítica de producto respetuosa con la privacidad y Sentry para informar de errores. No enviamos contenido de debates, mensajes de chat, títulos de fuentes ni direcciones de correo electrónico en las propiedades de los eventos analíticos. La captura automática, el registro de páginas vistas, la grabación de sesiones y el almacenamiento persistente de PostHog están desactivados, al igual que la recopilación predeterminada de datos personales de Sentry.", "s8": "Privacidad de Menores", "p8": "DebateLab está diseñado para usuarios de 13 años o más. No recopilamos datos de menores de 13 años conscientemente.", "s9": "Cambios a Esta Política", diff --git a/src/locales/hu.json b/src/locales/hu.json index 5d3f81807..dd04ba7b6 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1068,7 +1068,7 @@ "s6": "Jogaid", "p6": "Bármikor hozzáférhetsz, exportálhatod vagy törölheted adataidat a fiókbeállításokból. A fiók törlése eltávolítja az összes kapcsolódó adatot.", "s7": "Sütik és Nyomkövetés", - "p7": "Alapvető sütiket használunk hitelesítéshez és a beállítások megőrzéséhez. A PostHogot termékanalitikára, a Sentryt hibajelentésre használjuk. A PostHog funkció-, formátum-, állapot- és időzítési adatokat kap, vitatartalmat, csevegőüzeneteket, forráscímeket vagy e-mail-címeket nem; az automatikus interakciórögzítés és a munkamenet-felvétel ki van kapcsolva. A Sentry úgy van beállítva, hogy ne küldjön alapértelmezett személyes adatokat.", + "p7": "Alapvető sütiket és böngészőtárhelyet használunk a hitelesítéshez és a beállításokhoz. A PostHogot adatvédelmet tiszteletben tartó termékanalitikához, a Sentryt pedig hibajelentésekhez használjuk. Az analitikai események tulajdonságaiban nem küldünk vitatartalmat, chatüzeneteket, forráscímeket vagy e-mail-címeket. A PostHog automatikus eseménygyűjtése, oldalmegtekintés-követése, munkamenet-rögzítése és tartós böngészőtárhelye ki van kapcsolva, a Sentry alapértelmezett személyesadat-gyűjtése pedig le van tiltva.", "s8": "Gyermekek Adatvédelme", "p8": "A DebateLab 13 éves és idősebb felhasználóknak készült. Tudatosan nem gyűjtünk adatot 13 év alatti gyermekektől.", "s9": "Irányelvek Módosítása", From 7c69da6b599267df9c1668d917da94d3e765b898 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 16:57:33 +0000 Subject: [PATCH 2/3] feat: enable full Sentry observability --- src/lib/observability.ts | 81 +++++++++++++++++++++++++++++++--- src/locales/en.json | 2 +- src/locales/es.json | 2 +- src/locales/hu.json | 2 +- src/test/observability.test.ts | 81 ++++++++++++++++++++++++++++++++-- 5 files changed, 156 insertions(+), 12 deletions(-) diff --git a/src/lib/observability.ts b/src/lib/observability.ts index 862e3dd09..9aae14ad8 100644 --- a/src/lib/observability.ts +++ b/src/lib/observability.ts @@ -22,6 +22,35 @@ export function filterObservabilityBreadcrumb(bread return isReactCaughtRenderError ? null : breadcrumb; } +const PRODUCT_PROPERTY_KEYS = new Set([ + "durationMs", + "errorName", + "feature", + "format", + "model", + "provider", + "roundIndex", + "speechCount", + "statusCode", + "streaming", + "tool", + "winner", +]); + +type TelemetryAttribute = string | number | boolean; + +function safeProductProperties(properties?: Record): Record { + if (!properties) return {}; + + const safeProperties: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (!PRODUCT_PROPERTY_KEYS.has(key)) continue; + if (typeof value === "string" || typeof value === "boolean") safeProperties[key] = value; + if (typeof value === "number" && Number.isFinite(value)) safeProperties[key] = value; + } + return safeProperties; +} + export function initializeObservability(): void { if (initialized) return; initialized = true; @@ -32,8 +61,30 @@ export function initializeObservability(): void { dsn: sentryDsn, environment: import.meta.env.MODE, sendDefaultPii: false, - tracesSampleRate: 0.1, beforeBreadcrumb: filterObservabilityBreadcrumb, + dataCollection: { + userInfo: false, + cookies: false, + httpHeaders: { request: false, response: false }, + httpBodies: [], + queryParams: false, + genAI: { inputs: false, outputs: false }, + stackFrameVariables: false, + frameContextLines: 5, + }, + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.replayIntegration({ + maskAllText: true, + maskAllInputs: true, + blockAllMedia: true, + }), + ], + enableLogs: true, + enableMetrics: true, + tracesSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, }); } @@ -50,12 +101,30 @@ export function initializeObservability(): void { } export function captureProductEvent(name: string, properties?: Record): void { - if (!import.meta.env.VITE_POSTHOG_KEY) return; + const safeProperties = safeProductProperties(properties); - try { - posthog.capture(name, properties); - } catch { - // Telemetry must not interrupt product behavior. + if (import.meta.env.VITE_POSTHOG_KEY) { + try { + posthog.capture(name, safeProperties); + } catch { + // Telemetry must not interrupt product behavior. + } + } + + if (import.meta.env.VITE_SENTRY_DSN) { + const attributes = { event_name: name, ...safeProperties }; + try { + Sentry.logger.info("Product event", attributes); + Sentry.metrics.count("debatelab.product_event", 1, { attributes }); + if (typeof safeProperties.durationMs === "number") { + Sentry.metrics.distribution("debatelab.product_event.duration", safeProperties.durationMs, { + unit: "millisecond", + attributes, + }); + } + } catch { + // Telemetry must not interrupt product behavior. + } } } diff --git a/src/locales/en.json b/src/locales/en.json index 179d84c6a..9082c7e71 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1068,7 +1068,7 @@ "s6": "Your Rights", "p6": "You may access, export, or delete your data at any time from your account settings. Account deletion removes all associated data.", "s7": "Cookies and Tracking", - "p7": "We use essential cookies and browser storage for authentication and preferences. We use PostHog for privacy-preserving product analytics and Sentry for error reporting. We do not send debate content, chat messages, source titles, or email addresses in analytics event properties. PostHog autocapture, pageview capture, session recording, and persistent browser storage are disabled, and Sentry's default personal-data collection is disabled.", + "p7": "We use essential cookies and browser storage for authentication and preferences. We use PostHog for product analytics and Sentry for error monitoring, structured logs, application metrics, performance tracing, and Session Replay. Sentry records 10% of ordinary sessions and 100% of sessions containing captured errors; all replay text and inputs are masked and media is blocked. Sentry receives technical metadata such as routes, browser details, request URLs, methods and status codes, stack traces, timings, and safe event classifications. Automatic collection of user identity, cookies, headers, HTTP bodies, query parameters, AI inputs and outputs, and stack-frame variables is disabled. PostHog autocapture, pageview capture, session recording, and persistent browser storage are disabled. We do not send debate content, chat messages, source titles, or email addresses in analytics events or structured logs.", "s8": "Children's Privacy", "p8": "DebateLab is designed for users 13 and older. We do not knowingly collect data from children under 13.", "s9": "Changes to This Policy", diff --git a/src/locales/es.json b/src/locales/es.json index 3e5eb5c97..903a5f910 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1068,7 +1068,7 @@ "s6": "Tus Derechos", "p6": "Puedes acceder, exportar o eliminar tus datos en cualquier momento desde la configuración de tu cuenta.", "s7": "Cookies y Seguimiento", - "p7": "Usamos cookies y almacenamiento del navegador esenciales para la autenticación y las preferencias. Usamos PostHog para analítica de producto respetuosa con la privacidad y Sentry para informar de errores. No enviamos contenido de debates, mensajes de chat, títulos de fuentes ni direcciones de correo electrónico en las propiedades de los eventos analíticos. La captura automática, el registro de páginas vistas, la grabación de sesiones y el almacenamiento persistente de PostHog están desactivados, al igual que la recopilación predeterminada de datos personales de Sentry.", + "p7": "Usamos cookies y almacenamiento del navegador esenciales para la autenticación y las preferencias. Usamos PostHog para analítica de producto y Sentry para monitorización de errores, registros estructurados, métricas de la aplicación, seguimiento del rendimiento y reproducción de sesiones. Sentry registra el 10% de las sesiones normales y el 100% de las sesiones que contienen errores capturados; todo el texto y los campos de entrada se ocultan en las reproducciones y el contenido multimedia se bloquea. Sentry recibe metadatos técnicos como rutas, datos del navegador, URL, métodos y códigos de estado de solicitudes, trazas de pila, tiempos y clasificaciones seguras de eventos. La recopilación automática de identidad del usuario, cookies, cabeceras, cuerpos HTTP, parámetros de consulta, entradas y salidas de IA y variables de marcos de pila está desactivada. La captura automática, el registro de páginas vistas, la grabación de sesiones y el almacenamiento persistente de PostHog están desactivados. No enviamos contenido de debates, mensajes de chat, títulos de fuentes ni direcciones de correo electrónico en eventos analíticos o registros estructurados.", "s8": "Privacidad de Menores", "p8": "DebateLab está diseñado para usuarios de 13 años o más. No recopilamos datos de menores de 13 años conscientemente.", "s9": "Cambios a Esta Política", diff --git a/src/locales/hu.json b/src/locales/hu.json index dd04ba7b6..17c044290 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1068,7 +1068,7 @@ "s6": "Jogaid", "p6": "Bármikor hozzáférhetsz, exportálhatod vagy törölheted adataidat a fiókbeállításokból. A fiók törlése eltávolítja az összes kapcsolódó adatot.", "s7": "Sütik és Nyomkövetés", - "p7": "Alapvető sütiket és böngészőtárhelyet használunk a hitelesítéshez és a beállításokhoz. A PostHogot adatvédelmet tiszteletben tartó termékanalitikához, a Sentryt pedig hibajelentésekhez használjuk. Az analitikai események tulajdonságaiban nem küldünk vitatartalmat, chatüzeneteket, forráscímeket vagy e-mail-címeket. A PostHog automatikus eseménygyűjtése, oldalmegtekintés-követése, munkamenet-rögzítése és tartós böngészőtárhelye ki van kapcsolva, a Sentry alapértelmezett személyesadat-gyűjtése pedig le van tiltva.", + "p7": "Alapvető sütiket és böngészőtárhelyet használunk a hitelesítéshez és a beállításokhoz. A PostHogot termékanalitikához, a Sentryt pedig hibamegfigyeléshez, strukturált naplókhoz, alkalmazásmetrikákhoz, teljesítménykövetéshez és munkamenet-visszajátszáshoz használjuk. A Sentry a szokásos munkamenetek 10%-át és a rögzített hibát tartalmazó munkamenetek 100%-át rögzíti; a visszajátszásokban minden szöveg és beviteli érték maszkolva van, a médiatartalom pedig blokkolva. A Sentry technikai metaadatokat kap, például útvonalakat, böngészőadatokat, kérés-URL-eket, metódusokat és állapotkódokat, veremkövetéseket, időzítéseket és biztonságos eseménybesorolásokat. A felhasználói azonosítók, sütik, fejlécek, HTTP-törzsek, lekérdezési paraméterek, MI-bemenetek és -kimenetek, valamint veremkeret-változók automatikus gyűjtése le van tiltva. A PostHog automatikus eseménygyűjtése, oldalmegtekintés-követése, munkamenet-rögzítése és tartós böngészőtárhelye ki van kapcsolva. Az analitikai eseményekben és strukturált naplókban nem küldünk vitatartalmat, chatüzeneteket, forráscímeket vagy e-mail-címeket.", "s8": "Gyermekek Adatvédelme", "p8": "A DebateLab 13 éves és idősebb felhasználóknak készült. Tudatosan nem gyűjtünk adatot 13 év alatti gyermekektől.", "s9": "Irányelvek Módosítása", diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts index 199ca39fb..e5cf7d908 100644 --- a/src/test/observability.test.ts +++ b/src/test/observability.test.ts @@ -1,15 +1,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { captureMock, posthogInitMock, sentryCaptureMock, sentryInitMock } = vi.hoisted(() => ({ +const { + browserTracingIntegrationMock, + captureMock, + posthogInitMock, + replayIntegrationMock, + sentryCaptureMock, + sentryInitMock, + sentryLoggerInfoMock, + sentryMetricsCountMock, + sentryMetricsDistributionMock, +} = vi.hoisted(() => ({ + browserTracingIntegrationMock: vi.fn(() => ({ name: "BrowserTracing" })), captureMock: vi.fn(), posthogInitMock: vi.fn(), + replayIntegrationMock: vi.fn(() => ({ name: "Replay" })), sentryCaptureMock: vi.fn(), sentryInitMock: vi.fn(), + sentryLoggerInfoMock: vi.fn(), + sentryMetricsCountMock: vi.fn(), + sentryMetricsDistributionMock: vi.fn(), })); vi.mock("@sentry/react", () => ({ + browserTracingIntegration: browserTracingIntegrationMock, captureException: sentryCaptureMock, init: sentryInitMock, + logger: { info: sentryLoggerInfoMock }, + metrics: { + count: sentryMetricsCountMock, + distribution: sentryMetricsDistributionMock, + }, + replayIntegration: replayIntegrationMock, })); vi.mock("posthog-js", () => ({ @@ -38,10 +60,12 @@ describe("observability primitives", () => { vi.unstubAllEnvs(); }); - it("does not capture product events when PostHog is not configured", () => { + it("does not capture product events when neither service is configured", () => { captureProductEvent("tool_opened", { tool: "generate" }); expect(captureMock).not.toHaveBeenCalled(); + expect(sentryLoggerInfoMock).not.toHaveBeenCalled(); + expect(sentryMetricsCountMock).not.toHaveBeenCalled(); }); it("passes structured product events through when PostHog is configured", () => { @@ -71,6 +95,36 @@ describe("observability primitives", () => { expect(() => captureProductEvent("tool_opened", { tool: "generate" })).not.toThrow(); }); + it("sends safe product logs and metrics to Sentry while dropping unexpected properties", () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); + + captureProductEvent("ai_request_succeeded", { + durationMs: 234, + feature: "ask", + message: "private debate content", + model: "gpt-5.4-mini", + }); + + const attributes = { + event_name: "ai_request_succeeded", + durationMs: 234, + feature: "ask", + model: "gpt-5.4-mini", + }; + expect(sentryLoggerInfoMock).toHaveBeenCalledWith("Product event", attributes); + expect(sentryMetricsCountMock).toHaveBeenCalledWith("debatelab.product_event", 1, { attributes }); + expect(sentryMetricsDistributionMock).toHaveBeenCalledWith( + "debatelab.product_event.duration", + 234, + { unit: "millisecond", attributes }, + ); + expect(JSON.stringify([ + ...sentryLoggerInfoMock.mock.calls, + ...sentryMetricsCountMock.mock.calls, + ...sentryMetricsDistributionMock.mock.calls, + ])).not.toContain("private debate content"); + }); + it("does not send exceptions when Sentry is not configured", () => { captureException(new Error("synthetic test error"), { category: "test" }); @@ -119,7 +173,7 @@ describe("observability primitives", () => { expect(filterObservabilityBreadcrumb(ordinaryBreadcrumb)).toBe(ordinaryBreadcrumb); }); - it("initializes both SDKs once with privacy-preserving options", () => { + it("initializes all selected Sentry capabilities and privacy controls once", () => { vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key"); vi.stubEnv("VITE_POSTHOG_HOST", "https://posthog.example"); vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); @@ -132,7 +186,28 @@ describe("observability primitives", () => { dsn: "https://public-test@sentry.example/1", sendDefaultPii: false, beforeBreadcrumb: filterObservabilityBreadcrumb, + dataCollection: { + userInfo: false, + cookies: false, + httpHeaders: { request: false, response: false }, + httpBodies: [], + queryParams: false, + genAI: { inputs: false, outputs: false }, + stackFrameVariables: false, + frameContextLines: 5, + }, + enableLogs: true, + enableMetrics: true, + tracesSampleRate: 1, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1, })); + expect(browserTracingIntegrationMock).toHaveBeenCalledOnce(); + expect(replayIntegrationMock).toHaveBeenCalledWith({ + maskAllText: true, + maskAllInputs: true, + blockAllMedia: true, + }); expect(posthogInitMock).toHaveBeenCalledOnce(); expect(posthogInitMock).toHaveBeenCalledWith("posthog-test-key", expect.objectContaining({ api_host: "https://posthog.example", From 324bfa81a1fcf07e78be236fffa180b15599178c Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 17:19:09 +0000 Subject: [PATCH 3/3] fix: enforce closed telemetry schema --- src/lib/observability.ts | 76 +++++++++++++++++++++++++++------- src/test/observability.test.ts | 27 +++++++++++- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/lib/observability.ts b/src/lib/observability.ts index 9aae14ad8..c95a23216 100644 --- a/src/lib/observability.ts +++ b/src/lib/observability.ts @@ -1,5 +1,6 @@ import * as Sentry from "@sentry/react"; import posthog from "posthog-js"; +import { DEFAULT_PROVIDERS } from "@/lib/ai/providers/types"; let initialized = false; @@ -22,35 +23,75 @@ export function filterObservabilityBreadcrumb(bread return isReactCaughtRenderError ? null : breadcrumb; } -const PRODUCT_PROPERTY_KEYS = new Set([ - "durationMs", - "errorName", - "feature", - "format", - "model", - "provider", - "roundIndex", - "speechCount", - "statusCode", - "streaming", - "tool", - "winner", +const PRODUCT_EVENT_NAMES = new Set([ + "ai_request_failed", + "ai_request_started", + "ai_request_succeeded", + "content_exported", + "sparring_round_completed", + "sparring_session_judged", + "tool_opened", ]); +const STRING_PROPERTY_VALUES: Record> = { + errorName: new Set([ + "AIAuthError", + "AIModelRestrictionError", + "AIProviderError", + "AIRateLimitError", + "AIResponseError", + "AISubscriptionError", + "Error", + "TimeoutError", + "TypeError", + "UnknownError", + ]), + feature: new Set(["ask", "attack", "brainstorm", "crossfire", "factoids", "generate", "sparring"]), + format: new Set(["docx", "google_docs", "ld", "pdf", "pf", "standard"]), + model: new Set(DEFAULT_PROVIDERS.flatMap((provider) => provider.models.map((model) => model.id))), + provider: new Set(DEFAULT_PROVIDERS.map((provider) => provider.id)), + tool: new Set([ + "admin", + "ask", + "attack", + "brainstorm", + "crossfire", + "factoids", + "flowchart", + "generate", + "history", + "library", + "settings", + "sparring", + "timer", + ]), + winner: new Set(["ai", "tie", "user"]), +}; + type TelemetryAttribute = string | number | boolean; +/** Retains only bounded values from the product telemetry schema. */ function safeProductProperties(properties?: Record): Record { if (!properties) return {}; const safeProperties: Record = {}; for (const [key, value] of Object.entries(properties)) { - if (!PRODUCT_PROPERTY_KEYS.has(key)) continue; - if (typeof value === "string" || typeof value === "boolean") safeProperties[key] = value; - if (typeof value === "number" && Number.isFinite(value)) safeProperties[key] = value; + if (typeof value === "string" && STRING_PROPERTY_VALUES[key]?.has(value)) safeProperties[key] = value; + if (key === "streaming" && typeof value === "boolean") safeProperties[key] = value; + if (key === "durationMs" && typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 3_600_000) { + safeProperties[key] = value; + } + if ((key === "roundIndex" || key === "speechCount") && Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 100) { + safeProperties[key] = Number(value); + } + if (key === "statusCode" && Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599) { + safeProperties[key] = Number(value); + } } return safeProperties; } +/** Initializes configured observability SDKs once with explicit privacy controls. */ export function initializeObservability(): void { if (initialized) return; initialized = true; @@ -100,7 +141,9 @@ export function initializeObservability(): void { } } +/** Reports a named product event only when its name and properties match the closed schema. */ export function captureProductEvent(name: string, properties?: Record): void { + if (!PRODUCT_EVENT_NAMES.has(name)) return; const safeProperties = safeProductProperties(properties); if (import.meta.env.VITE_POSTHOG_KEY) { @@ -128,6 +171,7 @@ export function captureProductEvent(name: string, properties?: Record): void { if (!import.meta.env.VITE_SENTRY_DSN) return; diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts index e5cf7d908..fdb475deb 100644 --- a/src/test/observability.test.ts +++ b/src/test/observability.test.ts @@ -72,7 +72,7 @@ describe("observability primitives", () => { vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key"); const properties = { tool: "generate", - format: "bp", + format: "pdf", durationMs: 123, streaming: false, }; @@ -125,6 +125,31 @@ describe("observability primitives", () => { ])).not.toContain("private debate content"); }); + it("rejects private strings in allowlisted fields and rejects unknown event names", () => { + vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key"); + vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); + + captureProductEvent("ai_request_succeeded", { + feature: "private debate content", + model: "student@example.com", + provider: "openai", + }); + captureProductEvent("student@example.com", { feature: "ask" }); + + expect(captureMock).toHaveBeenCalledOnce(); + expect(captureMock).toHaveBeenCalledWith("ai_request_succeeded", { provider: "openai" }); + expect(sentryLoggerInfoMock).toHaveBeenCalledOnce(); + expect(sentryLoggerInfoMock).toHaveBeenCalledWith("Product event", { + event_name: "ai_request_succeeded", + provider: "openai", + }); + expect(JSON.stringify([ + ...captureMock.mock.calls, + ...sentryLoggerInfoMock.mock.calls, + ...sentryMetricsCountMock.mock.calls, + ])).not.toMatch(/private debate content|student@example\.com/); + }); + it("does not send exceptions when Sentry is not configured", () => { captureException(new Error("synthetic test error"), { category: "test" });