diff --git a/src/lib/observability.ts b/src/lib/observability.ts index 862e3dd09..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,6 +23,75 @@ export function filterObservabilityBreadcrumb(bread return isReactCaughtRenderError ? null : breadcrumb; } +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 (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; @@ -32,8 +102,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, }); } @@ -49,16 +141,37 @@ 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 (!import.meta.env.VITE_POSTHOG_KEY) return; + if (!PRODUCT_EVENT_NAMES.has(name)) 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. + } } } +/** Reports a safely constructed exception without allowing telemetry failures into product flows. */ export function captureException(error: unknown, context?: Record): void { if (!import.meta.env.VITE_SENTRY_DSN) return; diff --git a/src/locales/en.json b/src/locales/en.json index 92c4cdc51..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 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 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 9d668d8f4..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 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 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 5d3f81807..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 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 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..fdb475deb 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,17 +60,19 @@ 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", () => { vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key"); const properties = { tool: "generate", - format: "bp", + format: "pdf", durationMs: 123, streaming: false, }; @@ -71,6 +95,61 @@ 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("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" }); @@ -119,7 +198,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 +211,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",