Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 119 additions & 6 deletions src/lib/observability.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -22,6 +23,75 @@ export function filterObservabilityBreadcrumb<T extends ConsoleBreadcrumb>(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<string, ReadonlySet<string>> = {
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<string, unknown>): Record<string, TelemetryAttribute> {
if (!properties) return {};

const safeProperties: Record<string, TelemetryAttribute> = {};
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;
Expand All @@ -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,
});
}

Expand All @@ -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<string, unknown>): 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<string, unknown>): void {
if (!import.meta.env.VITE_SENTRY_DSN) return;

Expand Down
2 changes: 1 addition & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 104 additions & 4 deletions src/test/observability.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand Down Expand Up @@ -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,
};
Expand All @@ -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" });

Expand Down Expand Up @@ -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");
Expand All @@ -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",
Expand Down
Loading