diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..14db15d77 --- /dev/null +++ b/.node-version @@ -0,0 +1,2 @@ +24 + diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..14db15d77 --- /dev/null +++ b/.nvmrc @@ -0,0 +1,2 @@ +24 + diff --git a/app/package.json b/app/package.json index c5859c8a7..0e3b04ed8 100644 --- a/app/package.json +++ b/app/package.json @@ -50,6 +50,7 @@ "html-to-image": "^1.11.13", "jsonp": "^0.2.1", "lucide-react": "^0.575.0", + "posthog-js": "^1.292.0", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/app/src/components/common/ErrorBoundary.tsx b/app/src/components/common/ErrorBoundary.tsx index 9eb754065..892e4e272 100644 --- a/app/src/components/common/ErrorBoundary.tsx +++ b/app/src/components/common/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; +import { captureReactBoundaryException } from '@/utils/errorTracking'; export interface ErrorBoundaryProps { children: ReactNode; @@ -37,6 +38,7 @@ export class ErrorBoundary extends Component void; onNumChildrenChange: (num: number) => void; disabled?: boolean; + trackingMode?: 'report' | 'standalone'; } export default function HouseholdBuilderForm({ @@ -68,6 +74,7 @@ export default function HouseholdBuilderForm({ onMaritalStatusChange, onNumChildrenChange, disabled = false, + trackingMode = 'report', }: HouseholdBuilderFormProps) { // State for custom variables const [selectedVariables, setSelectedVariables] = useState([]); @@ -138,6 +145,14 @@ export default function HouseholdBuilderForm({ [householdSearchValue, allInputVariables] ); + useEffect(() => { + trackHouseholdBuilderOpened({ + countryId: household.countryId, + year, + mode: trackingMode, + }); + }, [household.countryId, trackingMode, year]); + // Get variables for a specific person (custom only, not basic inputs) const getPersonVariables = (personName: string): string[] => { const personData = household.householdData.people[personName]; @@ -198,10 +213,25 @@ export default function HouseholdBuilderForm({ const newHousehold = addVariableToEntity(household, variable.name, metadata, year, person); onChange(newHousehold); + const nextSelectedVariables = selectedVariables.includes(variable.name) + ? selectedVariables + : [...selectedVariables, variable.name]; + if (!selectedVariables.includes(variable.name)) { - setSelectedVariables([...selectedVariables, variable.name]); + setSelectedVariables(nextSelectedVariables); } + trackHouseholdVariableAdded({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: variable.name, + variableLabel: variable.label, + entityScope: isPerson ? 'person' : 'household', + entityName: isPerson ? person : undefined, + selectedVariableCount: nextSelectedVariables.length, + }); + setActivePersonSearch(null); setPersonSearchValue(''); setIsPersonSearchFocused(false); @@ -223,9 +253,23 @@ export default function HouseholdBuilderForm({ ); // If no one else has it, remove from selectedVariables + const nextSelectedVariables = stillUsedByOthers + ? selectedVariables + : selectedVariables.filter((v) => v !== varName); + if (!stillUsedByOthers) { - setSelectedVariables(selectedVariables.filter((v) => v !== varName)); + setSelectedVariables(nextSelectedVariables); } + + trackHouseholdVariableRemoved({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: varName, + entityScope: 'person', + entityName: person, + selectedVariableCount: nextSelectedVariables.length, + }); }; // Handle opening household search @@ -272,10 +316,24 @@ export default function HouseholdBuilderForm({ ); } onChange(newHousehold); + const nextSelectedVariables = selectedVariables.includes(variable.name) + ? selectedVariables + : [...selectedVariables, variable.name]; if (!selectedVariables.includes(variable.name)) { - setSelectedVariables([...selectedVariables, variable.name]); + setSelectedVariables(nextSelectedVariables); } + trackHouseholdVariableAdded({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: variable.name, + variableLabel: variable.label, + entityScope: isPerson ? 'person' : 'household', + entityName: isPerson ? 'all_people' : undefined, + selectedVariableCount: nextSelectedVariables.length, + }); + setIsHouseholdSearchActive(false); setHouseholdSearchValue(''); setIsHouseholdSearchFocused(false); @@ -285,7 +343,17 @@ export default function HouseholdBuilderForm({ const handleRemoveHouseholdVariable = (varName: string) => { const newHousehold = removeVariable(household, varName, metadata); onChange(newHousehold); - setSelectedVariables(selectedVariables.filter((v) => v !== varName)); + const nextSelectedVariables = selectedVariables.filter((v) => v !== varName); + setSelectedVariables(nextSelectedVariables); + + trackHouseholdVariableRemoved({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: varName, + entityScope: 'household', + selectedVariableCount: nextSelectedVariables.length, + }); }; return ( diff --git a/app/src/hooks/useCreateHousehold.ts b/app/src/hooks/useCreateHousehold.ts index e3480d646..ef1a87b7a 100644 --- a/app/src/hooks/useCreateHousehold.ts +++ b/app/src/hooks/useCreateHousehold.ts @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { createHousehold } from '@/api/household'; import { MOCK_USER_ID } from '@/constants'; import { countryIds } from '@/libs/countries'; +import { captureApiException } from '@/utils/errorTracking'; import { useCreateHouseholdAssociation } from './useUserHousehold'; export function useCreateHousehold(householdLabel?: string) { @@ -22,6 +23,12 @@ export function useCreateHousehold(householdLabel?: string) { }); } catch (error) { console.error('Household created but association failed:', error); + captureApiException(error, { + source: 'household_association', + country_id: variables.country_id, + household_id: data.result.household_id, + has_label: Boolean(householdLabel), + }); } }, }); diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index 9a6b377ae..5d0c3645a 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { createPolicy } from '@/api/policy'; import { MOCK_USER_ID } from '@/constants'; import { PolicyCreationPayload } from '@/types/payloads'; +import { captureApiException } from '@/utils/errorTracking'; import { useCurrentCountry } from './useCurrentCountry'; import { useCreatePolicyAssociation } from './useUserPolicy'; @@ -26,6 +27,12 @@ export function useCreatePolicy(policyLabel?: string) { }); } catch (error) { console.error('Policy created but association failed:', error); + captureApiException(error, { + source: 'policy_association', + country_id: countryId, + policy_id: data.result.policy_id, + has_label: Boolean(policyLabel), + }); } }, }); diff --git a/app/src/hooks/useCreateReport.ts b/app/src/hooks/useCreateReport.ts index 7a7b4d5ef..d4d477899 100644 --- a/app/src/hooks/useCreateReport.ts +++ b/app/src/hooks/useCreateReport.ts @@ -8,6 +8,11 @@ import { Geography } from '@/types/ingredients/Geography'; import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { ReportCreationPayload } from '@/types/payloads'; +import { trackReportCreated } from '@/utils/analytics'; +import { + captureCalculationException, + captureCalculatorException, +} from '@/utils/errorTracking'; interface CreateReportAndBeginCalculationParams { countryId: (typeof countryIds)[number]; @@ -83,12 +88,23 @@ export function useCreateReport(reportLabel?: string) { try { const { report, simulations, populations } = result; const reportIdStr = String(report.id); + const simulationList = [simulations?.simulation1, simulations?.simulation2].filter( + (simulation): simulation is Simulation => Boolean(simulation) + ); // Invalidate report association queries so the Reports page picks up the new report queryClient.invalidateQueries({ queryKey: reportAssociationKeys.all }); // Cache the report data using consistent key structure queryClient.setQueryData(reportKeys.byId(reportIdStr), report); + trackReportCreated({ + countryId: report.countryId, + report: { + ...report, + id: reportIdStr, + }, + simulations: simulationList, + }); // Determine calculation type from simulation const simulation1 = simulations?.simulation1; @@ -147,6 +163,13 @@ export function useCreateReport(reportLabel?: string) { `[useCreateReport] Failed to start calculation for simulation ${sim.id}:`, error ); + captureCalculationException(error, { + source: 'use_create_report_household_start', + country_id: report.countryId, + year: report.year, + report_id: reportIdStr, + simulation_id: sim.id, + }); }); } } else { @@ -173,6 +196,10 @@ export function useCreateReport(reportLabel?: string) { } } catch (error) { console.error('[useCreateReport] Post-creation tasks failed:', error); + captureCalculatorException(error, { + source: 'use_create_report_on_success', + report_label: reportLabel, + }); } finally { if (import.meta.env.DEV) { (window as any).__journeyProfiler?.markEnd('report-onSuccess', 'render'); diff --git a/app/src/libs/calculations/CalcOrchestrator.ts b/app/src/libs/calculations/CalcOrchestrator.ts index 56f368af5..bfac40f31 100644 --- a/app/src/libs/calculations/CalcOrchestrator.ts +++ b/app/src/libs/calculations/CalcOrchestrator.ts @@ -1,7 +1,12 @@ import { QueryClient, QueryObserver } from '@tanstack/react-query'; import { calculationQueries } from '@/libs/queries/calculationQueries'; import type { CalcMetadata, CalcParams, CalcStartConfig, CalcStatus } from '@/types/calculation'; -import { trackSimulationCompleted } from '@/utils/analytics'; +import { + trackCalculationFailed, + trackCalculationStarted, + trackSimulationCompleted, +} from '@/utils/analytics'; +import { captureCalculationException } from '@/utils/errorTracking'; import type { CalcOrchestratorManager } from './CalcOrchestratorManager'; import { ResultPersister } from './ResultPersister'; @@ -59,6 +64,7 @@ export class CalcOrchestrator { // Build metadata and params const metadata = this.buildMetadata(config); const params = this.buildParams(config); + trackCalculationStarted({ config }); // Create query options (includes refetchInterval from strategy) const queryOptions = @@ -80,7 +86,32 @@ export class CalcOrchestrator { } // Execute initial queryFn - const initialStatus = await queryOptions.queryFn(); + let initialStatus: CalcStatus; + + try { + initialStatus = await queryOptions.queryFn(); + } catch (error) { + trackCalculationFailed({ + calcId: config.calcId, + targetType: config.targetType, + countryId: config.countryId, + year: config.year, + calcType: metadata.calcType, + reportId: config.reportId, + durationMs: Date.now() - metadata.startedAt, + error, + config, + }); + captureCalculationException(error, { + source: 'calc_orchestrator_query_fn', + calc_id: config.calcId, + target_type: config.targetType, + country_id: config.countryId, + year: config.year, + report_id: config.reportId, + }); + throw error; + } // Set result in cache this.queryClient.setQueryData(queryOptions.queryKey, initialStatus); @@ -88,7 +119,15 @@ export class CalcOrchestrator { // CRITICAL DECISION POINT: Household vs Economy if (initialStatus.status === 'complete') { // HOUSEHOLD CASE: Calculation completed synchronously - trackSimulationCompleted({ calcType: metadata.calcType, countryId: config.countryId }); + trackSimulationCompleted({ + calcType: metadata.calcType, + countryId: config.countryId, + year: config.year, + calcId: config.calcId, + targetType: config.targetType, + reportId: config.reportId, + durationMs: Date.now() - metadata.startedAt, + }); await this.resultPersister.persist(initialStatus, config.countryId, config.year); // Notify manager to cleanup this orchestrator @@ -157,7 +196,15 @@ export class CalcOrchestrator { // Handle completion if (status.status === 'complete' && status.result) { - trackSimulationCompleted({ calcType: _metadata.calcType, countryId }); + trackSimulationCompleted({ + calcType: _metadata.calcType, + countryId, + year, + calcId, + targetType: _metadata.targetType, + reportId: _metadata.reportId, + durationMs: Date.now() - _metadata.startedAt, + }); this.resultPersister .persist(status, countryId, year) .catch((error) => { @@ -179,6 +226,24 @@ export class CalcOrchestrator { // Handle error if (status.status === 'error') { console.error('[CalcOrchestrator] Calculation error:', status.error); + trackCalculationFailed({ + calcId, + targetType: _metadata.targetType, + countryId, + year, + calcType: _metadata.calcType, + reportId: _metadata.reportId, + durationMs: Date.now() - _metadata.startedAt, + error: status.error, + }); + captureCalculationException(status.error, { + source: 'calc_orchestrator_polling', + calc_id: calcId, + target_type: _metadata.targetType, + country_id: countryId, + year, + report_id: _metadata.reportId, + }); unsubscribe(); this.currentUnsubscribe = null; diff --git a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts index 71a7a10cc..89baa9801 100644 --- a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts +++ b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts @@ -6,6 +6,7 @@ import type { HouseholdReportConfig, SimulationConfig } from '@/types/calculatio import type { HouseholdData } from '@/types/ingredients/Household'; import type { Report } from '@/types/ingredients/Report'; import { cacheMonitor } from '@/utils/cacheMonitor'; +import { captureCalculationException } from '@/utils/errorTracking'; import { HouseholdProgressCoordinator } from './HouseholdProgressCoordinator'; import { buildHouseholdReportOutput } from './householdReportUtils'; import { HouseholdSimCalculator } from './HouseholdSimCalculator'; @@ -93,6 +94,11 @@ export class HouseholdReportOrchestrator { }) .catch((error) => { console.error('[HouseholdReportOrchestrator] Error in parallel execution:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_parallel', + country_id: countryId, + report_id: reportId, + }); // Mark report as error return this.markReportError(config.report, countryId, reportId); @@ -147,6 +153,12 @@ export class HouseholdReportOrchestrator { await this.persistSimulation(countryId, simulationId, result); } catch (error) { console.error('[HouseholdReportOrchestrator] Simulation failed:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_simulation', + country_id: countryId, + report_id: reportId, + simulation_id: simulationId, + }); // Notify progress coordinator that this simulation failed progressCoordinator.failSimulation(simulationId); @@ -256,6 +268,11 @@ export class HouseholdReportOrchestrator { } } catch (error) { console.error('[HouseholdReportOrchestrator] Failed to mark report complete:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_complete', + country_id: countryId, + report_id: reportId, + }); } } @@ -291,6 +308,11 @@ export class HouseholdReportOrchestrator { } } catch (error) { console.error('[HouseholdReportOrchestrator] Failed to mark report error:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_mark_error', + country_id: countryId, + report_id: reportId, + }); } } } diff --git a/app/src/pages/report-output/HouseholdReportOutput.tsx b/app/src/pages/report-output/HouseholdReportOutput.tsx index 178754608..3a8cbf582 100644 --- a/app/src/pages/report-output/HouseholdReportOutput.tsx +++ b/app/src/pages/report-output/HouseholdReportOutput.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useSimulationProgressDisplay } from '@/hooks/household'; import type { Household } from '@/types/ingredients/Household'; import type { Policy } from '@/types/ingredients/Policy'; @@ -10,6 +10,7 @@ import type { UserSimulation } from '@/types/ingredients/UserSimulation'; import { resolveDefaultReportOutputSubpage } from '@/utils/reportOutputSubpage'; import { convertPoliciesToV1Format } from '@/utils/reproducibilityCode'; import { getDisplayStatus } from '@/utils/statusMapping'; +import { trackReportOutputSubpageViewed, trackReportOutputViewed } from '@/utils/analytics'; import DynamicsSubPage from './DynamicsSubPage'; import ErrorPage from './ErrorPage'; import { HouseholdComparativeAnalysisPage } from './HouseholdComparativeAnalysisPage'; @@ -153,6 +154,34 @@ export function HouseholdReportOutput({ }: HouseholdReportOutputProps) { const normalizedSubpage = resolveDefaultReportOutputSubpage('household', subpage); + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputViewed({ + report, + simulations, + calcType: 'household', + subpage: normalizedSubpage, + activeView, + }); + }, [report?.id]); + + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputSubpageViewed({ + report, + simulations, + calcType: 'household', + subpage: normalizedSubpage, + activeView, + }); + }, [activeView, normalizedSubpage, report, simulations]); + // Build view model (memoized - recomputes only when props change) const viewModel = useMemo( () => new HouseholdReportViewModel(report, simulations, userSimulations, userPolicies), diff --git a/app/src/pages/report-output/SocietyWideReportOutput.tsx b/app/src/pages/report-output/SocietyWideReportOutput.tsx index 8bc36867d..c623fab2f 100644 --- a/app/src/pages/report-output/SocietyWideReportOutput.tsx +++ b/app/src/pages/report-output/SocietyWideReportOutput.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/societyWideCalculation'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; @@ -16,6 +16,7 @@ import type { UserSimulation } from '@/types/ingredients/UserSimulation'; import { resolveDefaultReportOutputSubpage } from '@/utils/reportOutputSubpage'; import { convertPoliciesToV1Format } from '@/utils/reproducibilityCode'; import { getDisplayStatus } from '@/utils/statusMapping'; +import { trackReportOutputSubpageViewed, trackReportOutputViewed } from '@/utils/analytics'; import { ComparativeAnalysisPage } from './ComparativeAnalysisPage'; import { ConstituencySubPage } from './ConstituencySubPage'; import DynamicsSubPage from './DynamicsSubPage'; @@ -159,6 +160,34 @@ export function SocietyWideReportOutput({ }: SocietyWideReportOutputProps) { const normalizedSubpage = resolveDefaultReportOutputSubpage('societyWide', subpage); + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputViewed({ + report, + simulations, + calcType: 'societyWide', + subpage: normalizedSubpage, + activeView, + }); + }, [report?.id]); + + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputSubpageViewed({ + report, + simulations, + calcType: 'societyWide', + subpage: normalizedSubpage, + activeView, + }); + }, [activeView, normalizedSubpage, report, simulations]); + // Read datasets from metadata for the reproduce tab const datasets = useSelector((state: RootState) => state.metadata.economyOptions?.datasets); diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index d47692879..719d596ba 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -13,6 +13,7 @@ import { IconPlayerPlay } from '@tabler/icons-react'; import { CURRENT_YEAR } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { trackSocietyWideBuilderOpened } from '@/utils/analytics'; import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; import { getReportOutputPath } from '@/utils/reportRouting'; import { ReportBuilderShell, SimulationBlockFull } from './components'; @@ -43,6 +44,13 @@ export default function ReportBuilderPage() { ingredientType: 'policy', }); + useEffect(() => { + trackSocietyWideBuilderOpened({ + countryId, + year: CURRENT_YEAR, + }); + }, [countryId]); + // Submission logic (extracted hook) const { handleSubmit, isSubmitting, isReportConfigured } = useReportSubmission({ reportState, diff --git a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx index f641a77db..0552ae183 100644 --- a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx +++ b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx @@ -21,6 +21,8 @@ import { import { CURRENT_YEAR } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { getTaxYears } from '@/libs/metadataUtils'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { trackReportYearSelected } from '@/utils/analytics'; import { FONT_SIZES } from '../constants'; import type { ReportBuilderState } from '../types'; @@ -43,6 +45,7 @@ interface ReportMetaPanelProps { } export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: ReportMetaPanelProps) { + const countryId = useCurrentCountry(); const yearOptions = useSelector(getTaxYears); const [isEditingLabel, setIsEditingLabel] = useState(false); const [labelInput, setLabelInput] = useState(''); @@ -219,9 +222,19 @@ export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: Rep