From 02e5fcd23606e7ee3ea383ff4915a395e344f84c Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 15:22:17 +0000 Subject: [PATCH 1/4] feat: instrument product analytics and AI errors --- src/App.tsx | 53 +++- src/lib/ai/client.ts | 271 ++++++++++++------ src/lib/ai/error-display.ts | 2 - src/lib/ai/streaming.ts | 75 ++++- src/lib/export-utils.ts | 7 + src/lib/observability.ts | 14 +- src/lib/pdf-export-utils.ts | 6 + src/lib/stores/sparring-store.ts | 16 ++ src/test/ai/client-analytics.test.ts | 235 +++++++++++++++ src/test/ai/streaming.test.ts | 199 ++++++++++++- ...pp-route-analytics-error-boundary.test.tsx | 117 ++++++++ src/test/export-telemetry.test.ts | 193 +++++++++++++ src/test/observability.test.ts | 121 ++++++++ src/test/stores/sparring-store.test.ts | 57 ++++ 14 files changed, 1263 insertions(+), 103 deletions(-) create mode 100644 src/test/ai/client-analytics.test.ts create mode 100644 src/test/app-route-analytics-error-boundary.test.tsx create mode 100644 src/test/export-telemetry.test.ts create mode 100644 src/test/observability.test.ts diff --git a/src/App.tsx b/src/App.tsx index b1f134914..017a633a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ // src/App.tsx -import { Component, Suspense, lazy, type ErrorInfo, type ReactNode } from "react"; +import { Component, Suspense, lazy, useEffect, type ErrorInfo, type ReactNode } from "react"; import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -14,6 +14,7 @@ import { ThemeProvider } from "@/components/ThemeProvider"; import { useSessionStorageSync } from "@/hooks/use-session-storage-sync"; import { Loader2 } from "lucide-react"; import { OfflineIndicator } from "@/components/OfflineIndicator"; +import { captureException, captureProductEvent } from "@/lib/observability"; // Lazy load all pages for code splitting const LandingPage = lazy(() => import("./pages/LandingPage")); @@ -40,6 +41,41 @@ const NotFound = lazy(() => import("./pages/NotFound")); const queryClient = new QueryClient(); +type ToolName = + | "generate" + | "attack" + | "brainstorm" + | "factoids" + | "ask" + | "crossfire" + | "history" + | "library" + | "timer" + | "sparring" + | "flowchart" + | "settings" + | "admin"; + +const TOOL_BY_ROUTE = new Map([ + ["/app/generate", "generate"], + ["/app/attack", "attack"], + ["/app/brainstorm", "brainstorm"], + ["/app/factoids", "factoids"], + ["/app/ask", "ask"], + ["/app/crossfire", "crossfire"], + ["/app/history", "history"], + ["/app/library", "library"], + ["/app/timer", "timer"], + ["/app/sparring", "sparring"], + ["/app/flowchart", "flowchart"], + ["/app/settings", "settings"], + ["/app/admin", "admin"], +]); + +function getToolForPathname(pathname: string): ToolName | undefined { + return TOOL_BY_ROUTE.get(pathname); +} + // Loading fallback component const PageLoader = () => (
@@ -55,7 +91,7 @@ const PageLoader = () => (
); -class RouteErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { +export class RouteErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { state = { failed: false }; static getDerivedStateFromError() { @@ -64,6 +100,7 @@ class RouteErrorBoundary extends Component<{ children: ReactNode }, { failed: bo componentDidCatch(error: Error, info: ErrorInfo) { console.error('Route rendering failed', error, info); + captureException(error, { boundary: "route" }); } render() { @@ -81,6 +118,17 @@ class RouteErrorBoundary extends Component<{ children: ReactNode }, { failed: bo } } +export function ToolRouteAnalytics(): null { + const location = useLocation(); + + useEffect(() => { + const tool = getToolForPathname(location.pathname); + if (tool) captureProductEvent("tool_opened", { tool }); + }, [location.pathname]); + + return null; +} + function AnimatedRoutes() { const location = useLocation(); const shouldReduceMotion = useReducedMotion(); @@ -96,6 +144,7 @@ function AnimatedRoutes() { transition={{ duration: shouldReduceMotion ? 0 : 0.16, ease: [0.22, 1, 0.36, 1] }} className="min-h-screen motion-reduce:transform-none" > + {/* Public Routes */} } /> diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index 15f940c4b..3af1869ee 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -27,6 +27,7 @@ import { useSessionStore } from '../stores/session-store'; import type { SourceProvenance } from '../stores/session-store'; import { supabase } from '@/lib/supabase/client'; import { toast } from '@/hooks/use-toast'; +import { captureException, captureProductEvent } from '@/lib/observability'; import { CrossfireAIInputSchema, CrossfireDebriefSchema, @@ -50,6 +51,7 @@ interface AIRequestOptions { includeCustomSystemPrompt?: boolean; timeoutMs?: number; signal?: AbortSignal; + transformResponse?: (data: unknown) => unknown; } function waitForAbortable(operation: () => PromiseLike, signal: AbortSignal): Promise { @@ -286,6 +288,31 @@ export function isAIModelRestrictionError(error: unknown): error is AIModelRestr return error instanceof AIModelRestrictionError || (error instanceof Error && error.name === 'AIModelRestrictionError'); } +export interface AIErrorClassification { + errorName: string; + statusCode?: number; + shouldCaptureException: boolean; +} + +/** Classifies AI failures into stable, privacy-safe telemetry fields. */ +export function classifyAIError(error: unknown, userInitiatedAbort = false): AIErrorClassification { + const statusCode = error instanceof AIError ? error.statusCode : undefined; + const errorName = error instanceof Error ? error.name : 'UnknownError'; + + if (userInitiatedAbort) return { errorName: 'AbortError', shouldCaptureException: false }; + if (isAIAuthError(error)) return { errorName: 'AIAuthError', statusCode, shouldCaptureException: false }; + if (isAIRateLimitError(error)) return { errorName: 'AIRateLimitError', statusCode, shouldCaptureException: false }; + if (isAISubscriptionError(error)) return { errorName: 'AISubscriptionError', statusCode, shouldCaptureException: false }; + if (isAIModelRestrictionError(error)) return { errorName: 'AIModelRestrictionError', statusCode, shouldCaptureException: false }; + if (isTimeoutError(error)) return { errorName: 'TimeoutError', statusCode, shouldCaptureException: true }; + if (error instanceof AIResponseError || (error instanceof Error && error.name === 'AIResponseError')) { + return { errorName: 'AIResponseError', statusCode, shouldCaptureException: true }; + } + if (isAIProviderError(error)) return { errorName: 'AIProviderError', statusCode, shouldCaptureException: true }; + + return { errorName, statusCode, shouldCaptureException: true }; +} + async function callSupabaseAI( feature: 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring' | 'crossfire', input: unknown, @@ -308,6 +335,40 @@ async function callSupabaseAI( : selectedSources; const provider = config.provider === 'lovable' ? 'openai' : config.provider; + captureProductEvent('ai_request_started', { + feature, + provider, + model: config.model, + }); + + const reportFailure = (error: unknown, userInitiatedAbort = false): void => { + const classification = classifyAIError(error, userInitiatedAbort); + const durationMs = Math.round(performance.now() - startTime); + const properties = { + 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, + }); + } + }; + const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); @@ -425,27 +486,40 @@ async function callSupabaseAI( }); } - return data; + const response = options.transformResponse ? options.transformResponse(data) : data; + captureProductEvent('ai_request_succeeded', { + feature, + provider, + model: config.model, + durationMs: Math.round(performance.now() - startTime), + }); + return response; } catch (err) { // Re-throw our custom errors if (err instanceof AIError) { + reportFailure(err); throw err; } // Handle timeout if (err instanceof TimeoutError) { + reportFailure(err); throw err; } // Handle abort (our timeout triggered) if (options.signal?.aborted) { - throw options.signal.reason instanceof Error + const abortError = options.signal.reason instanceof Error ? options.signal.reason : new DOMException('The request was canceled.', 'AbortError'); + reportFailure(abortError, true); + throw abortError; } if (err instanceof DOMException && err.name === 'AbortError') { - throw new TimeoutError(); + const timeoutError = new TimeoutError(); + reportFailure(timeoutError); + throw timeoutError; } // Wrap unknown errors @@ -453,10 +527,14 @@ async function callSupabaseAI( // Check if the error message indicates an abort/timeout if (message.includes('abort') || message.includes('signal')) { - throw new TimeoutError(); + const timeoutError = new TimeoutError(); + reportFailure(timeoutError); + throw timeoutError; } - throw new AIError(message, { requestId }); + const wrappedError = new AIError(message, { requestId }); + reportFailure(wrappedError); + throw wrappedError; } finally { clearTimeout(timeoutId); options.signal?.removeEventListener('abort', abortFromCaller); @@ -552,7 +630,7 @@ export async function crossfire( }); } - const data = await callSupabaseAI( + return await callSupabaseAI( 'crossfire', input.data as unknown as Record, config, @@ -561,29 +639,31 @@ export async function crossfire( includeCustomSystemPrompt: false, timeoutMs: CROSSFIRE_REQUEST_TIMEOUT_MS, signal: options.signal, - }, - ) as { output: unknown }; - - if (input.data.operation === 'next-turn') { - const reply = CrossfireReplySchema.safeParse(data.output); - if (!reply.success || reply.data.type !== input.data.expectedReply) { - throw new AIResponseError('AI response did not match the expected Crossfire turn.', { - details: reply.success - ? { expectedReply: input.data.expectedReply, actualReply: reply.data.type } - : { issues: reply.error.issues }, - }); - } - return reply.data; - } + transformResponse: (rawData) => { + const data = rawData as { output: unknown }; + if (input.data.operation === 'next-turn') { + const reply = CrossfireReplySchema.safeParse(data.output); + if (!reply.success || reply.data.type !== input.data.expectedReply) { + throw new AIResponseError('AI response did not match the expected Crossfire turn.', { + details: reply.success + ? { expectedReply: input.data.expectedReply, actualReply: reply.data.type } + : { issues: reply.error.issues }, + }); + } + return reply.data; + } - const debrief = CrossfireDebriefSchema.safeParse(data.output); - if (!debrief.success) { - throw new AIResponseError('AI response did not match the expected Crossfire debrief.', { - details: { issues: debrief.error.issues }, - }); - } - validateCrossfireTurnReferences(input.data, debrief.data); - return debrief.data; + const debrief = CrossfireDebriefSchema.safeParse(data.output); + if (!debrief.success) { + throw new AIResponseError('AI response did not match the expected Crossfire debrief.', { + details: { issues: debrief.error.issues }, + }); + } + validateCrossfireTurnReferences(input.data, debrief.data); + return debrief.data; + }, + }, + ) as CrossfireReply | CrossfireDebrief; } export function mapAIError( @@ -978,47 +1058,51 @@ 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 data = await callSupabaseAI('generate', inputWithLanguage, config) as { output: unknown; discoveredSources?: unknown }; - const result = validateGenerateOutput(transformGenerateResponse(data.output as Record)); - const expectedCount = expectedContentionCount(input.length); - if (result.contentions.length !== expectedCount) { - throw new AIResponseError('AI response did not contain the expected number of contentions.', { - details: { expectedCount, actualCount: result.contentions.length }, - }); - } - - const discovered = parseDiscoveredSources(data.discoveredSources); - await autosaveDiscoveredSources(discovered, 'generate'); - - return { - result, - discoveredSources: discovered, - }; + 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)); + const expectedCount = expectedContentionCount(input.length); + if (result.contentions.length !== expectedCount) { + throw new AIResponseError('AI response did not contain the expected number of contentions.', { + details: { expectedCount, actualCount: result.contentions.length }, + }); + } + 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 data = await callSupabaseAI('attack', input as Record, config) as { output: unknown; discoveredSources?: unknown }; - const result = validateAttackOutput(data.output); - const discovered = parseDiscoveredSources(data.discoveredSources); - await autosaveDiscoveredSources(discovered, 'attack'); - - return { - result, - discoveredSources: discovered, - }; + const response = await callSupabaseAI('attack', input as Record, config, { + transformResponse: (rawData) => { + const data = rawData as { output: unknown; discoveredSources?: unknown }; + return { + result: validateAttackOutput(data.output), + 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 data = await callSupabaseAI('brainstorm', input as Record, config) as { output: unknown; discoveredSources?: unknown }; - const discovered = parseDiscoveredSources(data.discoveredSources); - await autosaveDiscoveredSources(discovered, 'brainstorm'); - - return { - result: transformBrainstormResponse(data.output as Record), - discoveredSources: discovered, - }; + const response = await callSupabaseAI('brainstorm', input as Record, config, { + transformResponse: (rawData) => { + const data = rawData as { output: unknown; discoveredSources?: unknown }; + return { + result: transformBrainstormResponse(data.output as Record), + discoveredSources: parseDiscoveredSources(data.discoveredSources), + }; + }, + }) as ResultWithSources; + await autosaveDiscoveredSources(response.discoveredSources, 'brainstorm'); + return response; } export async function factoids(input: FactoidsInput, config: ProviderConfig): Promise { @@ -1031,22 +1115,26 @@ export async function factoids(input: FactoidsInput, config: ProviderConfig): Pr sourceCount: input.sourceCount || 5, language, } as Record; - const data = await callSupabaseAI('factoids', inputWithLanguage, config) as { output: unknown }; - const parsed = FactoidsOutputSchema.safeParse(data.output); - if (!parsed.success) { - throw new AIResponseError('AI response did not match the expected factoids schema.', { - details: { issues: parsed.error.issues }, - }); - } + return await callSupabaseAI('factoids', inputWithLanguage, config, { + transformResponse: (rawData) => { + const data = rawData as { output: unknown }; + const parsed = FactoidsOutputSchema.safeParse(data.output); + if (!parsed.success) { + throw new AIResponseError('AI response did not match the expected factoids schema.', { + details: { issues: parsed.error.issues }, + }); + } - const allFactoids = parsed.data.factoids; - const verifiedFactoids = allFactoids.filter((factoid) => factoid.citation.verified === 'verified'); - const returnedFactoids = verifiedFactoids.slice(0, input.sourceCount || 5); - return { - ...parsed.data, - factoids: returnedFactoids, - filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length), - }; + const allFactoids = parsed.data.factoids; + const verifiedFactoids = allFactoids.filter((factoid) => factoid.citation.verified === 'verified'); + const returnedFactoids = verifiedFactoids.slice(0, input.sourceCount || 5); + return { + ...parsed.data, + factoids: returnedFactoids, + filteredOutCount: Math.max(0, allFactoids.length - returnedFactoids.length), + }; + }, + }) as FactoidsOutput; } export interface AskChatOptions { @@ -1065,7 +1153,7 @@ export async function askChat( ): Promise { const output = config.provider === 'mock' ? await mockAsk(message, mode) - : (await callSupabaseAI('ask', { + : await callSupabaseAI('ask', { message, history, mode, @@ -1073,14 +1161,19 @@ export async function askChat( webSearch: Boolean(options.webSearch), prosConsMode: Boolean(options.prosConsMode), argumentMapMode: Boolean(options.argumentMapMode), - }, config) as { output: unknown }).output; - const parsed = AskOutputSchema.safeParse(output); - if (!parsed.success) { - throw new AIResponseError('AI response did not match the expected ask schema.', { - details: { issues: parsed.error.issues }, + }, config, { + transformResponse: (rawData) => { + const data = rawData as { output: unknown }; + const parsed = AskOutputSchema.safeParse(data.output); + if (!parsed.success) { + throw new AIResponseError('AI response did not match the expected ask schema.', { + details: { issues: parsed.error.issues }, + }); + } + return parsed.data; + }, }); - } - return parsed.data; + return output as AskOutput; } function sparringLanguage(language: string): string { @@ -1169,8 +1262,9 @@ async function generateSparringTurnOnce( return validateSparringTurnResponse(await mockGenerateSparringTurn(inputResult.data)); } - const data = await callSupabaseAI('sparring', inputResult.data, config) as { output: unknown }; - return validateSparringTurnResponse(data.output); + 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. */ @@ -1220,6 +1314,7 @@ async function judgeSparringSessionOnce( return validateSparringScoreResponse(await mockJudgeSparringSession(inputResult.data)); } - const data = await callSupabaseAI('sparring', inputResult.data, config) as { output: unknown }; - return validateSparringScoreResponse(data.output); + return await callSupabaseAI('sparring', inputResult.data, config, { + transformResponse: (rawData) => validateSparringScoreResponse((rawData as { output: unknown }).output), + }) as SparringScoreResponse; } diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index fa2b73b77..2e1ff9068 100644 --- a/src/lib/ai/error-display.ts +++ b/src/lib/ai/error-display.ts @@ -1,5 +1,4 @@ import { AIProviderError } from './client'; -import { captureException } from '@/lib/observability'; const PROVIDER_LABELS: Record = { openai: 'OpenAI', @@ -59,7 +58,6 @@ export function getUserFacingAIError(error: unknown): string { return `${provider} blocked this request for safety reasons. Try rephrasing your input.`; } - captureException(error, { provider: (error as AIProviderError).provider, category: 'unmapped_provider_error' }); return 'The AI provider returned an unexpected error. Please try again later.'; } diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index 90daaa6c6..2b06685e5 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -1,5 +1,6 @@ import { supabase, SUPABASE_URL } from '@/lib/supabase/client'; -import { AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, mapAIError } from './client'; +import { captureException, captureProductEvent } from '@/lib/observability'; +import { AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, classifyAIError, mapAIError } from './client'; import type { ProviderConfig } from './providers/types'; import { AskOutputSchema, type AskOutput } from './schemas/ask'; @@ -69,6 +70,32 @@ export function consumeSSEChunk(buffer: string, chunk: string): { events: Parsed return { events, buffer: rest }; } +function captureStreamFailure( + error: unknown, + provider: string, + model: string, + startTime: number, + callerAborted: boolean, +): void { + const classification = classifyAIError(error, callerAborted); + const properties: Record = { + 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); + } +} + export async function streamAskChat( message: string, history: { role: 'user' | 'assistant'; content: string }[], @@ -77,19 +104,42 @@ export async function streamAskChat( options: AskStreamOptions, handlers: AskStreamHandlers ): Promise { + const provider = config.provider === 'lovable' ? 'openai' : config.provider; + const requestId = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const startTime = performance.now(); + captureProductEvent('ai_request_started', { + feature: 'ask', + streaming: true, + provider, + model: config.model, + }); + if (handlers.signal?.aborted) { - throw new DOMException('Aborted', 'AbortError'); + const error = new DOMException('Aborted', 'AbortError'); + captureStreamFailure(error, provider, config.model, startTime, true); + throw error; } - const { data: sessionData } = await supabase.auth.getSession(); + let sessionData: Awaited>['data']; + try { + ({ data: sessionData } = await supabase.auth.getSession()); + } catch (error) { + captureStreamFailure(error, provider, config.model, startTime, Boolean(handlers.signal?.aborted)); + throw error; + } const accessToken = sessionData.session?.access_token; - if (!accessToken) throw new Error('Please sign in to use AI features'); + 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)); + throw error; + } if (handlers.signal?.aborted) { + const error = new DOMException('Aborted', 'AbortError'); + captureStreamFailure(error, provider, config.model, startTime, true); throw new DOMException('Aborted', 'AbortError'); } - const provider = config.provider === 'lovable' ? 'openai' : config.provider; - const requestId = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const abortForCaller = () => controller.abort(); @@ -200,10 +250,21 @@ export async function streamAskChat( throw new AIResponseError('Streaming request ended before a final response was received', { requestId }); } + captureProductEvent('ai_request_succeeded', { + feature: 'ask', + streaming: true, + provider, + model: config.model, + durationMs: Math.round(performance.now() - startTime), + }); + return finalOutput; } catch (error) { + const callerAborted = Boolean(handlers.signal?.aborted); + const reportedError = !callerAborted && controller.signal.aborted ? new TimeoutError() : error; + captureStreamFailure(reportedError, provider, config.model, startTime, callerAborted); if (handlers.signal?.aborted) throw error; - if (controller.signal.aborted) throw new TimeoutError(); + if (controller.signal.aborted) throw reportedError; throw error; } finally { clearTimeout(timeoutId); diff --git a/src/lib/export-utils.ts b/src/lib/export-utils.ts index 5f6673912..d4d90d589 100644 --- a/src/lib/export-utils.ts +++ b/src/lib/export-utils.ts @@ -5,6 +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'; function formatSparringDuration(seconds: number): string { const totalSeconds = Math.max(0, Math.round(seconds)); @@ -192,6 +193,7 @@ export async function exportCaseToDocx(result: GenerateOutput): Promise const blob = await Packer.toBlob(doc); const fileSuffix = result.metadata.side || 'case'; saveAs(blob, `DebateCase_${fileSuffix}.docx`); + captureProductEvent('content_exported', { format: 'docx', tool: 'generate' }); return true; } catch (error) { console.error('Failed to export case to DOCX:', error); @@ -290,6 +292,7 @@ export async function exportAttackToDocx(result: AttackOutput): Promise const blob = await Packer.toBlob(doc); saveAs(blob, `AttackAnalysis_${new Date().toISOString().split('T')[0]}.docx`); + captureProductEvent('content_exported', { format: 'docx', tool: 'attack' }); return true; } catch (error) { console.error('Failed to export attack to DOCX:', error); @@ -377,6 +380,7 @@ export async function exportBrainstormToDocx(result: BrainstormOutput): Promise< const doc = new Document({ sections: [{ properties: {}, children }] }); const blob = await Packer.toBlob(doc); saveAs(blob, `Brainstorm_${new Date().toISOString().split('T')[0]}.docx`); + captureProductEvent('content_exported', { format: 'docx', tool: 'brainstorm' }); return true; } catch (error) { console.error('Failed to export brainstorm to DOCX:', error); @@ -452,6 +456,7 @@ export async function exportFactoidsToDocx(result: FactoidsOutput): Promise): void { - if (import.meta.env.VITE_POSTHOG_KEY) { + if (!import.meta.env.VITE_POSTHOG_KEY) return; + + try { posthog.capture(name, properties); + } catch { + // Telemetry must not interrupt product behavior. } } export function captureException(error: unknown, context?: Record): void { - if (import.meta.env.VITE_SENTRY_DSN) Sentry.captureException(error, { extra: context }); + if (!import.meta.env.VITE_SENTRY_DSN) return; + + try { + Sentry.captureException(error, { extra: context }); + } catch { + // Telemetry must not interrupt product behavior. + } } diff --git a/src/lib/pdf-export-utils.ts b/src/lib/pdf-export-utils.ts index f6f7dad4e..111ca73bd 100644 --- a/src/lib/pdf-export-utils.ts +++ b/src/lib/pdf-export-utils.ts @@ -7,6 +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'; // Color constants for consistent branding const COLORS = { @@ -436,6 +437,7 @@ export async function exportCaseToPdf(result: GenerateOutput): Promise addFooter(); doc.save(`DebateCase_${sideValue}.pdf`); + captureProductEvent('content_exported', { format: 'pdf', tool: 'generate' }); return true; } catch (error) { console.error('Failed to export case to PDF:', error); @@ -516,6 +518,7 @@ export async function exportAttackToPdf(result: AttackOutput): Promise addFooter(); doc.save(`AttackAnalysis_${score}.pdf`); + captureProductEvent('content_exported', { format: 'pdf', tool: 'attack' }); return true; } catch (error) { console.error('Failed to export attack to PDF:', error); @@ -580,6 +583,7 @@ export async function exportBrainstormToPdf(result: BrainstormOutput): Promise(); for (const session of [...local, ...remote]) { @@ -359,6 +368,7 @@ export const useSparringStore = create()( }; const next = advanceSession({ ...session, speeches: [...session.speeches, speech] }, now); set({ currentSession: next }); + captureRoundCompletion(session, next); return next; }, @@ -369,6 +379,7 @@ export const useSparringStore = create()( if (!session || session.phase !== 'round' || turn?.speaker !== 'ai' || speech.turnId !== turn.id || alreadyRecorded) return null; const next = advanceSession({ ...session, speeches: [...session.speeches, speech] }, now); set({ currentSession: next }); + captureRoundCompletion(session, next); return next; }, @@ -388,6 +399,11 @@ export const useSparringStore = create()( currentSession: completed, savedSessions: mergeSessions(state.savedSessions, [completed]), })); + captureProductEvent('sparring_session_judged', { + format: completed.format, + winner: completed.scorecard.winner, + speechCount: completed.speeches.length, + }); return completed; }, diff --git a/src/test/ai/client-analytics.test.ts b/src/test/ai/client-analytics.test.ts new file mode 100644 index 000000000..893f08ed1 --- /dev/null +++ b/src/test/ai/client-analytics.test.ts @@ -0,0 +1,235 @@ +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'; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + invoke: vi.fn(), + captureProductEvent: vi.fn(), + captureException: vi.fn(), + settings: { customSystemPrompt: undefined as string | undefined, outputLanguage: 'en' }, + session: { + sourceLibrary: [] as Array<{ title: string; content: string; origin: string; type: string; isActive: boolean }>, + }, +})); + +vi.mock('@/lib/observability', () => ({ + captureProductEvent: mocks.captureProductEvent, + captureException: mocks.captureException, +})); + +vi.mock('@/lib/supabase/client', () => ({ + supabase: { + auth: { getSession: mocks.getSession }, + functions: { invoke: mocks.invoke }, + }, +})); + +vi.mock('@/lib/stores/settings-store', () => ({ + useSettingsStore: { getState: () => mocks.settings }, +})); + +vi.mock('@/lib/stores/session-store', () => ({ + useSessionStore: { getState: () => mocks.session }, +})); + +import { + AIAuthError, + AIError, + AIModelRestrictionError, + AIProviderError, + AIRateLimitError, + AIResponseError, + AISubscriptionError, + TimeoutError, + classifyAIError, + crossfire, + generateCase, +} from '@/lib/ai/client'; + +const config: ProviderConfig = { + provider: 'openai', + model: 'gpt-4o', + temperature: 0.7, +}; + +const input: GenerateInput = { + motion: 'USER MOTION SHOULD NEVER APPEAR IN TELEMETRY', + side: 'pro', + format: 'bp', + role: 'intro', + length: 'brief', + audience: 'beginner', + tone: 'formal', +}; + +const output: GenerateOutput = { + caseStructure: { + thesis: 'A thesis', + framework: 'A framework', + keyDefinitions: [], + burdenOfProof: 'A burden', + }, + contentions: [ + { id: '1', claim: 'Claim one', reasoning: 'Reasoning one', evidence: 'Evidence one', needsSource: false, impact: 'Impact one' }, + { id: '2', claim: 'Claim two', reasoning: 'Reasoning two', evidence: 'Evidence two', needsSource: false, impact: 'Impact two' }, + ], + predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] }, + counters: [], + metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' }, +}; + +function nextTurnInput() { + return { + operation: 'next-turn' as const, + config: { + resolution: 'A resolution', + argument: 'An argument', + mode: 'question-ai' as const, + style: 'cooperative' as const, + durationSeconds: 180 as const, + useSourceLibrary: false, + seedQuestions: [] as string[], + }, + transcript: [{ + id: 'T1', + sequence: 1, + speaker: 'user' as const, + kind: 'question' as const, + text: 'A question', + createdAt: 1, + }], + expectedReply: 'answer' as const, + }; +} + +describe('non-streaming AI request analytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSession.mockResolvedValue({ data: { session: { access_token: 'token' } } }); + mocks.invoke.mockResolvedValue({ data: { output }, error: null }); + }); + + it('emits started and succeeded events with only safe request metadata', async () => { + await generateCase(input, config); + + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(1, 'ai_request_started', { + feature: 'generate', + provider: 'openai', + model: 'gpt-4o', + }); + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_succeeded', { + feature: 'generate', + provider: 'openai', + model: 'gpt-4o', + durationMs: expect.any(Number), + }); + + for (const [, properties] of mocks.captureProductEvent.mock.calls) { + expect(JSON.stringify(properties)).not.toContain(input.motion); + } + }); + + it('emits a classified failure and captures provider exceptions without user content', async () => { + mocks.invoke.mockResolvedValueOnce({ data: { error: 'OpenAI API unavailable' }, error: null }); + + await expect(generateCase(input, config)).rejects.toMatchObject({ name: 'AIProviderError' }); + + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', { + feature: 'generate', + provider: 'openai', + model: 'gpt-4o', + durationMs: expect.any(Number), + errorName: 'AIProviderError', + statusCode: undefined, + }); + expect(mocks.captureException).toHaveBeenCalledWith( + 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); + }); + + it('skips Sentry for expected auth failures', async () => { + mocks.getSession.mockResolvedValueOnce({ data: { session: null } }); + + await expect(generateCase(input, config)).rejects.toBeInstanceOf(AIAuthError); + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + errorName: 'AIAuthError', + })); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it('reports malformed responses as AIResponseError failures', async () => { + mocks.invoke.mockResolvedValueOnce({ data: { requestId: 'request-id-only' }, error: null }); + + await expect(generateCase(input, config)).rejects.toBeInstanceOf(AIResponseError); + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + errorName: 'AIResponseError', + })); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + }); + + it('does not count a non-empty malformed output as a successful request', async () => { + mocks.invoke.mockResolvedValueOnce({ data: { output: {} }, error: null }); + + await expect(generateCase(input, config)).rejects.toBeInstanceOf(AIResponseError); + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(1, 'ai_request_started', expect.objectContaining({ + feature: 'generate', + })); + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + feature: 'generate', + errorName: 'AIResponseError', + })); + expect(mocks.captureProductEvent).not.toHaveBeenCalledWith( + 'ai_request_succeeded', + expect.anything(), + ); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + }); + + it('reports unknown failures after the client wraps them', async () => { + mocks.invoke.mockRejectedValueOnce(new Error(input.motion)); + + await expect(generateCase(input, config)).rejects.toMatchObject({ name: 'AIError' }); + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + errorName: 'AIError', + })); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + expect(JSON.stringify(mocks.captureException.mock.calls[0])).not.toContain(input.motion); + }); + + it('does not send caller aborts to Sentry', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(crossfire(nextTurnInput(), config, { signal: controller.signal })) + .rejects.toMatchObject({ name: 'AbortError' }); + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + errorName: 'AbortError', + })); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); +}); + +describe('classifyAIError', () => { + it.each([ + [new AIAuthError('auth', { statusCode: 401 }), 'AIAuthError', false, 401], + [new AIRateLimitError('rate'), 'AIRateLimitError', false, 429], + [new AISubscriptionError('subscription'), 'AISubscriptionError', false, 402], + [new AIModelRestrictionError('upgrade'), 'AIModelRestrictionError', false, 403], + [new TimeoutError('timeout'), 'TimeoutError', true, 408], + [new AIProviderError('provider', { statusCode: 503 }), 'AIProviderError', true, 503], + [new AIResponseError('response'), 'AIResponseError', true, undefined], + [new AIError('unknown', { statusCode: 500 }), 'AIError', true, 500], + ])('classifies %s', (error, errorName, shouldCaptureException, statusCode) => { + expect(classifyAIError(error)).toEqual({ errorName, statusCode, shouldCaptureException }); + }); + + it('suppresses Sentry for an explicitly user-initiated abort', () => { + expect(classifyAIError(new Error('canceled'), true)).toEqual({ + errorName: 'AbortError', + shouldCaptureException: false, + }); + }); +}); diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index 5d51ce35a..5eaaeafe3 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -1,5 +1,56 @@ -import { describe, expect, it } from 'vitest'; -import { consumeSSEChunk, isAskStreamingSupported } from '@/lib/ai/streaming'; +import { beforeEach, describe, expect, it, afterEach, vi } from 'vitest'; +import { supabaseMock, resetMockState, setMockSessionToken } from '../mocks/supabase'; + +const { captureExceptionMock, captureProductEventMock } = vi.hoisted(() => ({ + captureExceptionMock: vi.fn(), + captureProductEventMock: vi.fn(), +})); + +vi.mock('@/lib/observability', () => ({ + captureException: captureExceptionMock, + captureProductEvent: captureProductEventMock, +})); + +vi.mock('@/lib/supabase/client', () => ({ + supabase: supabaseMock, + SUPABASE_URL: 'http://test-supabase.local', +})); + +import { consumeSSEChunk, isAskStreamingSupported, streamAskChat } from '@/lib/ai/streaming'; + +function streamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index >= chunks.length) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(chunks[index])); + index += 1; + }, + }); +} + +function mockFetchOnce(body: BodyInit | null, init: ResponseInit = { status: 200 }) { + const fetchMock = vi.fn(async () => new Response(body, init)); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +const streamConfig = { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 } as const; +const streamOptions = {}; + +beforeEach(() => { + resetMockState(); + setMockSessionToken('test-token'); + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe('AI streaming helpers', () => { it('parses complete SSE events', () => { @@ -64,4 +115,148 @@ describe('AI streaming helpers', () => { { mode: 'general' } )).toBe(true); }); + + describe('telemetry', () => { + it('emits comparable success events without including Ask input', async () => { + mockFetchOnce(streamFromChunks([ + 'event: delta\ndata: {"text":"answer"}\n\n', + 'event: final\ndata: {"output":{"response":"answer","followUpQuestions":[],"uncertainties":[]}}\n\n', + ])); + + await streamAskChat( + 'private question', + [{ role: 'user', content: 'private history' }], + 'general', + streamConfig, + streamOptions, + { onDelta: () => undefined }, + ); + + expect(captureProductEventMock).toHaveBeenNthCalledWith(1, 'ai_request_started', { + feature: 'ask', + streaming: true, + provider: 'openai', + model: 'gpt-5.4-mini', + }); + expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_succeeded', expect.objectContaining({ + feature: 'ask', + streaming: true, + provider: 'openai', + model: 'gpt-5.4-mini', + durationMs: expect.any(Number), + })); + expect(captureExceptionMock).not.toHaveBeenCalled(); + expect(JSON.stringify(captureProductEventMock.mock.calls)).not.toContain('private question'); + expect(JSON.stringify(captureProductEventMock.mock.calls)).not.toContain('private history'); + }); + + it('records unexpected provider failures and sends them to Sentry', async () => { + mockFetchOnce(streamFromChunks([ + 'event: error\ndata: {"message":"OpenAI exploded for private question"}\n\n', + ])); + + await expect(streamAskChat( + 'private question', + [], + 'general', + streamConfig, + streamOptions, + { onDelta: () => undefined }, + )).rejects.toMatchObject({ name: 'AIProviderError' }); + + expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + feature: 'ask', + streaming: true, + provider: 'openai', + model: 'gpt-5.4-mini', + durationMs: expect.any(Number), + errorName: 'AIProviderError', + statusCode: 500, + })); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'AIProviderError' }), + expect.objectContaining({ errorName: 'AIProviderError', statusCode: 500 }), + ); + expect(JSON.stringify(captureExceptionMock.mock.calls)).not.toContain('private question'); + }); + + it.each([ + ['auth', 401, { error: 'Unauthorized' }, 'AIAuthError'], + ['rate limit', 429, { error: 'Rate limit exceeded' }, 'AIRateLimitError'], + ['subscription', 402, { error: 'Trial expired', code: 'trial_expired' }, 'AISubscriptionError'], + ['model restriction', 403, { error: 'Upgrade required', code: 'upgrade_required' }, 'AIModelRestrictionError'], + ] as const)('does not send expected %s failures to Sentry', async (_label, status, payload, errorName) => { + mockFetchOnce(JSON.stringify(payload), { status }); + + await expect(streamAskChat( + 'private question', + [], + 'general', + streamConfig, + streamOptions, + { onDelta: () => undefined }, + )).rejects.toMatchObject({ name: errorName }); + + expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + feature: 'ask', + streaming: true, + provider: 'openai', + model: 'gpt-5.4-mini', + errorName, + statusCode: status, + })); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + + it('records an unauthenticated attempt as an expected auth failure', async () => { + setMockSessionToken(null); + + await expect(streamAskChat( + 'private question', + [], + 'general', + streamConfig, + streamOptions, + { onDelta: () => undefined }, + )).rejects.toMatchObject({ name: 'AIAuthError' }); + + expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + feature: 'ask', + streaming: true, + provider: 'openai', + model: 'gpt-5.4-mini', + errorName: 'AIAuthError', + })); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + + it('records caller aborts without sending them to Sentry', async () => { + let resolveSession!: (result: { data: { session: { access_token: string } } }) => void; + supabaseMock.auth.getSession.mockImplementationOnce(() => new Promise((resolve) => { + resolveSession = resolve; + })); + const controller = new AbortController(); + const request = streamAskChat( + 'private question', + [], + 'general', + streamConfig, + streamOptions, + { onDelta: () => undefined, signal: controller.signal }, + ); + controller.abort(); + resolveSession({ data: { session: { access_token: 'test-token' } } }); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ + feature: 'ask', + streaming: true, + provider: 'openai', + model: 'gpt-5.4-mini', + errorName: 'AbortError', + })); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/test/app-route-analytics-error-boundary.test.tsx b/src/test/app-route-analytics-error-boundary.test.tsx new file mode 100644 index 000000000..897317d2b --- /dev/null +++ b/src/test/app-route-analytics-error-boundary.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RouteErrorBoundary, ToolRouteAnalytics } from "@/App"; + +const observability = vi.hoisted(() => ({ + captureException: vi.fn(), + captureProductEvent: vi.fn(), +})); + +vi.mock("@/lib/observability", () => observability); + +const toolRoutes = [ + ["/app/generate", "generate"], + ["/app/attack", "attack"], + ["/app/brainstorm", "brainstorm"], + ["/app/factoids", "factoids"], + ["/app/ask", "ask"], + ["/app/crossfire", "crossfire"], + ["/app/history", "history"], + ["/app/library", "library"], + ["/app/timer", "timer"], + ["/app/sparring", "sparring"], + ["/app/flowchart", "flowchart"], + ["/app/settings", "settings"], + ["/app/admin", "admin"], +] as const; + +describe("App route observability", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(toolRoutes)("tracks %s as the safe tool enum", (pathname, tool) => { + render( + + + , + ); + + expect(observability.captureProductEvent).toHaveBeenCalledWith("tool_opened", { tool }); + }); + + it.each(["/app", "/app/unknown", "/app/generate/private-case"])( + "does not track %s as a tool", + (pathname) => { + render( + + + , + ); + + expect(observability.captureProductEvent).not.toHaveBeenCalled(); + }, + ); + + it("tracks tool openings from location changes without path data", () => { + function Navigation() { + const navigate = useNavigate(); + + return ( + <> + + + + + ); + } + + render( + + + } /> + + , + ); + + expect(observability.captureProductEvent).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Open generate" })); + expect(observability.captureProductEvent).toHaveBeenLastCalledWith("tool_opened", { tool: "generate" }); + + fireEvent.click(screen.getByRole("button", { name: "Open ask" })); + expect(observability.captureProductEvent).toHaveBeenLastCalledWith("tool_opened", { tool: "ask" }); + expect(observability.captureProductEvent.mock.calls).toEqual([ + ["tool_opened", { tool: "generate" }], + ["tool_opened", { tool: "ask" }], + ]); + }); + + it("captures route errors with a fixed, non-sensitive boundary context", () => { + const error = new Error("route failed"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const suppressWindowError = (event: ErrorEvent) => event.preventDefault(); + window.addEventListener("error", suppressWindowError); + + function ExplodingRoute(): ReactNode { + throw error; + } + + try { + render( + + + , + ); + + expect(screen.getByRole("heading", { name: "Unable to load this page" })).toBeInTheDocument(); + expect(observability.captureException).toHaveBeenCalledWith(error, { boundary: "route" }); + expect(observability.captureException.mock.calls[0][1]).not.toHaveProperty("componentStack"); + } finally { + window.removeEventListener("error", suppressWindowError); + consoleError.mockRestore(); + } + }); +}); diff --git a/src/test/export-telemetry.test.ts b/src/test/export-telemetry.test.ts new file mode 100644 index 000000000..b1c10cd89 --- /dev/null +++ b/src/test/export-telemetry.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AttackOutput } from '@/lib/ai/schemas/attack'; +import type { BrainstormOutput } from '@/lib/ai/schemas/brainstorm'; +import type { FactoidsOutput } from '@/lib/ai/schemas/factoids'; +import type { GenerateOutput } from '@/lib/ai/schemas/generate'; +import { buildSparringTurns } from '@/lib/sparring/formats'; +import type { SparringSession } from '@/lib/stores/sparring-store'; + +const mocks = vi.hoisted(() => ({ + captureProductEvent: vi.fn(), + getSession: vi.fn(), +})); + +vi.mock('@/lib/observability', () => ({ + captureProductEvent: mocks.captureProductEvent, +})); + +vi.mock('@/lib/supabase/client', () => ({ + SUPABASE_URL: 'https://example.supabase.co', + supabase: { auth: { getSession: mocks.getSession } }, +})); + +vi.mock('docx', () => ({ + Document: vi.fn(function DocumentMock(options: Record) { + return { options }; + }), + Packer: { toBlob: vi.fn().mockResolvedValue(new Blob(['docx'])) }, + Paragraph: vi.fn(function ParagraphMock(options: Record) { + return options; + }), + TextRun: vi.fn(function TextRunMock(options: Record | string) { + return typeof options === 'string' ? { text: options } : options; + }), + HeadingLevel: { HEADING_1: 'HEADING_1', HEADING_2: 'HEADING_2' }, + AlignmentType: { CENTER: 'CENTER' }, +})); + +vi.mock('file-saver', () => ({ saveAs: vi.fn() })); + +vi.mock('jspdf', () => ({ + jsPDF: class MockJsPdf { + constructor() { + return { + internal: { pageSize: { height: 297, width: 210 } }, + lastAutoTable: { finalY: 100 }, + setFillColor: vi.fn(), + rect: vi.fn(), + setTextColor: vi.fn(), + setFontSize: vi.fn(), + setFont: vi.fn(), + text: vi.fn(), + setDrawColor: vi.fn(), + setLineWidth: vi.fn(), + line: vi.fn(), + getTextWidth: vi.fn(() => 10), + splitTextToSize: vi.fn((text: string) => [text]), + addPage: vi.fn(), + getNumberOfPages: vi.fn(() => 1), + setPage: vi.fn(), + save: vi.fn(), + }; + } + }, +})); + +vi.mock('jspdf-autotable', () => ({ + default: vi.fn((doc: Record) => { + doc.lastAutoTable = { finalY: 100 }; + }), +})); + +import { + exportAttackToDocx, + exportAttackToGoogleDocs, + exportBrainstormToDocx, + exportBrainstormToGoogleDocs, + exportCaseToDocx, + exportCaseToGoogleDocs, + exportFactoidsToDocx, + exportFactoidsToGoogleDocs, + exportSparringToDocx, +} from '@/lib/export-utils'; +import { + exportAttackToPdf, + exportBrainstormToPdf, + exportCaseToPdf, + exportFactoidsToPdf, + exportSparringToPdf, +} from '@/lib/pdf-export-utils'; + +const validCase: GenerateOutput = { + caseStructure: { + thesis: 'Private thesis content', + framework: 'Framework', + keyDefinitions: [], + burdenOfProof: 'Burden', + }, + contentions: [], + predictedOpponentCase: { likelyArguments: [], strategicWeaknesses: [], keyClashes: [] }, + counters: [], + metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00Z' }, +}; + +const validAttack: AttackOutput = { + overallAssessment: { score: 75, summary: 'Private summary content', strengthAreas: [] }, + weaknesses: [], + fallaciesDetected: [], + crossExQuestions: [], + weighingAnalysis: { currentWeighing: '', missingComparisons: [], improvementSuggestions: [] }, +}; + +const validBrainstorm: BrainstormOutput = { + needsMoreInfo: false, + mindmap: [], + buckets: [], + topFive: [], + nextSteps: [], + warningsAndTradeoffs: [], +}; + +const validFactoids: FactoidsOutput = { + factoids: [], + searchSuggestions: [], + warnings: [], + verificationStatus: 'complete', +}; + +const validSparringSession: SparringSession = { + id: '00000000-0000-4000-8000-000000000010', + motion: 'Private motion content', + userSide: 'pro', + format: 'pf', + difficulty: 'hard', + turns: buildSparringTurns('pf', 'pro'), + speeches: [], + currentTurnIndex: 0, + phase: 'complete', + draft: '', + timerRemainingSeconds: 0, + timerRunning: false, + timerDeadline: null, + createdAt: 0, + updatedAt: 0, +}; + +describe('content export telemetry', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSession.mockResolvedValue({ + data: { session: { access_token: 'app-token', provider_token: 'provider-token' } }, + error: null, + }); + globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ documentUrl: 'https://docs.google.com/document/d/example' }), { status: 200 })); + }); + + it('records only enum-only format and tool values for successful exports', async () => { + const exports = [ + { format: 'docx', tool: 'generate', run: () => exportCaseToDocx(validCase) }, + { format: 'docx', tool: 'attack', run: () => exportAttackToDocx(validAttack) }, + { format: 'docx', tool: 'brainstorm', run: () => exportBrainstormToDocx(validBrainstorm) }, + { format: 'docx', tool: 'factoids', run: () => exportFactoidsToDocx(validFactoids) }, + { format: 'docx', tool: 'sparring', run: () => exportSparringToDocx(validSparringSession) }, + { format: 'pdf', tool: 'generate', run: () => exportCaseToPdf(validCase) }, + { format: 'pdf', tool: 'attack', run: () => exportAttackToPdf(validAttack) }, + { format: 'pdf', tool: 'brainstorm', run: () => exportBrainstormToPdf(validBrainstorm) }, + { format: 'pdf', tool: 'factoids', run: () => exportFactoidsToPdf(validFactoids) }, + { format: 'pdf', tool: 'sparring', run: () => exportSparringToPdf(validSparringSession) }, + { format: 'google_docs', tool: 'generate', run: () => exportCaseToGoogleDocs(validCase) }, + { format: 'google_docs', tool: 'attack', run: () => exportAttackToGoogleDocs(validAttack) }, + { format: 'google_docs', tool: 'brainstorm', run: () => exportBrainstormToGoogleDocs(validBrainstorm) }, + { format: 'google_docs', tool: 'factoids', run: () => exportFactoidsToGoogleDocs(validFactoids) }, + ] as const; + + for (const exportOperation of exports) { + await exportOperation.run(); + } + + expect(mocks.captureProductEvent).toHaveBeenCalledTimes(exports.length); + exports.forEach(({ format, tool }, index) => { + expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(index + 1, 'content_exported', { format, tool }); + }); + expect(JSON.stringify(mocks.captureProductEvent.mock.calls)).not.toContain('Private thesis content'); + expect(JSON.stringify(mocks.captureProductEvent.mock.calls)).not.toContain('Private motion content'); + }); + + it('does not record validation or request failures as exports', async () => { + await exportCaseToDocx({ ...validCase, metadata: undefined } as unknown as GenerateOutput); + mocks.getSession.mockResolvedValue({ data: { session: null }, error: null }); + await exportCaseToGoogleDocs(validCase); + + expect(mocks.captureProductEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts new file mode 100644 index 000000000..0a2231a76 --- /dev/null +++ b/src/test/observability.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { captureMock, posthogInitMock, sentryCaptureMock, sentryInitMock } = vi.hoisted(() => ({ + captureMock: vi.fn(), + posthogInitMock: vi.fn(), + sentryCaptureMock: vi.fn(), + sentryInitMock: vi.fn(), +})); + +vi.mock("@sentry/react", () => ({ + captureException: sentryCaptureMock, + init: sentryInitMock, +})); + +vi.mock("posthog-js", () => ({ + default: { + capture: captureMock, + init: posthogInitMock, + }, +})); + +import { + captureException, + captureProductEvent, + initializeObservability, +} from "@/lib/observability"; + +describe("observability primitives", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("VITE_POSTHOG_KEY", undefined); + vi.stubEnv("VITE_POSTHOG_HOST", undefined); + vi.stubEnv("VITE_SENTRY_DSN", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not capture product events when PostHog is not configured", () => { + captureProductEvent("tool_opened", { tool: "generate" }); + + expect(captureMock).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", + durationMs: 123, + streaming: false, + }; + + captureProductEvent("tool_opened", properties); + + expect(captureMock).toHaveBeenCalledOnce(); + expect(captureMock).toHaveBeenCalledWith("tool_opened", properties); + expect(properties).not.toHaveProperty("motion"); + expect(properties).not.toHaveProperty("content"); + expect(properties).not.toHaveProperty("message"); + }); + + it("does not let PostHog failures interrupt the product", () => { + vi.stubEnv("VITE_POSTHOG_KEY", "posthog-test-key"); + captureMock.mockImplementationOnce(() => { + throw new Error("synthetic PostHog failure"); + }); + + expect(() => captureProductEvent("tool_opened", { tool: "generate" })).not.toThrow(); + }); + + it("does not send exceptions when Sentry is not configured", () => { + captureException(new Error("synthetic test error"), { category: "test" }); + + expect(sentryCaptureMock).not.toHaveBeenCalled(); + }); + + it("passes exceptions and structured context through when Sentry is configured", () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); + const error = new Error("synthetic test error"); + const context = { provider: "openai", category: "provider_error" }; + + captureException(error, context); + + expect(sentryCaptureMock).toHaveBeenCalledOnce(); + expect(sentryCaptureMock).toHaveBeenCalledWith(error, { extra: context }); + }); + + it("does not let Sentry failures interrupt the product", () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); + sentryCaptureMock.mockImplementationOnce(() => { + throw new Error("synthetic Sentry failure"); + }); + + expect(() => captureException(new Error("synthetic test error"))).not.toThrow(); + }); + + 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"); + vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); + + initializeObservability(); + initializeObservability(); + + expect(sentryInitMock).toHaveBeenCalledOnce(); + expect(sentryInitMock).toHaveBeenCalledWith(expect.objectContaining({ + dsn: "https://public-test@sentry.example/1", + sendDefaultPii: false, + })); + expect(posthogInitMock).toHaveBeenCalledOnce(); + expect(posthogInitMock).toHaveBeenCalledWith("posthog-test-key", expect.objectContaining({ + api_host: "https://posthog.example", + autocapture: false, + capture_pageview: false, + disable_session_recording: true, + persistence: "memory", + })); + }); +}); diff --git a/src/test/stores/sparring-store.test.ts b/src/test/stores/sparring-store.test.ts index 9b257a2a8..baee03b32 100644 --- a/src/test/stores/sparring-store.test.ts +++ b/src/test/stores/sparring-store.test.ts @@ -3,10 +3,18 @@ import { supabaseMock, resetMockState, setMockTableResponse } from '../mocks/sup import { useSparringStore, type SparringSession } from '@/lib/stores/sparring-store'; import type { SparringScorecard, SparringSpeech } from '@/lib/ai/schemas/sparring'; +const { captureProductEventMock } = vi.hoisted(() => ({ + captureProductEventMock: vi.fn(), +})); + vi.mock('@/lib/supabase/client', () => ({ supabase: supabaseMock, })); +vi.mock('@/lib/observability', () => ({ + captureProductEvent: captureProductEventMock, +})); + const SCORECARD: SparringScorecard = { userScore: 76, aiScore: 72, @@ -78,6 +86,7 @@ describe('sparring-store', () => { localStorage.clear(); sessionStorage.clear(); resetMockState(); + captureProductEventMock.mockClear(); useSparringStore.setState({ ownerUserId: null, currentSession: null, savedSessions: [], hydrated: false }); }); @@ -260,6 +269,46 @@ describe('sparring-store', () => { expect(getSession().speeches.filter((item) => item.id === opponentSpeech.id)).toHaveLength(1); }); + it('captures the zero-based final turn index when the round enters judging', () => { + const store = useSparringStore.getState(); + const session = store.startSession({ + motion: 'This House would make public transport free.', + userSide: 'pro', + format: 'standard', + difficulty: 'medium', + }); + const finalTurnIndex = session.turns.length - 1; + useSparringStore.setState({ + currentSession: { + ...session, + currentTurnIndex: finalTurnIndex, + timerRemainingSeconds: session.turns[finalTurnIndex].timeLimitSeconds, + timerRunning: false, + timerDeadline: null, + }, + }); + + const completedRound = store.appendAiSpeech(speech({ + id: 'ai-final-speech', + turnId: session.turns[finalTurnIndex].id, + role: session.turns[finalTurnIndex].label, + timeLimitSeconds: session.turns[finalTurnIndex].timeLimitSeconds, + }), 10_000); + + expect(completedRound?.phase).toBe('judging'); + // The store's currentTurnIndex is zero-based, so the final standard turn is index 7. + expect(captureProductEventMock).toHaveBeenCalledWith('sparring_round_completed', { + format: 'standard', + roundIndex: finalTurnIndex, + }); + + expect(store.appendAiSpeech(speech({ + id: 'ai-final-retry', + turnId: session.turns[finalTurnIndex].id, + }), 11_000)).toBeNull(); + expect(captureProductEventMock).toHaveBeenCalledTimes(1); + }); + it('rejects a speech that exceeds the server content limit without advancing', () => { const store = useSparringStore.getState(); store.startSession({ @@ -311,6 +360,14 @@ describe('sparring-store', () => { expect(completed?.phase).toBe('complete'); expect(completed?.scorecard).toEqual(SCORECARD); expect(useSparringStore.getState().savedSessions).toEqual([completed]); + expect(captureProductEventMock).toHaveBeenCalledWith('sparring_session_judged', { + format: 'ld', + winner: 'user', + speechCount: 0, + }); + + expect(store.completeSession(SCORECARD, 11_000)).toBeNull(); + expect(captureProductEventMock).toHaveBeenCalledTimes(1); }); it('hydrates only complete rows that pass the nested speech and scorecard schemas', async () => { From a4e6065ce6be2cc5b062d4f6d3ae2793a2bcd3a6 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 15:32:16 +0000 Subject: [PATCH 2/4] fix: sanitize route errors and settle stream aborts --- src/App.tsx | 2 +- src/lib/ai/streaming.ts | 34 +++++++++++++--- src/test/ai/streaming.test.ts | 39 ++++++++++++++++--- ...pp-route-analytics-error-boundary.test.tsx | 6 ++- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 017a633a2..b2800eeab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -100,7 +100,7 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { fai componentDidCatch(error: Error, info: ErrorInfo) { console.error('Route rendering failed', error, info); - captureException(error, { boundary: "route" }); + captureException(new Error("Route rendering failed"), { boundary: "route" }); } render() { diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index 2b06685e5..49ccb718a 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -70,6 +70,23 @@ export function consumeSSEChunk(buffer: string, chunk: string): { events: Parsed return { events, buffer: rest }; } +function abortError(): DOMException { + return new DOMException('Aborted', 'AbortError'); +} + +function waitForCaller(operation: PromiseLike, signal?: AbortSignal): Promise { + if (!signal) return Promise.resolve(operation); + if (signal.aborted) return Promise.reject(abortError()); + + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + signal.addEventListener('abort', onAbort, { once: true }); + Promise.resolve(operation) + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + function captureStreamFailure( error: unknown, provider: string, @@ -115,14 +132,14 @@ export async function streamAskChat( }); if (handlers.signal?.aborted) { - const error = new DOMException('Aborted', 'AbortError'); + const error = abortError(); captureStreamFailure(error, provider, config.model, startTime, true); throw error; } let sessionData: Awaited>['data']; try { - ({ data: sessionData } = await supabase.auth.getSession()); + ({ data: sessionData } = await waitForCaller(supabase.auth.getSession(), handlers.signal)); } catch (error) { captureStreamFailure(error, provider, config.model, startTime, Boolean(handlers.signal?.aborted)); throw error; @@ -135,9 +152,9 @@ export async function streamAskChat( throw error; } if (handlers.signal?.aborted) { - const error = new DOMException('Aborted', 'AbortError'); + const error = abortError(); captureStreamFailure(error, provider, config.model, startTime, true); - throw new DOMException('Aborted', 'AbortError'); + throw error; } const controller = new AbortController(); @@ -236,6 +253,7 @@ export async function streamAskChat( throw new DOMException('Aborted', 'AbortError'); } const { done, value } = await reader.read(); + if (handlers.signal?.aborted) throw abortError(); if (done) break; const parsed = consumeSSEChunk(buffer, decoder.decode(value, { stream: true })); @@ -261,9 +279,13 @@ export async function streamAskChat( return finalOutput; } catch (error) { const callerAborted = Boolean(handlers.signal?.aborted); - const reportedError = !callerAborted && controller.signal.aborted ? new TimeoutError() : error; + const reportedError = callerAborted + ? abortError() + : controller.signal.aborted + ? new TimeoutError() + : error; captureStreamFailure(reportedError, provider, config.model, startTime, callerAborted); - if (handlers.signal?.aborted) throw error; + if (callerAborted) throw reportedError; if (controller.signal.aborted) throw reportedError; throw error; } finally { diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index 5eaaeafe3..a400c28b5 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -232,10 +232,7 @@ describe('AI streaming helpers', () => { }); it('records caller aborts without sending them to Sentry', async () => { - let resolveSession!: (result: { data: { session: { access_token: string } } }) => void; - supabaseMock.auth.getSession.mockImplementationOnce(() => new Promise((resolve) => { - resolveSession = resolve; - })); + supabaseMock.auth.getSession.mockImplementationOnce(() => new Promise(() => undefined)); const controller = new AbortController(); const request = streamAskChat( 'private question', @@ -246,7 +243,6 @@ describe('AI streaming helpers', () => { { onDelta: () => undefined, signal: controller.signal }, ); controller.abort(); - resolveSession({ data: { session: { access_token: 'test-token' } } }); await expect(request).rejects.toMatchObject({ name: 'AbortError' }); expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ @@ -258,5 +254,38 @@ describe('AI streaming helpers', () => { })); expect(captureExceptionMock).not.toHaveBeenCalled(); }); + + it('does not report success when cancellation settles a pending stream read', async () => { + const controller = new AbortController(); + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(streamController) { + streamController.enqueue(encoder.encode( + 'event: final\ndata: {"output":{"response":"answer","followUpQuestions":[],"uncertainties":[]}}\n\n', + )); + setTimeout(() => controller.abort(), 0); + }, + }); + mockFetchOnce(body); + + await expect(streamAskChat( + 'private question', + [], + 'general', + streamConfig, + streamOptions, + { onDelta: () => undefined, signal: controller.signal }, + )).rejects.toMatchObject({ name: 'AbortError' }); + + expect(captureProductEventMock).toHaveBeenLastCalledWith( + 'ai_request_failed', + expect.objectContaining({ errorName: 'AbortError', streaming: true }), + ); + expect(captureProductEventMock).not.toHaveBeenCalledWith( + 'ai_request_succeeded', + expect.anything(), + ); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/test/app-route-analytics-error-boundary.test.tsx b/src/test/app-route-analytics-error-boundary.test.tsx index 897317d2b..2b48deefc 100644 --- a/src/test/app-route-analytics-error-boundary.test.tsx +++ b/src/test/app-route-analytics-error-boundary.test.tsx @@ -107,7 +107,11 @@ describe("App route observability", () => { ); expect(screen.getByRole("heading", { name: "Unable to load this page" })).toBeInTheDocument(); - expect(observability.captureException).toHaveBeenCalledWith(error, { boundary: "route" }); + expect(observability.captureException).toHaveBeenCalledWith( + expect.objectContaining({ name: "Error", message: "Route rendering failed" }), + { boundary: "route" }, + ); + expect(JSON.stringify(observability.captureException.mock.calls)).not.toContain(error.message); expect(observability.captureException.mock.calls[0][1]).not.toHaveProperty("componentStack"); } finally { window.removeEventListener("error", suppressWindowError); From 0f243d5d81f301000d9ef7b1e42107ac8e880fb5 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 15:40:23 +0000 Subject: [PATCH 3/4] fix: normalize analytics tool routes --- src/App.tsx | 3 ++- src/test/app-route-analytics-error-boundary.test.tsx | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index b2800eeab..36e52cc43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,7 +73,8 @@ const TOOL_BY_ROUTE = new Map([ ]); function getToolForPathname(pathname: string): ToolName | undefined { - return TOOL_BY_ROUTE.get(pathname); + const normalizedPathname = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; + return TOOL_BY_ROUTE.get(normalizedPathname); } // Loading fallback component diff --git a/src/test/app-route-analytics-error-boundary.test.tsx b/src/test/app-route-analytics-error-boundary.test.tsx index 2b48deefc..836f5c422 100644 --- a/src/test/app-route-analytics-error-boundary.test.tsx +++ b/src/test/app-route-analytics-error-boundary.test.tsx @@ -55,6 +55,16 @@ describe("App route observability", () => { }, ); + it("tracks a recognized tool route with a trailing slash", () => { + render( + + + , + ); + + expect(observability.captureProductEvent).toHaveBeenCalledWith("tool_opened", { tool: "generate" }); + }); + it("tracks tool openings from location changes without path data", () => { function Navigation() { const navigate = useNavigate(); From 4f7d38685509a4c1bbd482e4fc94c0dc334c22ba Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 31 Aug 2026 15:52:07 +0000 Subject: [PATCH 4/4] fix: track only authorized tool access --- src/App.tsx | 6 +-- src/pages/AdminPage.tsx | 5 +++ ...pp-route-analytics-error-boundary.test.tsx | 39 ++++++++++++++++++- src/test/pages/AdminPage.test.tsx | 9 ++++- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 36e52cc43..761993f07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,8 +53,7 @@ type ToolName = | "timer" | "sparring" | "flowchart" - | "settings" - | "admin"; + | "settings"; const TOOL_BY_ROUTE = new Map([ ["/app/generate", "generate"], @@ -69,7 +68,6 @@ const TOOL_BY_ROUTE = new Map([ ["/app/sparring", "sparring"], ["/app/flowchart", "flowchart"], ["/app/settings", "settings"], - ["/app/admin", "admin"], ]); function getToolForPathname(pathname: string): ToolName | undefined { @@ -145,7 +143,6 @@ function AnimatedRoutes() { transition={{ duration: shouldReduceMotion ? 0 : 0.16, ease: [0.22, 1, 0.36, 1] }} className="min-h-screen motion-reduce:transform-none" > - {/* Public Routes */} } /> @@ -159,6 +156,7 @@ function AnimatedRoutes() { + diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index e70c9cf81..c8d0f7eca 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -20,6 +20,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { formatEuropeanDate } from '@/lib/date-format'; +import { captureProductEvent } from '@/lib/observability'; interface UsageStats { totals: { requests: number; cost: number; successRate: number; unknownCostRequests?: number }; @@ -236,6 +237,10 @@ export default function AdminPage() { loadData(); }, [toast]); + useEffect(() => { + if (isAdmin === true) captureProductEvent('tool_opened', { tool: 'admin' }); + }, [isAdmin]); + const handleSetUserRole = async (userId: string, role: string) => { try { await postAdminData('set-role', { userId, role }); diff --git a/src/test/app-route-analytics-error-boundary.test.tsx b/src/test/app-route-analytics-error-boundary.test.tsx index 836f5c422..4e1f6f305 100644 --- a/src/test/app-route-analytics-error-boundary.test.tsx +++ b/src/test/app-route-analytics-error-boundary.test.tsx @@ -1,15 +1,28 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; 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"; const observability = vi.hoisted(() => ({ captureException: vi.fn(), captureProductEvent: vi.fn(), })); +const authState = vi.hoisted(() => ({ + loading: false, + user: null as object | null, +})); + vi.mock("@/lib/observability", () => observability); +vi.mock("@/lib/supabase/AuthProvider", async () => { + const React = await import("react"); + return { + AuthProvider: ({ children }: { children: ReactNode }) => React.createElement(React.Fragment, null, children), + useAuth: () => authState, + }; +}); const toolRoutes = [ ["/app/generate", "generate"], @@ -24,12 +37,13 @@ const toolRoutes = [ ["/app/sparring", "sparring"], ["/app/flowchart", "flowchart"], ["/app/settings", "settings"], - ["/app/admin", "admin"], ] as const; describe("App route observability", () => { beforeEach(() => { vi.clearAllMocks(); + authState.loading = false; + authState.user = null; }); it.each(toolRoutes)("tracks %s as the safe tool enum", (pathname, tool) => { @@ -65,6 +79,27 @@ describe("App route observability", () => { expect(observability.captureProductEvent).toHaveBeenCalledWith("tool_opened", { tool: "generate" }); }); + it("does not track a protected tool when authentication redirects to login", async () => { + render( + + + + + + )} + /> + Login page} /> + + , + ); + + await waitFor(() => expect(screen.getByText("Login page")).toBeInTheDocument()); + expect(observability.captureProductEvent).not.toHaveBeenCalled(); + }); + it("tracks tool openings from location changes without path data", () => { function Navigation() { const navigate = useNavigate(); diff --git a/src/test/pages/AdminPage.test.tsx b/src/test/pages/AdminPage.test.tsx index c602af4fa..7fd6dc306 100644 --- a/src/test/pages/AdminPage.test.tsx +++ b/src/test/pages/AdminPage.test.tsx @@ -6,13 +6,14 @@ import { BrowserRouter } from 'react-router-dom'; import type { ReactElement, ReactNode } from 'react'; // Hoist mocks -const { mockSupabase, mockToast } = vi.hoisted(() => ({ +const { mockSupabase, mockToast, captureProductEventMock } = vi.hoisted(() => ({ mockSupabase: { auth: { getSession: vi.fn(), }, }, mockToast: vi.fn(), + captureProductEventMock: vi.fn(), })); vi.mock('@/lib/supabase/client', () => ({ @@ -41,6 +42,10 @@ vi.mock('@/hooks/use-toast', () => ({ useToast: (): { toast: typeof mockToast } => ({ toast: mockToast }), })); +vi.mock('@/lib/observability', () => ({ + captureProductEvent: captureProductEventMock, +})); + // Mock global fetch const globalFetch = vi.fn(); global.fetch = globalFetch; @@ -86,6 +91,7 @@ describe('AdminPage Integration', () => { await waitFor(() => { expect(screen.getByText('Access Denied')).toBeInTheDocument(); }); + expect(captureProductEventMock).not.toHaveBeenCalled(); }); it('loads and displays analytics when admin', async () => { @@ -130,6 +136,7 @@ describe('AdminPage Integration', () => { await waitFor(() => { expect(screen.getByText('Admin Dashboard')).toBeInTheDocument(); }); + expect(captureProductEventMock).toHaveBeenCalledWith('tool_opened', { tool: 'admin' }); // Debug output if fails // screen.debug();