diff --git a/src/App.tsx b/src/App.tsx index 761993f07..05efebe28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -97,8 +97,8 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { fai return { failed: true }; } - componentDidCatch(error: Error, info: ErrorInfo) { - console.error('Route rendering failed', error, info); + componentDidCatch(_error: Error, _info: ErrorInfo) { + console.error('Route rendering failed'); captureException(new Error("Route rendering failed"), { boundary: "route" }); } diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index 3af1869ee..c96ad264c 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -46,12 +46,12 @@ const MAX_CROSSFIRE_SOURCE_COUNT = 10; const MAX_CROSSFIRE_SOURCE_CHARS = 8_000; const MAX_CROSSFIRE_SOURCE_TOTAL_CHARS = 24_000; -interface AIRequestOptions { +interface AIRequestOptions { includeActiveSources?: boolean; includeCustomSystemPrompt?: boolean; timeoutMs?: number; signal?: AbortSignal; - transformResponse?: (data: unknown) => unknown; + transformResponse?: (data: unknown) => T; } function waitForAbortable(operation: () => PromiseLike, signal: AbortSignal): Promise { @@ -313,12 +313,45 @@ export function classifyAIError(error: unknown, userInitiatedAbort = false): AIE return { errorName, statusCode, shouldCaptureException: true }; } -async function callSupabaseAI( +interface AIFailureReport { + error: unknown; + feature: 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring' | 'crossfire'; + provider: string; + model: string; + startTime: number; + requestId: string; + streaming?: boolean; + userInitiatedAbort?: boolean; +} + +/** Reports an AI failure without forwarding user-controlled error text. */ +export function reportAIFailure(report: AIFailureReport): void { + const classification = classifyAIError(report.error, report.userInitiatedAbort); + const durationMs = Math.round(performance.now() - report.startTime); + const properties = { + feature: report.feature, + ...(report.streaming === undefined ? {} : { streaming: report.streaming }), + provider: report.provider, + model: report.model, + durationMs, + errorName: classification.errorName, + statusCode: classification.statusCode, + }; + captureProductEvent('ai_request_failed', properties); + + if (classification.shouldCaptureException) { + const safeError = new Error(classification.errorName); + safeError.name = classification.errorName; + captureException(safeError, { ...properties, requestId: report.requestId }); + } +} + +async function callSupabaseAI( feature: 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring' | 'crossfire', input: unknown, config: ProviderConfig, - options: AIRequestOptions = {}, -) { + options: AIRequestOptions = {}, +): Promise { const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const startTime = performance.now(); @@ -342,31 +375,15 @@ async function callSupabaseAI( }); const reportFailure = (error: unknown, userInitiatedAbort = false): void => { - const classification = classifyAIError(error, userInitiatedAbort); - const durationMs = Math.round(performance.now() - startTime); - const properties = { + reportAIFailure({ + error, feature, provider, model: config.model, - durationMs, - errorName: classification.errorName, - statusCode: classification.statusCode, - }; - captureProductEvent('ai_request_failed', properties); - - if (classification.shouldCaptureException) { - const safeError = new Error(classification.errorName); - safeError.name = classification.errorName; - captureException(safeError, { - feature, - provider, - model: config.model, - durationMs, - errorName: classification.errorName, - statusCode: classification.statusCode, - requestId, - }); - } + startTime, + requestId, + userInitiatedAbort, + }); }; const controller = new AbortController(); @@ -486,7 +503,7 @@ async function callSupabaseAI( }); } - const response = options.transformResponse ? options.transformResponse(data) : data; + const response = options.transformResponse ? options.transformResponse(data) : data as T; captureProductEvent('ai_request_succeeded', { feature, provider, @@ -630,7 +647,7 @@ export async function crossfire( }); } - return await callSupabaseAI( + return await callSupabaseAI( 'crossfire', input.data as unknown as Record, config, @@ -663,7 +680,7 @@ export async function crossfire( return debrief.data; }, }, - ) as CrossfireReply | CrossfireDebrief; + ); } export function mapAIError( @@ -1058,7 +1075,7 @@ export async function generateCase(input: GenerateInput, config: ProviderConfig) // Inject global language into input for the edge function const language = useSettingsStore.getState().outputLanguage; const inputWithLanguage = { ...input, language } as Record; - const response = await callSupabaseAI('generate', inputWithLanguage, config, { + const response = await callSupabaseAI>('generate', inputWithLanguage, config, { transformResponse: (rawData) => { const data = rawData as { output: unknown; discoveredSources?: unknown }; const result = validateGenerateOutput(transformGenerateResponse(data.output as Record)); @@ -1070,14 +1087,14 @@ export async function generateCase(input: GenerateInput, config: ProviderConfig) } return { result, discoveredSources: parseDiscoveredSources(data.discoveredSources) }; }, - }) as ResultWithSources; + }); await autosaveDiscoveredSources(response.discoveredSources, 'generate'); return response; } export async function attackCase(input: AttackInput, config: ProviderConfig): Promise> { if (config.provider === 'mock') return { result: await mockAttackCase(input.caseText), discoveredSources: [] }; - const response = await callSupabaseAI('attack', input as Record, config, { + const response = await callSupabaseAI>('attack', input as Record, config, { transformResponse: (rawData) => { const data = rawData as { output: unknown; discoveredSources?: unknown }; return { @@ -1085,14 +1102,14 @@ export async function attackCase(input: AttackInput, config: ProviderConfig): Pr discoveredSources: parseDiscoveredSources(data.discoveredSources), }; }, - }) as ResultWithSources; + }); await autosaveDiscoveredSources(response.discoveredSources, 'attack'); return response; } export async function brainstorm(input: BrainstormInput, config: ProviderConfig): Promise> { if (config.provider === 'mock') return { result: await mockBrainstorm(input.motion, input.outputMode), discoveredSources: [] }; - const response = await callSupabaseAI('brainstorm', input as Record, config, { + const response = await callSupabaseAI>('brainstorm', input as Record, config, { transformResponse: (rawData) => { const data = rawData as { output: unknown; discoveredSources?: unknown }; return { @@ -1100,7 +1117,7 @@ export async function brainstorm(input: BrainstormInput, config: ProviderConfig) discoveredSources: parseDiscoveredSources(data.discoveredSources), }; }, - }) as ResultWithSources; + }); await autosaveDiscoveredSources(response.discoveredSources, 'brainstorm'); return response; } @@ -1115,7 +1132,7 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig): Pr sourceCount: input.sourceCount || 5, language, } as Record; - return await callSupabaseAI('factoids', inputWithLanguage, config, { + return await callSupabaseAI('factoids', inputWithLanguage, config, { transformResponse: (rawData) => { const data = rawData as { output: unknown }; const parsed = FactoidsOutputSchema.safeParse(data.output); @@ -1134,7 +1151,7 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig): Pr filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length), }; }, - }) as FactoidsOutput; + }); } export interface AskChatOptions { @@ -1153,7 +1170,7 @@ export async function askChat( ): Promise { const output = config.provider === 'mock' ? await mockAsk(message, mode) - : await callSupabaseAI('ask', { + : await callSupabaseAI('ask', { message, history, mode, @@ -1173,7 +1190,7 @@ export async function askChat( return parsed.data; }, }); - return output as AskOutput; + return output; } function sparringLanguage(language: string): string { @@ -1262,9 +1279,9 @@ async function generateSparringTurnOnce( return validateSparringTurnResponse(await mockGenerateSparringTurn(inputResult.data)); } - return await callSupabaseAI('sparring', inputResult.data, config, { + return await callSupabaseAI('sparring', inputResult.data, config, { transformResponse: (rawData) => validateSparringTurnResponse((rawData as { output: unknown }).output), - }) as SparringTurnResponse; + }); } /** Judge a completed sparring session and return its validated scorecard. */ @@ -1314,7 +1331,7 @@ async function judgeSparringSessionOnce( return validateSparringScoreResponse(await mockJudgeSparringSession(inputResult.data)); } - return await callSupabaseAI('sparring', inputResult.data, config, { + return await callSupabaseAI('sparring', inputResult.data, config, { transformResponse: (rawData) => validateSparringScoreResponse((rawData as { output: unknown }).output), - }) as SparringScoreResponse; + }); } diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index 49ccb718a..96ea61c6c 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -1,6 +1,6 @@ import { supabase, SUPABASE_URL } from '@/lib/supabase/client'; -import { captureException, captureProductEvent } from '@/lib/observability'; -import { AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, classifyAIError, mapAIError } from './client'; +import { captureProductEvent } from '@/lib/observability'; +import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, mapAIError, reportAIFailure } from './client'; import type { ProviderConfig } from './providers/types'; import { AskOutputSchema, type AskOutput } from './schemas/ask'; @@ -92,25 +92,19 @@ function captureStreamFailure( provider: string, model: string, startTime: number, + requestId: string, callerAborted: boolean, ): void { - const classification = classifyAIError(error, callerAborted); - const properties: Record = { + reportAIFailure({ + error, feature: 'ask', streaming: true, provider, model, - durationMs: Math.round(performance.now() - startTime), - errorName: classification.errorName, - statusCode: classification.statusCode, - }; - - captureProductEvent('ai_request_failed', properties); - if (classification.shouldCaptureException) { - const safeError = new Error(classification.errorName); - safeError.name = classification.errorName; - captureException(safeError, properties); - } + startTime, + requestId, + userInitiatedAbort: callerAborted, + }); } export async function streamAskChat( @@ -133,7 +127,7 @@ export async function streamAskChat( if (handlers.signal?.aborted) { const error = abortError(); - captureStreamFailure(error, provider, config.model, startTime, true); + captureStreamFailure(error, provider, config.model, startTime, requestId, true); throw error; } @@ -141,19 +135,18 @@ export async function streamAskChat( try { ({ data: sessionData } = await waitForCaller(supabase.auth.getSession(), handlers.signal)); } catch (error) { - captureStreamFailure(error, provider, config.model, startTime, Boolean(handlers.signal?.aborted)); + captureStreamFailure(error, provider, config.model, startTime, requestId, Boolean(handlers.signal?.aborted)); throw error; } const accessToken = sessionData.session?.access_token; if (!accessToken) { - const error = new Error('Please sign in to use AI features'); - error.name = 'AIAuthError'; - captureStreamFailure(error, provider, config.model, startTime, Boolean(handlers.signal?.aborted)); + const error = new AIAuthError('Please sign in to use AI features', { requestId }); + captureStreamFailure(error, provider, config.model, startTime, requestId, Boolean(handlers.signal?.aborted)); throw error; } if (handlers.signal?.aborted) { const error = abortError(); - captureStreamFailure(error, provider, config.model, startTime, true); + captureStreamFailure(error, provider, config.model, startTime, requestId, true); throw error; } @@ -284,7 +277,7 @@ export async function streamAskChat( : controller.signal.aborted ? new TimeoutError() : error; - captureStreamFailure(reportedError, provider, config.model, startTime, callerAborted); + captureStreamFailure(reportedError, provider, config.model, startTime, requestId, callerAborted); if (callerAborted) throw reportedError; if (controller.signal.aborted) throw reportedError; throw error; diff --git a/src/lib/export-utils.ts b/src/lib/export-utils.ts index d4d90d589..b13b52dac 100644 --- a/src/lib/export-utils.ts +++ b/src/lib/export-utils.ts @@ -5,7 +5,7 @@ import type { BrainstormOutput } from './ai/schemas/brainstorm'; import type { FactoidsOutput } from './ai/schemas/factoids'; import type { SparringSession } from './stores/sparring-store'; import { formatEuropeanDate, formatEuropeanDateTime } from './date-format'; -import { captureProductEvent } from './observability'; +import { captureProductEvent } from '@/lib/observability'; function formatSparringDuration(seconds: number): string { const totalSeconds = Math.max(0, Math.round(seconds)); diff --git a/src/lib/observability.ts b/src/lib/observability.ts index 7babae8ce..862e3dd09 100644 --- a/src/lib/observability.ts +++ b/src/lib/observability.ts @@ -3,6 +3,25 @@ import posthog from "posthog-js"; let initialized = false; +interface ConsoleBreadcrumb { + category?: string; + level?: string; + message?: string; + data?: { arguments?: unknown }; +} + +/** Drops React's caught-render console entry without suppressing unrelated errors. */ +export function filterObservabilityBreadcrumb(breadcrumb: T): T | null { + if (breadcrumb.category !== "console" || breadcrumb.level !== "error") return breadcrumb; + const args = Array.isArray(breadcrumb.data?.arguments) ? breadcrumb.data.arguments : []; + const text = [breadcrumb.message, ...args] + .filter((value): value is string => typeof value === "string") + .join("\n"); + const isReactCaughtRenderError = text.includes("The above error occurred in the <") + || text.includes("React will try to recreate this component tree from scratch using the error boundary"); + return isReactCaughtRenderError ? null : breadcrumb; +} + export function initializeObservability(): void { if (initialized) return; initialized = true; @@ -14,6 +33,7 @@ export function initializeObservability(): void { environment: import.meta.env.MODE, sendDefaultPii: false, tracesSampleRate: 0.1, + beforeBreadcrumb: filterObservabilityBreadcrumb, }); } diff --git a/src/lib/pdf-export-utils.ts b/src/lib/pdf-export-utils.ts index 111ca73bd..0cd4ccbeb 100644 --- a/src/lib/pdf-export-utils.ts +++ b/src/lib/pdf-export-utils.ts @@ -7,7 +7,7 @@ import type { BrainstormOutput } from './ai/schemas/brainstorm'; import type { FactoidsOutput } from './ai/schemas/factoids'; import type { SparringSession } from './stores/sparring-store'; import { formatEuropeanDate } from './date-format'; -import { captureProductEvent } from './observability'; +import { captureProductEvent } from '@/lib/observability'; // Color constants for consistent branding const COLORS = { diff --git a/src/locales/en.json b/src/locales/en.json index db537337d..92c4cdc51 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1054,7 +1054,7 @@ "privacyPage": { "title": "Privacy Policy", "lastUpdated": "Last updated", - "date": "May 1, 2026", + "date": "August 31, 2026", "s1": "Information We Collect", "p1": "We collect information you provide when creating an account (name, email via Google OAuth) and usage data including generated content, saved items, and interaction patterns.", "s2": "How We Use Your Information", @@ -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 analytics to improve service quality.", + "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.", "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 539102349..9d668d8f4 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1054,7 +1054,7 @@ "privacyPage": { "title": "Política de Privacidad", "lastUpdated": "Última actualización", - "date": "1 de mayo de 2026", + "date": "31 de agosto de 2026", "s1": "Información que Recopilamos", "p1": "Recopilamos información que proporcionas al crear una cuenta (nombre, email vía Google OAuth) y datos de uso incluyendo contenido generado, elementos guardados y patrones de interacción.", "s2": "Cómo Usamos Tu Información", @@ -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 autenticación y preferencias. Usamos analíticas para mejorar la calidad del servicio.", + "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.", "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 0616ec5bd..5d3f81807 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1054,7 +1054,7 @@ "privacyPage": { "title": "Adatvédelmi Irányelvek", "lastUpdated": "Utolsó frissítés", - "date": "2026. május 1.", + "date": "2026. augusztus 31.", "s1": "Gyűjtött Információk", "p1": "Gyűjtjük a fiók létrehozásakor megadott adatokat (név, email Google OAuth-on keresztül) és használati adatokat, beleértve a generált tartalmat, mentett elemeket és interakciós mintákat.", "s2": "Hogyan Használjuk az Információidat", @@ -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 beállításokhoz. Analitikát használunk a szolgáltatás minőségének javítására.", + "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.", "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/ai/client-analytics.test.ts b/src/test/ai/client-analytics.test.ts index 893f08ed1..3eb23328b 100644 --- a/src/test/ai/client-analytics.test.ts +++ b/src/test/ai/client-analytics.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ProviderConfig } from '@/lib/ai/providers/types'; import type { GenerateInput, GenerateOutput } from '@/lib/ai/schemas/generate'; +import type { CrossfireAIInput } from '@/lib/crossfire/schemas'; const mocks = vi.hoisted(() => ({ getSession: vi.fn(), @@ -79,7 +80,7 @@ const output: GenerateOutput = { metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' }, }; -function nextTurnInput() { +function nextTurnInput(): CrossfireAIInput { return { operation: 'next-turn' as const, config: { @@ -147,7 +148,9 @@ describe('non-streaming AI request analytics', () => { expect.objectContaining({ name: 'AIProviderError', message: 'AIProviderError' }), expect.objectContaining({ feature: 'generate', provider: 'openai', model: 'gpt-4o' }), ); - expect(JSON.stringify(mocks.captureException.mock.calls[0])).not.toContain(input.motion); + const [capturedError, capturedContext] = mocks.captureException.mock.calls[0]; + expect(capturedError).toMatchObject({ message: 'AIProviderError' }); + expect(JSON.stringify(capturedContext)).not.toContain(input.motion); }); it('skips Sentry for expected auth failures', async () => { @@ -196,7 +199,9 @@ describe('non-streaming AI request analytics', () => { errorName: 'AIError', })); expect(mocks.captureException).toHaveBeenCalledTimes(1); - expect(JSON.stringify(mocks.captureException.mock.calls[0])).not.toContain(input.motion); + const [capturedError, capturedContext] = mocks.captureException.mock.calls[0]; + expect(capturedError).toMatchObject({ message: 'AIError' }); + expect(JSON.stringify(capturedContext)).not.toContain(input.motion); }); it('does not send caller aborts to Sentry', async () => { diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index a400c28b5..83c659f7c 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -178,7 +178,9 @@ describe('AI streaming helpers', () => { expect.objectContaining({ name: 'AIProviderError' }), expect.objectContaining({ errorName: 'AIProviderError', statusCode: 500 }), ); - expect(JSON.stringify(captureExceptionMock.mock.calls)).not.toContain('private question'); + const [capturedError, capturedContext] = captureExceptionMock.mock.calls[0]; + expect(capturedError).toMatchObject({ message: 'AIProviderError' }); + expect(JSON.stringify(capturedContext)).not.toContain('private question'); }); it.each([ diff --git a/src/test/app-route-analytics-error-boundary.test.tsx b/src/test/app-route-analytics-error-boundary.test.tsx index 4e1f6f305..ee253b9be 100644 --- a/src/test/app-route-analytics-error-boundary.test.tsx +++ b/src/test/app-route-analytics-error-boundary.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { RouteErrorBoundary, ToolRouteAnalytics } from "@/App"; import { RequireAuth } from "@/components/auth/RequireAuth"; +import { filterObservabilityBreadcrumb } from "@/lib/observability"; const observability = vi.hoisted(() => ({ captureException: vi.fn(), @@ -15,7 +16,10 @@ const authState = vi.hoisted(() => ({ user: null as object | null, })); -vi.mock("@/lib/observability", () => observability); +vi.mock("@/lib/observability", async (importOriginal) => ({ + ...(await importOriginal()), + ...observability, +})); vi.mock("@/lib/supabase/AuthProvider", async () => { const React = await import("react"); return { @@ -158,6 +162,12 @@ describe("App route observability", () => { ); expect(JSON.stringify(observability.captureException.mock.calls)).not.toContain(error.message); expect(observability.captureException.mock.calls[0][1]).not.toHaveProperty("componentStack"); + expect(JSON.stringify(consoleError.mock.calls)).not.toContain(error.message); + const retainedBreadcrumbs = consoleError.mock.calls + .map((args) => filterObservabilityBreadcrumb({ category: "console", level: "error", data: { arguments: args } })) + .filter((breadcrumb) => breadcrumb !== null); + expect(retainedBreadcrumbs.flatMap((breadcrumb) => breadcrumb.data?.arguments ?? [])).not.toContain(error); + expect(JSON.stringify(retainedBreadcrumbs)).not.toContain("at ExplodingRoute"); } finally { window.removeEventListener("error", suppressWindowError); consoleError.mockRestore(); diff --git a/src/test/i18n.test.ts b/src/test/i18n.test.ts index c9bc2ea12..a9c171b86 100644 --- a/src/test/i18n.test.ts +++ b/src/test/i18n.test.ts @@ -90,6 +90,13 @@ describe('i18n locale files', () => { const empty = Object.entries(values).filter(([, v]) => v === ''); expect(empty).toEqual([]); }); + + it('names both observability processors in every privacy policy translation', () => { + for (const locale of [en, es, hu]) { + expect(locale.privacyPage.p7).toContain('PostHog'); + expect(locale.privacyPage.p7).toContain('Sentry'); + } + }); }); describe('i18n runtime', () => { diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts index 0a2231a76..199ca39fb 100644 --- a/src/test/observability.test.ts +++ b/src/test/observability.test.ts @@ -22,6 +22,7 @@ vi.mock("posthog-js", () => ({ import { captureException, captureProductEvent, + filterObservabilityBreadcrumb, initializeObservability, } from "@/lib/observability"; @@ -96,6 +97,28 @@ describe("observability primitives", () => { expect(() => captureException(new Error("synthetic test error"))).not.toThrow(); }); + it("drops React error-boundary console breadcrumbs but preserves other console errors", () => { + const reactBreadcrumb = { + category: "console", + level: "error", + data: { + arguments: [ + new Error("private descendant message"), + "The above error occurred in the component:\n at PrivateRoute", + "React will try to recreate this component tree from scratch using the error boundary you provided.", + ], + }, + }; + const ordinaryBreadcrumb = { + category: "console", + level: "error", + data: { arguments: ["ordinary application error"] }, + }; + + expect(filterObservabilityBreadcrumb(reactBreadcrumb)).toBeNull(); + expect(filterObservabilityBreadcrumb(ordinaryBreadcrumb)).toBe(ordinaryBreadcrumb); + }); + it("initializes both SDKs once with privacy-preserving options", () => { vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key"); vi.stubEnv("VITE_POSTHOG_HOST", "https://posthog.example"); @@ -108,6 +131,7 @@ describe("observability primitives", () => { expect(sentryInitMock).toHaveBeenCalledWith(expect.objectContaining({ dsn: "https://public-test@sentry.example/1", sendDefaultPii: false, + beforeBreadcrumb: filterObservabilityBreadcrumb, })); expect(posthogInitMock).toHaveBeenCalledOnce(); expect(posthogInitMock).toHaveBeenCalledWith("posthog-test-key", expect.objectContaining({