From 47c8cdf76a73328cecc7d13a5514efb8c02e7a94 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 2 Apr 2026 22:44:22 +0200 Subject: [PATCH 1/5] Implement PostHog analytics and monitoring foundation --- Makefile | 2 +- POSTHOG_IMPLEMENTATION_PLAN.md | 802 ++++++++++++++++++ app/package.json | 1 + app/src/components/common/ErrorBoundary.tsx | 2 + .../household/HouseholdBuilderForm.tsx | 78 +- app/src/hooks/useCreateReport.ts | 27 + app/src/libs/calculations/CalcOrchestrator.ts | 73 +- .../household/HouseholdReportOrchestrator.ts | 22 + .../report-output/HouseholdReportOutput.tsx | 31 +- .../report-output/SocietyWideReportOutput.tsx | 31 +- .../pages/reportBuilder/ReportBuilderPage.tsx | 8 + .../hooks/useReportSubmission.ts | 26 +- .../modals/PopulationBrowseModal.tsx | 19 + .../population/HouseholdCreationContent.tsx | 1 + .../report/views/policy/PolicySubmitView.tsx | 7 +- .../views/population/HouseholdBuilderView.tsx | 18 +- app/src/utils/analytics.ts | 324 ++++++- app/src/utils/analyticsSchemas.ts | 131 +++ app/src/utils/analyticsSnapshots.ts | 225 +++++ app/src/utils/errorTracking.ts | 59 ++ app/src/utils/posthogClient.ts | 18 + bun.lock | 94 +- calculator-app/.env.example | 8 + calculator-app/instrumentation-client.ts | 32 + calculator-app/instrumentation.ts | 69 ++ calculator-app/package.json | 4 +- calculator-app/src/app/[countryId]/error.tsx | 5 + calculator-app/src/app/error.tsx | 5 + calculator-app/src/app/global-error.tsx | 46 + calculator-app/src/app/layout.tsx | 5 +- calculator-app/src/app/providers.tsx | 12 + calculator-app/src/lib/posthog-server.ts | 34 + package.json | 3 +- scripts/dev-server-next.mjs | 147 ++++ website/.env.example | 12 +- website/instrumentation-client.ts | 32 + website/instrumentation.ts | 69 ++ website/package.json | 4 +- .../[countryId]/research/ResearchClient.tsx | 27 + .../research/[slug]/ArticleClient.tsx | 15 +- website/src/app/error.tsx | 43 + website/src/app/global-error.tsx | 37 + website/src/app/layout.tsx | 3 +- website/src/app/providers.tsx | 12 + website/src/components/Footer.tsx | 19 + website/src/components/Header.tsx | 8 +- website/src/components/home/HeroCTA.tsx | 11 +- website/src/lib/posthog-events.ts | 114 +++ website/src/lib/posthog-server.ts | 34 + 49 files changed, 2754 insertions(+), 55 deletions(-) create mode 100644 POSTHOG_IMPLEMENTATION_PLAN.md create mode 100644 app/src/utils/analyticsSchemas.ts create mode 100644 app/src/utils/analyticsSnapshots.ts create mode 100644 app/src/utils/errorTracking.ts create mode 100644 app/src/utils/posthogClient.ts create mode 100644 calculator-app/.env.example create mode 100644 calculator-app/instrumentation-client.ts create mode 100644 calculator-app/instrumentation.ts create mode 100644 calculator-app/src/app/global-error.tsx create mode 100644 calculator-app/src/app/providers.tsx create mode 100644 calculator-app/src/lib/posthog-server.ts create mode 100644 scripts/dev-server-next.mjs create mode 100644 website/instrumentation-client.ts create mode 100644 website/instrumentation.ts create mode 100644 website/src/app/error.tsx create mode 100644 website/src/app/global-error.tsx create mode 100644 website/src/app/providers.tsx create mode 100644 website/src/lib/posthog-events.ts create mode 100644 website/src/lib/posthog-server.ts diff --git a/Makefile b/Makefile index e40b0203f..efcc6b66e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ help: @echo "Available commands:" @echo " make install - Install dependencies" - @echo " make dev - Start development server" + @echo " make dev - Start Next.js website + calculator dev servers" @echo " make build - Build production version" @echo " make typecheck - Run type checks" @echo " make test - Run tests" diff --git a/POSTHOG_IMPLEMENTATION_PLAN.md b/POSTHOG_IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..788566fc2 --- /dev/null +++ b/POSTHOG_IMPLEMENTATION_PLAN.md @@ -0,0 +1,802 @@ +# PostHog Implementation Plan + +## Current Single-Branch Execution Plan + +This is the implementation plan we are actively working from on the current branch. + +### Goals + +- finish the Next.js PostHog foundation cleanly +- fully instrument both calculator surfaces: + - household flows + - society-wide/report flows +- add real website event instrumentation +- complete robust error tracking, release plumbing, and replay/deployment hardening + +### Delivery Strategy + +We will do this in one branch, but in a few internal milestones so validation stays manageable. + +#### Milestone 1: Foundation Cleanup + +- normalize the Next.js bootstrap and shared PostHog access points +- keep `website/` and `calculator-app/` as the real runtime bootstrap locations +- keep shared analytics helpers in `app/src/` +- standardize environment variable usage and ownership +- preserve the Next-based local dev workflow as the main validation path + +#### Milestone 2: Calculator Event Schema + +- add typed event contracts for calculator analytics +- add snapshot builders for modeled inputs +- implement full event coverage for: + - builders + - report creation + - calculation lifecycle + - report output lifecycle + - download/share/copy actions +- treat household and society-wide flows as equal first-class surfaces + +#### Milestone 3: Website Event Instrumentation + +- add real website-specific custom events for: + - entering the calculator + - newsletter lifecycle + - research list interactions + - research article views + - country switching + +#### Milestone 4: Error Tracking and Release Plumbing + +- expand manual exception capture in high-value calculator paths +- make Next route/global/shared-boundary errors consistent +- add release metadata and source-map/release upload hooks +- make server-side request error capture production-ready + +#### Milestone 5: Replay and Deployment Hardening + +- enable replay intentionally for website and calculator +- add ingestion hardening through proxy or managed proxy configuration +- align env and deployment behavior across local, preview, and production + +#### Milestone 6: Final Validation and Docs + +- verify pageviews, custom events, and exceptions in both apps +- verify release metadata and production diagnostics +- update this document to reflect the implemented system instead of the planned one +- leave a short internal runbook for local verification and expected events + +### Current Status + +- foundation: substantially complete +- calculator event schema: in progress +- website event instrumentation: in progress +- error tracking: partially complete +- replay/release/proxy hardening: not yet complete + +### Implementation Notes + +Current branch progress: + +- Next.js PostHog bootstrap remains owned by `website/` and `calculator-app` +- shared calculator analytics now have typed event contracts and snapshot builders +- report creation, calculation lifecycle, report output views, household builder interactions, and policy creation now emit richer calculator events +- website CTA, newsletter, country switcher, research filters, and article views now emit custom website events +- release plumbing, replay configuration, and deployment hardening remain ahead + +## Scope + +This plan covers: + +- `website/` as the public Next.js app-router site +- `calculator-app/` as the product Next.js app-router app +- shared calculator code in `app/src/` +- analytics, session replay, feature-flag readiness, and robust error tracking + +This plan assumes: + +- one PostHog project for both apps +- anonymous tracking only at first +- modeled household and society-wide configurations are valid analytics payloads +- error tracking is a first-class deliverable, not a later add-on + +## Architecture Decisions + +### 1. One PostHog project across both apps + +Reason: + +- lets us track journeys from `policyengine.org` to `app.policyengine.org` +- keeps website, calculator, replay, errors, and experiments in one place +- simplifies release tracking and error correlation + +### 2. Use Next.js-native PostHog setup + +Use: + +- `instrumentation-client.ts` for client bootstrap +- app-root PostHog provider for hooks and React integrations +- `instrumentation.ts` plus `posthog-node` for server-side request error capture +- app-router `error.tsx` and `global-error.tsx` for robust client exception capture + +### 3. Keep calculator telemetry in shared code + +Most calculator behavior lives in `app/src/`, not the thin Next route wrappers in `calculator-app/src/app/**`. + +Therefore: + +- Next-specific bootstrap belongs in `calculator-app/` +- event and exception helpers used by calculator flows belong in `app/src/utils/**` +- instrumentation should be attached at real product behavior points in shared code + +### 4. Use a typed event model + +Implement typed payload maps so event names and payloads are not ad hoc strings. + +Benefits: + +- fewer broken queries +- consistent dimensions across household and society-wide flows +- easier refactors when shared code changes + +### 5. Stay anonymous for now + +Do not call `identify()` with placeholders like `anonymous`. + +Initial plan: + +- rely on PostHog anonymous distinct IDs +- do not create person profiles intentionally +- add `identify(realUserId)` later only if/when auth exists + +## Environment Variables + +Use these in both apps unless noted otherwise. + +| Variable | Scope | Purpose | +| --- | --- | --- | +| `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN` | client | PostHog project token | +| `NEXT_PUBLIC_POSTHOG_HOST` | client | PostHog host or managed reverse-proxy host | +| `NEXT_PUBLIC_APP_RELEASE` | client | release SHA/version shown on all client events | +| `APP_RELEASE` | server | release SHA/version for server-side errors/events | +| `POSTHOG_PROJECT_TOKEN` | server | server-side token if not reusing public token | +| `POSTHOG_HOST` | server | server-side host | +| `POSTHOG_PERSONAL_API_KEY` | deploy | source-map / release upload | +| `POSTHOG_PROJECT_ID` | deploy | release/source-map targeting | +| `NEXT_PUBLIC_WEBSITE_URL` | calculator only | already used by calculator build | +| `NEXT_PUBLIC_CALCULATOR_URL` | website only | already used by website CTA | + +Recommended release value: + +- `VERCEL_GIT_COMMIT_SHA` + +## Package Dependencies + +### Add to `website/package.json` + +- `posthog-js` +- `posthog-node` + +### Add to `calculator-app/package.json` + +- `posthog-js` +- `posthog-node` + +No separate `@posthog/react` package is required for this plan. Use `posthog-js/react` per the Next.js docs path. + +## File-by-File Changes + +## Website: New Files + +| File | Responsibility | Exact Exports / Functions | +| --- | --- | --- | +| `website/instrumentation-client.ts` | client bootstrap for PostHog | initialize `posthog` singleton with `defaults`, replay, autocapture, release property | +| `website/instrumentation.ts` | server request error capture | `register()`, `onRequestError(err, request, context)` | +| `website/src/app/providers.tsx` | app-level React provider for PostHog | `PostHogProvider({ children })`, `PostHogPageviewTracker()` | +| `website/src/lib/posthog-server.ts` | singleton Node SDK setup | `getPostHogServer()` | +| `website/src/lib/posthog-events.ts` | typed website event helpers | `captureWebsiteEvent`, `captureWebsiteException`, `trackEnterCalculatorClicked`, `trackNewsletterSignupStarted`, `trackNewsletterSignupSucceeded`, `trackNewsletterSignupFailed`, `trackResearchArticleViewed`, `trackResearchFiltersChanged`, `trackCountrySwitched` | +| `website/src/app/error.tsx` | route-level error capture | default error component calling `captureWebsiteException` | +| `website/src/app/global-error.tsx` | root-layout error capture | default global error component calling `captureWebsiteException` | +| `website/scripts/posthog-release.mjs` | release + source-map upload | `main()` | + +## Website: Modified Files + +| File | Change | +| --- | --- | +| [website/src/app/layout.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/app/layout.tsx) | wrap app body with `PostHogProvider` | +| [website/src/components/home/HeroCTA.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/components/home/HeroCTA.tsx) | fire `trackEnterCalculatorClicked` before navigation | +| [website/src/components/Footer.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/components/Footer.tsx) | fire newsletter signup started/succeeded/failed events | +| [website/src/components/Header.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/components/Header.tsx) | fire `trackCountrySwitched` | +| [website/src/app/[countryId]/research/ResearchClient.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/app/[countryId]/research/ResearchClient.tsx) | fire `trackResearchFiltersChanged` on meaningful filter/search changes | +| [website/src/app/[countryId]/research/[slug]/ArticleClient.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx) | fire `trackResearchArticleViewed` on mount | +| [website/scripts/vercel-build.sh](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/scripts/vercel-build.sh) | invoke `posthog-release.mjs` after build when env is available | + +## Calculator App: New Files + +| File | Responsibility | Exact Exports / Functions | +| --- | --- | --- | +| `calculator-app/instrumentation-client.ts` | client bootstrap for PostHog | initialize `posthog` singleton with replay, autocapture, release property | +| `calculator-app/instrumentation.ts` | server request error capture | `register()`, `onRequestError(err, request, context)` | +| `calculator-app/src/app/posthog-provider.tsx` | app-root PostHog React provider | `PostHogProvider({ children })`, `PostHogPageviewTracker()` | +| `calculator-app/src/lib/posthog-server.ts` | singleton Node SDK setup | `getPostHogServer()` | +| `calculator-app/src/app/global-error.tsx` | root-layout error capture | default global error component calling calculator exception helper | +| `calculator-app/scripts/posthog-release.mjs` | release + source-map upload | `main()` | +| `calculator-app/scripts/vercel-build.sh` | deployment build wrapper | build app, then call `posthog-release.mjs` | +| `calculator-app/vercel.json` | explicit build config if calculator is deployed independently | install/build/output settings | + +## Calculator App: Modified Files + +| File | Change | +| --- | --- | +| [calculator-app/src/app/layout.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/src/app/layout.tsx) | wrap root body with `PostHogProvider` | +| [calculator-app/src/app/error.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/src/app/error.tsx) | call calculator exception helper in `useEffect` | +| [calculator-app/src/app/[countryId]/error.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/src/app/[countryId]/error.tsx) | call calculator exception helper in `useEffect` | +| [calculator-app/package.json](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/package.json) | add PostHog dependencies and optional build script | +| [calculator-app/next.config.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/next.config.ts) | expose PostHog env vars into shared code if needed | + +## Shared Calculator Code: New Files + +| File | Responsibility | Exact Exports / Functions | +| --- | --- | --- | +| `app/src/utils/analyticsSchemas.ts` | typed calculator event contracts | `CalculatorEventName`, `CalculatorEventPayloadMap`, payload interfaces | +| `app/src/utils/errorTracking.ts` | shared exception capture utilities | `captureCalculatorException(error, context?)`, `captureCalculationException(error, context)`, `captureRouteException(error, context)` | +| `app/src/utils/analyticsSnapshots.ts` | serialization helpers for modeled inputs | `buildHouseholdSnapshot`, `buildSimulationSnapshot`, `buildReportConfigSnapshot` | +| `app/src/utils/posthogClient.ts` | browser-safe singleton access | `getPostHogClient()`, `isPostHogAvailable()` | + +## Shared Calculator Code: Modified Files + +| File | Change | +| --- | --- | +| [app/src/utils/analytics.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/utils/analytics.ts) | replace GA-only `gtag` wrapper with typed PostHog-backed wrapper; keep existing helper exports; add new calculator helpers | +| [app/src/components/common/ErrorBoundary.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/components/common/ErrorBoundary.tsx) | call `captureCalculatorException` in `componentDidCatch` | +| [app/src/pages/reportBuilder/hooks/useReportSubmission.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/reportBuilder/hooks/useReportSubmission.ts) | emit report-builder and snapshot events | +| [app/src/hooks/useCreateReport.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/hooks/useCreateReport.ts) | emit report-created / calculation-start events and capture exceptions | +| [app/src/libs/calculations/CalcOrchestrator.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/libs/calculations/CalcOrchestrator.ts) | emit `calculation_started`, `calculation_completed`, `calculation_failed` | +| [app/src/libs/calculations/household/HouseholdReportOrchestrator.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts) | emit household-specific calc failures and capture exceptions | +| [app/src/components/household/HouseholdBuilderForm.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/components/household/HouseholdBuilderForm.tsx) | emit builder-opened and variable add/remove events | +| [app/src/pathways/report/views/policy/PolicySubmitView.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pathways/report/views/policy/PolicySubmitView.tsx) | preserve `trackPolicyCreated` and include richer context | +| [app/src/pages/report-output/SocietyWideReportOutput.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/report-output/SocietyWideReportOutput.tsx) | emit output viewed / subpage viewed | +| [app/src/pages/report-output/HouseholdReportOutput.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/report-output/HouseholdReportOutput.tsx) | emit output viewed / subpage viewed | +| [app/src/components/ChartContainer.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/components/ChartContainer.tsx) | preserve CSV event with richer context | +| [app/src/components/report/DashboardCard.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/components/report/DashboardCard.tsx) | preserve CSV event with richer context | +| [app/src/pages/report-output/reproduce-in-python/PolicyReproducibility.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/report-output/reproduce-in-python/PolicyReproducibility.tsx) | preserve Python copy event with richer context | +| [app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx) | preserve Python copy event with richer context | + +## Exact Function Surface + +## `app/src/utils/analytics.ts` + +Implement these exact exports. + +### Core exports + +- `captureCalculatorEvent(event: T, properties: CalculatorEventPayloadMap[T]): void` +- `registerCalculatorSuperProperties(properties: Record): void` +- `clearCalculatorSuperProperties(): void` + +### Existing exports to preserve + +- `trackSimulationCompleted` +- `trackToolEngaged` +- `trackContactClicked` +- `trackNewsletterSignup` +- `trackReportStarted` +- `trackPolicyCreated` +- `trackChartCsvDownloaded` +- `trackPythonCodeCopied` +- `trackLandingPageViewed` + +### New exports to add + +- `trackBuilderOpened` +- `trackBuilderSelectionChanged` +- `trackHouseholdVariableAdded` +- `trackHouseholdVariableRemoved` +- `trackHouseholdSaved` +- `trackReportCreated` +- `trackCalculationStarted` +- `trackCalculationFailed` +- `trackReportOutputViewed` +- `trackReportOutputSubpageViewed` +- `trackReportShared` +- `trackConfigurationSnapshot` + +## `app/src/utils/errorTracking.ts` + +Implement these exact exports. + +- `captureCalculatorException(error: unknown, context?: Record): void` +- `captureCalculationException(error: unknown, context: Record): void` +- `captureRouteException(error: unknown, context: Record): void` +- `captureApiException(error: unknown, context: Record): void` + +## `website/src/lib/posthog-events.ts` + +Implement these exact exports. + +- `captureWebsiteEvent(name: WebsiteEventName, properties: WebsiteEventProperties): void` +- `captureWebsiteException(error: unknown, context?: Record): void` +- `trackEnterCalculatorClicked` +- `trackNewsletterSignupStarted` +- `trackNewsletterSignupSucceeded` +- `trackNewsletterSignupFailed` +- `trackResearchArticleViewed` +- `trackResearchFiltersChanged` +- `trackCountrySwitched` + +## Event Model + +## Base calculator event properties + +Every calculator event should automatically include: + +```ts +interface BaseCalculatorEventProperties { + surface: "calculator"; + release: string; + country_id?: string; + year?: string; + report_id?: string; + simulation_id?: string; + simulation_ids?: string[]; + calc_type?: "household" | "society_wide"; + builder_kind?: "household" | "society_wide" | "policy" | "report"; + output_subpage?: string; + output_view?: string; +} +``` + +## Base website event properties + +Every website event should automatically include: + +```ts +interface BaseWebsiteEventProperties { + surface: "website"; + release: string; + country_id?: string; + pathname?: string; + referrer?: string; +} +``` + +## Calculator event names and payload shapes + +### `builder_opened` + +Use for both household and society-wide entry flows. + +```ts +interface BuilderOpenedProperties extends BaseCalculatorEventProperties { + builder_kind: "household" | "society_wide"; + entrypoint: string; +} +``` + +### `builder_selection_changed` + +Use for high-value report-builder choices. Do not emit on every keystroke. + +```ts +interface BuilderSelectionChangedProperties extends BaseCalculatorEventProperties { + builder_kind: "household" | "society_wide"; + selection_key: + | "year" + | "dataset" + | "baseline_policy_id" + | "reform_policy_id" + | "population_type" + | "geography_id" + | "geography_type"; + selection_value: string | number | boolean | null; +} +``` + +### `household_variable_added` + +```ts +interface HouseholdVariableAddedProperties extends BaseCalculatorEventProperties { + builder_kind: "household"; + variable_name: string; + entity_name?: string; + entity_kind: "person" | "household" | "tax_unit" | "spm_unit"; +} +``` + +### `household_variable_removed` + +```ts +interface HouseholdVariableRemovedProperties extends BaseCalculatorEventProperties { + builder_kind: "household"; + variable_name: string; + entity_name?: string; + entity_kind: "person" | "household" | "tax_unit" | "spm_unit"; +} +``` + +### `household_saved` + +```ts +interface HouseholdSavedProperties extends BaseCalculatorEventProperties { + builder_kind: "household"; + household_id?: string; + person_count: number; + child_count: number; + marital_status: "single" | "married"; + variable_names: string[]; +} +``` + +### `report_started` + +Preserve existing event name and enrich it. + +```ts +interface ReportStartedProperties extends BaseCalculatorEventProperties { + builder_kind: "household" | "society_wide"; + country_id: string; + year: string; + simulation_count: number; + baseline_policy_id: string; + reform_policy_id?: string; + population_type: "household" | "geography"; + geography_id?: string; + dataset?: string; +} +``` + +### `report_created` + +```ts +interface ReportCreatedProperties extends ReportStartedProperties { + report_id: string; + simulation_ids: string[]; +} +``` + +### `calculation_started` + +```ts +interface CalculationStartedProperties extends BaseCalculatorEventProperties { + calc_type: "household" | "society_wide"; + report_id?: string; + simulation_id?: string; + country_id: string; + year: string; +} +``` + +### `simulation_completed` + +Preserve existing event name and enrich it. + +```ts +interface SimulationCompletedProperties extends BaseCalculatorEventProperties { + calc_type: "household" | "society_wide"; + country_id: string; + report_id?: string; + simulation_id?: string; + duration_ms?: number; +} +``` + +### `calculation_failed` + +```ts +interface CalculationFailedProperties extends BaseCalculatorEventProperties { + calc_type: "household" | "society_wide"; + country_id: string; + report_id?: string; + simulation_id?: string; + error_name?: string; + error_message?: string; + stage: + | "create_report" + | "create_simulation" + | "fetch_household" + | "fetch_society_wide" + | "persist_simulation" + | "persist_report" + | "resume_on_load"; +} +``` + +### `report_output_viewed` + +```ts +interface ReportOutputViewedProperties extends BaseCalculatorEventProperties { + report_id: string; + calc_type: "household" | "society_wide"; + output_subpage: string; + output_view?: string; +} +``` + +### `report_output_subpage_viewed` + +```ts +interface ReportOutputSubpageViewedProperties extends BaseCalculatorEventProperties { + report_id: string; + calc_type: "household" | "society_wide"; + output_subpage: string; + output_view?: string; +} +``` + +### `configuration_snapshot` + +Use only at milestone points. + +```ts +interface ConfigurationSnapshotProperties extends BaseCalculatorEventProperties { + snapshot_kind: "report_config" | "simulation" | "household"; + report_config?: Record; + simulation_config?: Record; + household_config?: Record; +} +``` + +## Website event names and payload shapes + +### `enter_calculator_clicked` + +```ts +interface EnterCalculatorClickedProperties extends BaseWebsiteEventProperties { + country_id: string; + destination: string; + cta_location: "hero"; +} +``` + +### `newsletter_signup_started` + +```ts +interface NewsletterSignupStartedProperties extends BaseWebsiteEventProperties { + placement: "footer"; +} +``` + +### `newsletter_signup_succeeded` + +```ts +interface NewsletterSignupSucceededProperties extends BaseWebsiteEventProperties { + placement: "footer"; +} +``` + +### `newsletter_signup_failed` + +```ts +interface NewsletterSignupFailedProperties extends BaseWebsiteEventProperties { + placement: "footer"; + error_message?: string; +} +``` + +### `research_article_viewed` + +```ts +interface ResearchArticleViewedProperties extends BaseWebsiteEventProperties { + country_id: string; + slug: string; + title: string; + tags: string[]; +} +``` + +### `research_filters_changed` + +```ts +interface ResearchFiltersChangedProperties extends BaseWebsiteEventProperties { + country_id: string; + search_query?: string; + topic_filters: string[]; + location_filters: string[]; + sort?: string; +} +``` + +### `country_switched` + +```ts +interface CountrySwitchedProperties extends BaseWebsiteEventProperties { + from_country_id: string; + to_country_id: string; + pathname: string; +} +``` + +## Exact Instrumentation Points + +## Website + +| Event | File | Trigger | +| --- | --- | --- | +| `enter_calculator_clicked` | [website/src/components/home/HeroCTA.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/components/home/HeroCTA.tsx) | CTA click | +| `newsletter_signup_started` / `succeeded` / `failed` | [website/src/components/Footer.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/components/Footer.tsx) | Mailchimp submission lifecycle | +| `country_switched` | [website/src/components/Header.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/components/Header.tsx) | country selector change | +| `research_filters_changed` | [website/src/app/[countryId]/research/ResearchClient.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/app/[countryId]/research/ResearchClient.tsx) | debounced search/filter changes | +| `research_article_viewed` | [website/src/app/[countryId]/research/[slug]/ArticleClient.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx) | article client mount | + +## Calculator + +| Event | File | Trigger | +| --- | --- | --- | +| `builder_opened` | [calculator-app/src/app/[countryId]/households/create/page.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/src/app/[countryId]/households/create/page.tsx) and [calculator-app/src/app/[countryId]/reports/create/page.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/src/app/[countryId]/reports/create/page.tsx) or underlying shared page mount | first render of household / society-wide builder surfaces | +| `household_variable_added` / `removed` | [app/src/components/household/HouseholdBuilderForm.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/components/household/HouseholdBuilderForm.tsx) | variable add/remove handlers | +| `household_saved` | shared create-household success path | household creation success | +| `builder_selection_changed` | [app/src/pages/reportBuilder/hooks/useReportSubmission.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/reportBuilder/hooks/useReportSubmission.ts) and related builder state change handlers | year/dataset/geography/policy changes | +| `report_started` | [app/src/pages/reportBuilder/hooks/useReportSubmission.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/reportBuilder/hooks/useReportSubmission.ts) | submit click | +| `report_created` | [app/src/hooks/useCreateReport.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/hooks/useCreateReport.ts) | report creation success | +| `calculation_started` | [app/src/libs/calculations/CalcOrchestrator.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/libs/calculations/CalcOrchestrator.ts) | initial calc start | +| `simulation_completed` | [app/src/libs/calculations/CalcOrchestrator.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/libs/calculations/CalcOrchestrator.ts) | existing completion logic | +| `calculation_failed` | [app/src/libs/calculations/CalcOrchestrator.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/libs/calculations/CalcOrchestrator.ts), [app/src/libs/calculations/household/HouseholdReportOrchestrator.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts), [app/src/hooks/useCreateReport.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/hooks/useCreateReport.ts) | any major failure point | +| `report_output_viewed` | [app/src/pages/report-output/SocietyWideReportOutput.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/report-output/SocietyWideReportOutput.tsx), [app/src/pages/report-output/HouseholdReportOutput.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/report-output/HouseholdReportOutput.tsx) | completed output first shown | +| `report_output_subpage_viewed` | same output files | subpage/view change | +| `chart_csv_downloaded` | [app/src/components/ChartContainer.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/components/ChartContainer.tsx), [app/src/components/report/DashboardCard.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/components/report/DashboardCard.tsx) | download click | +| `python_code_copied` | [app/src/pages/report-output/reproduce-in-python/PolicyReproducibility.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/report-output/reproduce-in-python/PolicyReproducibility.tsx), [app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/report-output/reproduce-in-python/HouseholdReproducibility.tsx) | copy click | + +## Error Tracking Plan + +## Client-side + +### Website + +- add `website/src/app/error.tsx` +- add `website/src/app/global-error.tsx` +- use `captureWebsiteException(error, { route: pathname, surface: "website" })` + +### Calculator app + +- modify [calculator-app/src/app/error.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/src/app/error.tsx) +- modify [calculator-app/src/app/[countryId]/error.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/src/app/[countryId]/error.tsx) +- add `calculator-app/src/app/global-error.tsx` +- upgrade [app/src/components/common/ErrorBoundary.tsx](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/components/common/ErrorBoundary.tsx) to call `captureCalculatorException` + +## Server-side + +### Website + +- add `website/instrumentation.ts` +- add `website/src/lib/posthog-server.ts` +- parse PostHog cookie when available and pass `distinctId` to `captureException` + +### Calculator app + +- add `calculator-app/instrumentation.ts` +- add `calculator-app/src/lib/posthog-server.ts` +- same cookie parsing approach + +## Catch-and-report upgrades + +Replace `console.error(...)`-only handling with capture-plus-log in: + +- [app/src/hooks/useCreateReport.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/hooks/useCreateReport.ts) +- [app/src/pages/reportBuilder/hooks/useReportSubmission.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/pages/reportBuilder/hooks/useReportSubmission.ts) +- [app/src/libs/calculations/CalcOrchestrator.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/libs/calculations/CalcOrchestrator.ts) +- [app/src/libs/calculations/household/HouseholdReportOrchestrator.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts) +- any API wrapper where failures are currently logged and rethrown without telemetry + +Standard exception context keys: + +- `surface` +- `release` +- `country_id` +- `report_id` +- `simulation_id` +- `calc_type` +- `stage` +- `pathname` + +## Replay Plan + +### Website + +- enable replay globally +- no route exclusions initially + +### Calculator app + +- enable replay globally for product routes +- no exclusions for household or society-wide setup/output +- if future auth/payment/account pages are added, exclude those routes then + +## Release and Source Map Plan + +### Website deployment + +Modify [website/scripts/vercel-build.sh](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/scripts/vercel-build.sh): + +1. build website +2. if `POSTHOG_PERSONAL_API_KEY` and `POSTHOG_PROJECT_ID` exist: + - run `node scripts/posthog-release.mjs` + +`website/scripts/posthog-release.mjs` responsibilities: + +- derive release from `VERCEL_GIT_COMMIT_SHA || APP_RELEASE` +- register release metadata in PostHog +- upload Next.js browser source maps +- attach deploy URL / environment metadata if available + +### Calculator deployment + +Add: + +- `calculator-app/scripts/vercel-build.sh` +- `calculator-app/scripts/posthog-release.mjs` +- `calculator-app/vercel.json` + +Build flow: + +1. `cd .. && bun install --frozen-lockfile` +2. build design system if needed +3. `cd calculator-app && bun run build` +4. upload release/source maps if PostHog env is available + +## Reverse Proxy Plan + +Preferred first implementation: + +- use PostHog managed reverse proxy +- set `NEXT_PUBLIC_POSTHOG_HOST` to that managed proxy host +- do not change app rewrites initially + +Fallback implementation if managed proxy is not used: + +- add `/_posthog/*` rewrites in [website/next.config.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/website/next.config.ts) and [calculator-app/next.config.ts](/Users/administrator/Documents/PolicyEngine/app-v2-posthog-pathways/calculator-app/next.config.ts) +- route analytics and static assets through that path + +## Rollout Order + +### Step 1 + +Bootstrap and shared helpers. + +- add client bootstrap files +- add providers +- add shared analytics/error utility files +- replace GA-only `analytics.ts` + +### Step 2 + +Error tracking. + +- add app-router error files +- add server-side instrumentation files +- add shared exception capture helpers + +### Step 3 + +Core calculator milestone events. + +- household builder +- report builder +- report creation +- calculation lifecycle +- output viewed + +### Step 4 + +Website events. + +- calculator CTA +- newsletter +- research filters +- article views +- country switching + +### Step 5 + +Replay, releases, and source maps. + +## Done Criteria + +The implementation is complete when: + +- both apps initialize PostHog correctly +- website and calculator pageviews appear in one PostHog project +- household and society-wide report flows are queryable end-to-end +- route errors and shared React boundary errors appear in PostHog +- server-side Next request errors appear in PostHog +- production stack traces are source-mapped +- replay links from exceptions work in production 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/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/hooks/useReportSubmission.ts b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts index 699962f2b..4ca71ae88 100644 --- a/app/src/pages/reportBuilder/hooks/useReportSubmission.ts +++ b/app/src/pages/reportBuilder/hooks/useReportSubmission.ts @@ -21,7 +21,8 @@ import { RootState } from '@/store'; import { Report } from '@/types/ingredients/Report'; import { Simulation } from '@/types/ingredients/Simulation'; import { SimulationStateProps } from '@/types/pathwayState'; -import { trackReportStarted } from '@/utils/analytics'; +import { trackCalculationFailed, trackReportStarted } from '@/utils/analytics'; +import { captureCalculatorException } from '@/utils/errorTracking'; import { toApiPolicyId } from '../currentLaw'; import { ReportBuilderState } from '../types'; @@ -96,7 +97,10 @@ export function useReportSubmission({ } setIsSubmitting(true); - trackReportStarted(); + trackReportStarted({ + countryId, + reportState, + }); try { const simulationIds: string[] = []; @@ -155,6 +159,14 @@ export function useReportSubmission({ if (simulationIds.length === 0) { console.error('[useReportSubmission] No simulations created'); + trackCalculationFailed({ + calcId: 'report_submission', + targetType: 'report', + countryId, + year: reportState.year, + calcType: 'societyWide', + error: new Error('No simulations were created for the report'), + }); setIsSubmitting(false); return; } @@ -189,12 +201,22 @@ export function useReportSubmission({ }, onError: (error) => { console.error('[useReportSubmission] Report creation failed:', error); + captureCalculatorException(error, { + source: 'use_report_submission_on_error', + country_id: countryId, + year: reportState.year, + }); setIsSubmitting(false); }, } ); } catch (error) { console.error('[useReportSubmission] Error running report:', error); + captureCalculatorException(error, { + source: 'use_report_submission', + country_id: countryId, + year: reportState.year, + }); setIsSubmitting(false); } }, [ diff --git a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx index 2d0b993ab..638ba3f2a 100644 --- a/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx +++ b/app/src/pages/reportBuilder/modals/PopulationBrowseModal.tsx @@ -33,8 +33,10 @@ import { RootState } from '@/store'; import { Geography } from '@/types/ingredients/Geography'; import { Household } from '@/types/ingredients/Household'; import { PopulationStateProps } from '@/types/pathwayState'; +import { trackHouseholdSaved } from '@/utils/analytics'; import { generateGeographyLabel } from '@/utils/geographyUtils'; import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; +import { captureCalculatorException } from '@/utils/errorTracking'; import { getUKConstituencies, getUKCountries, @@ -370,6 +372,16 @@ export function PopulationBrowseModal({ id: householdId, }; + trackHouseholdSaved({ + countryId, + year: reportYear, + householdId, + household: createdHousehold, + maritalStatus, + numChildren, + mode: 'report', + }); + const populationState = { geography: null, household: createdHousehold, @@ -385,12 +397,19 @@ export function PopulationBrowseModal({ onClose(); } catch (err) { console.error('Failed to create household:', err); + captureCalculatorException(err, { + source: 'population_browse_modal', + country_id: countryId, + year: reportYear, + }); } }, [ householdDraft, householdLabel, countryId, createHousehold, + maritalStatus, + numChildren, onSelect, onClose, queryClient, diff --git a/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx b/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx index 08baf0cff..358d4d817 100644 --- a/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx +++ b/app/src/pages/reportBuilder/modals/population/HouseholdCreationContent.tsx @@ -60,6 +60,7 @@ export function HouseholdCreationContent({ household={householdDraft} metadata={metadata} year={reportYear} + trackingMode="report" maritalStatus={maritalStatus} numChildren={numChildren} basicPersonFields={basicPersonFields} diff --git a/app/src/pathways/report/views/policy/PolicySubmitView.tsx b/app/src/pathways/report/views/policy/PolicySubmitView.tsx index ea4cbc493..cd925b24d 100644 --- a/app/src/pathways/report/views/policy/PolicySubmitView.tsx +++ b/app/src/pathways/report/views/policy/PolicySubmitView.tsx @@ -54,7 +54,12 @@ export default function PolicySubmitView({ ); createPolicy(serializedPolicyCreationPayload, { onSuccess: (data) => { - trackPolicyCreated(); + trackPolicyCreated({ + countryId, + policyId: data.result.policy_id, + parameterCount: policy.parameters.length, + hasLabel: Boolean(policy.label), + }); onSubmitSuccess(data.result.policy_id); }, }); diff --git a/app/src/pathways/report/views/population/HouseholdBuilderView.tsx b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx index e1affd2c6..f33d4757d 100644 --- a/app/src/pathways/report/views/population/HouseholdBuilderView.tsx +++ b/app/src/pathways/report/views/population/HouseholdBuilderView.tsx @@ -16,8 +16,10 @@ import { getBasicInputFields } from '@/libs/metadataUtils'; import { RootState } from '@/store'; import { Household } from '@/types/ingredients/Household'; import { PopulationStateProps } from '@/types/pathwayState'; +import { trackHouseholdSaved } from '@/utils/analytics'; import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; import { HouseholdValidation } from '@/utils/HouseholdValidation'; +import { captureCalculatorException } from '@/utils/errorTracking'; interface HouseholdBuilderViewProps { population: PopulationStateProps; @@ -160,9 +162,22 @@ export default function HouseholdBuilderView({ const result = await createHousehold(payload); const householdId = result.result.household_id; + trackHouseholdSaved({ + countryId, + year: reportYear, + householdId, + household, + maritalStatus, + numChildren, + mode: 'standalone', + }); onSubmitSuccess(householdId, household); } catch (err) { - // Error is handled by the mutation + captureCalculatorException(err, { + source: 'household_builder_view', + country_id: countryId, + year: reportYear, + }); } }; @@ -188,6 +203,7 @@ export default function HouseholdBuilderView({ household={household} metadata={metadata} year={reportYear} + trackingMode="standalone" maritalStatus={maritalStatus} numChildren={numChildren} basicPersonFields={basicInputFields.person || []} diff --git a/app/src/utils/analytics.ts b/app/src/utils/analytics.ts index c29a03dd8..d73f1bcbe 100644 --- a/app/src/utils/analytics.ts +++ b/app/src/utils/analytics.ts @@ -1,67 +1,355 @@ +'use client'; + /** - * GA4 event tracking utility + * Analytics utility used by shared calculator code. * - * Wraps window.gtag calls. The gtag script is loaded via the HTML files - * (website.html / calculator.html). This module provides typed helpers - * so components don't call window.gtag directly. + * In the Next.js calculator app, events go to PostHog. While the legacy Vite + * app still exists, we keep a GA fallback so existing flows do not break. */ +import type { Properties } from 'posthog-js'; +import type { Household } from '@/types/ingredients/Household'; +import type { Report } from '@/types/ingredients/Report'; +import type { Simulation } from '@/types/ingredients/Simulation'; +import { getPostHogClient } from '@/utils/posthogClient'; +import { + buildCalcConfigSnapshot, + buildHouseholdSnapshot, + buildPersistedReportSnapshot, + buildReportBuilderSnapshot, +} from './analyticsSnapshots'; +import type { + CalculatorCalcType, + CalculatorEventName, + CalculatorEventPayloadMap, +} from './analyticsSchemas'; + declare global { interface Window { gtag?: (...args: unknown[]) => void; } } -function trackEvent(eventName: string, params?: Record) { +function trackEvent(eventName: string, params?: Properties) { + const posthog = getPostHogClient(); + + if (posthog) { + posthog.capture(eventName, { + surface: import.meta.env.VITE_APP_MODE ?? 'calculator', + ...params, + } as Properties); + return; + } + if (typeof window !== 'undefined' && window.gtag) { window.gtag('event', eventName, params); } } +export function captureCalculatorEvent( + eventName: CalculatorEventName, + params: CalculatorEventPayloadMap[CalculatorEventName], +) { + trackEvent(eventName, params as Properties); +} + +function normalizeCalcType( + calcType: 'household' | 'societyWide' | 'society_wide' +): CalculatorCalcType { + return calcType === 'societyWide' ? 'society_wide' : calcType; +} + +export function trackSocietyWideBuilderOpened(params: { + countryId: string; + year: string; +}) { + captureCalculatorEvent('society_wide_builder_opened', { + country_id: params.countryId, + year: params.year, + }); +} + +export function trackHouseholdBuilderOpened(params: { + countryId: string; + year: string; + mode?: 'report' | 'standalone'; +}) { + captureCalculatorEvent('household_builder_opened', { + country_id: params.countryId, + year: params.year, + calc_type: 'household', + mode: params.mode, + }); +} + +export function trackHouseholdVariableAdded(params: { + countryId: string; + year: string; + household: Household; + variableName: string; + variableLabel?: string; + entityScope: 'person' | 'household'; + entityName?: string; + selectedVariableCount: number; +}) { + const householdSnapshot = buildHouseholdSnapshot(params.household); + + captureCalculatorEvent('household_variable_added', { + country_id: params.countryId, + year: params.year, + calc_type: 'household', + variable_name: params.variableName, + variable_label: params.variableLabel, + entity_scope: params.entityScope, + entity_name: params.entityName, + people_count: householdSnapshot?.people_count, + selected_variable_count: params.selectedVariableCount, + }); +} + +export function trackHouseholdVariableRemoved(params: { + countryId: string; + year: string; + household: Household; + variableName: string; + entityScope: 'person' | 'household'; + entityName?: string; + selectedVariableCount: number; +}) { + const householdSnapshot = buildHouseholdSnapshot(params.household); + + captureCalculatorEvent('household_variable_removed', { + country_id: params.countryId, + year: params.year, + calc_type: 'household', + variable_name: params.variableName, + entity_scope: params.entityScope, + entity_name: params.entityName, + people_count: householdSnapshot?.people_count, + selected_variable_count: params.selectedVariableCount, + }); +} + +export function trackHouseholdSaved(params: { + countryId: string; + year: string; + householdId?: string; + household: Household; + maritalStatus: 'single' | 'married'; + numChildren: number; + mode?: 'report' | 'standalone'; +}) { + const householdSnapshot = buildHouseholdSnapshot({ + ...params.household, + id: params.householdId ?? params.household.id, + }); + + if (!householdSnapshot) { + return; + } + + captureCalculatorEvent('household_saved', { + country_id: params.countryId, + year: params.year, + calc_type: 'household', + household_id: params.householdId ?? params.household.id, + marital_status: params.maritalStatus, + num_children: params.numChildren, + household_snapshot: householdSnapshot, + mode: params.mode, + }); +} + /** Fires when a calculation (household or economy) completes successfully */ export function trackSimulationCompleted(params: { calcType: 'household' | 'societyWide'; countryId: string; + year?: string; + calcId?: string; + targetType?: 'report' | 'simulation'; + reportId?: string; + durationMs?: number; }) { - trackEvent('simulation_completed', params); + captureCalculatorEvent('calculation_completed', { + calc_type: normalizeCalcType(params.calcType), + country_id: params.countryId, + year: params.year, + calc_id: params.calcId, + target_type: params.targetType, + report_id: params.reportId, + duration_ms: params.durationMs, + }); } /** Fires after 15s on an iframe tool page */ export function trackToolEngaged(params: { toolSlug: string; toolTitle: string }) { - trackEvent('tool_engaged', params); + captureCalculatorEvent('tool_engaged', { + tool_slug: params.toolSlug, + tool_title: params.toolTitle, + }); } /** Fires when user clicks the email contact link */ export function trackContactClicked() { - trackEvent('contact_clicked'); + captureCalculatorEvent('contact_clicked', {}); } /** Fires on successful newsletter subscription */ export function trackNewsletterSignup() { - trackEvent('newsletter_signup'); + captureCalculatorEvent('newsletter_signup', {}); } /** Fires when user clicks "Build Report" to start the creation flow */ -export function trackReportStarted() { - trackEvent('report_started'); +export function trackReportStarted(params: { + countryId: string; + reportState: import('@/pages/reportBuilder/types').ReportBuilderState; +}) { + captureCalculatorEvent('report_started', { + country_id: params.countryId, + year: params.reportState.year, + calc_type: buildReportBuilderSnapshot(params.reportState).calc_type, + report_config_snapshot: buildReportBuilderSnapshot(params.reportState), + }); +} + +export function trackReportCreated(params: { + countryId: string; + report: Report; + simulations?: Array; +}) { + captureCalculatorEvent('report_created', { + country_id: params.countryId, + year: params.report.year, + report_id: params.report.id, + calc_type: buildPersistedReportSnapshot(params.report, params.simulations).calc_type, + report_config_snapshot: buildPersistedReportSnapshot(params.report, params.simulations), + }); +} + +export function trackCalculationStarted(params: { + config: import('@/types/calculation').CalcStartConfig; +}) { + const calcConfigSnapshot = buildCalcConfigSnapshot(params.config); + + captureCalculatorEvent('calculation_started', { + calc_id: params.config.calcId, + target_type: params.config.targetType, + report_id: params.config.reportId, + country_id: params.config.countryId, + year: params.config.year, + calc_type: calcConfigSnapshot.calc_type, + calc_config_snapshot: calcConfigSnapshot, + }); +} + +export function trackCalculationFailed(params: { + calcId: string; + targetType: 'report' | 'simulation'; + countryId: string; + year?: string; + calcType: 'household' | 'societyWide' | 'society_wide'; + reportId?: string; + durationMs?: number; + error?: unknown; + config?: import('@/types/calculation').CalcStartConfig; +}) { + const normalizedError = params.error instanceof Error ? params.error : null; + + captureCalculatorEvent('calculation_failed', { + calc_id: params.calcId, + target_type: params.targetType, + country_id: params.countryId, + year: params.year, + report_id: params.reportId, + calc_type: normalizeCalcType(params.calcType), + duration_ms: params.durationMs, + error_name: normalizedError?.name, + error_message: + normalizedError?.message ?? + (typeof params.error === 'string' ? params.error : undefined), + calc_config_snapshot: params.config ? buildCalcConfigSnapshot(params.config) : undefined, + }); +} + +export function trackReportOutputViewed(params: { + report: Report; + calcType: 'household' | 'societyWide' | 'society_wide'; + simulations?: Array; + subpage?: string; + activeView?: string; +}) { + captureCalculatorEvent('report_output_viewed', { + country_id: params.report.countryId, + year: params.report.year, + report_id: params.report.id, + calc_type: normalizeCalcType(params.calcType), + output_subpage: params.subpage, + active_view: params.activeView, + report_config_snapshot: buildPersistedReportSnapshot(params.report, params.simulations), + }); +} + +export function trackReportOutputSubpageViewed(params: { + report: Report; + calcType: 'household' | 'societyWide' | 'society_wide'; + simulations?: Array; + subpage: string; + activeView?: string; +}) { + captureCalculatorEvent('report_output_subpage_viewed', { + country_id: params.report.countryId, + year: params.report.year, + report_id: params.report.id, + calc_type: normalizeCalcType(params.calcType), + output_subpage: params.subpage, + active_view: params.activeView, + report_config_snapshot: buildPersistedReportSnapshot(params.report, params.simulations), + }); } /** Fires when user saves a custom policy */ -export function trackPolicyCreated() { - trackEvent('policy_created'); +export function trackPolicyCreated(params?: { + countryId?: string; + policyId?: string; + parameterCount?: number; + hasLabel?: boolean; +}) { + captureCalculatorEvent('policy_created', { + country_id: params?.countryId, + policy_id: params?.policyId, + parameter_count: params?.parameterCount, + has_label: params?.hasLabel, + }); } /** Fires when user downloads CSV data from a chart */ -export function trackChartCsvDownloaded() { - trackEvent('chart_csv_downloaded'); +export function trackChartCsvDownloaded(params?: { + reportId?: string; + calcType?: 'household' | 'societyWide' | 'society_wide'; + chartName?: string; +}) { + captureCalculatorEvent('chart_csv_downloaded', { + report_id: params?.reportId, + calc_type: params?.calcType ? normalizeCalcType(params.calcType) : undefined, + chart_name: params?.chartName, + }); } /** Fires when user copies Python reproduction code */ -export function trackPythonCodeCopied() { - trackEvent('python_code_copied'); +export function trackPythonCodeCopied(params?: { + reportId?: string; + calcType?: 'household' | 'societyWide' | 'society_wide'; +}) { + captureCalculatorEvent('python_code_copied', { + report_id: params?.reportId, + calc_type: params?.calcType ? normalizeCalcType(params.calcType) : undefined, + }); } /** Fires when a landing page is viewed */ export function trackLandingPageViewed(params: { page: string; countryId: string }) { - trackEvent('landing_page_viewed', params); + captureCalculatorEvent('landing_page_viewed', { + page: params.page, + country_id: params.countryId, + }); } diff --git a/app/src/utils/analyticsSchemas.ts b/app/src/utils/analyticsSchemas.ts new file mode 100644 index 000000000..d24c1be14 --- /dev/null +++ b/app/src/utils/analyticsSchemas.ts @@ -0,0 +1,131 @@ +export type CalculatorCalcType = 'household' | 'society_wide'; + +export interface HouseholdSnapshot { + household_id?: string; + people_count: number; + people: string[]; + group_counts: Record; + variable_names: string[]; + household_data: Record; +} + +export interface SimulationSnapshot { + simulation_id?: string; + label: string | null; + policy_id?: string; + population_id?: string; + population_type?: 'household' | 'geography'; + population_label?: string | null; +} + +export interface ReportConfigSnapshot { + report_id?: string; + label?: string | null; + year?: string; + simulation_count: number; + calc_type?: CalculatorCalcType; + simulation_ids?: string[]; + simulations: SimulationSnapshot[]; +} + +export interface CalcConfigSnapshot { + calc_id: string; + target_type: 'report' | 'simulation'; + country_id: string; + year: string; + report_id?: string; + calc_type: CalculatorCalcType; + simulations: SimulationSnapshot[]; + households: HouseholdSnapshot[]; + geographies: Array<{ + geography_id?: string; + scope?: string; + }>; +} + +interface CalculatorBaseEventProperties { + country_id?: string; + year?: string; + report_id?: string; + simulation_id?: string; + calc_id?: string; + calc_type?: CalculatorCalcType; + target_type?: 'report' | 'simulation'; + output_subpage?: string; + active_view?: string; + source?: string; +} + +export interface CalculatorEventPayloadMap { + calculation_completed: CalculatorBaseEventProperties & { + duration_ms?: number; + calc_config_snapshot?: CalcConfigSnapshot; + }; + calculation_failed: CalculatorBaseEventProperties & { + duration_ms?: number; + error_message?: string; + error_name?: string; + calc_config_snapshot?: CalcConfigSnapshot; + }; + calculation_started: CalculatorBaseEventProperties & { + calc_config_snapshot: CalcConfigSnapshot; + }; + chart_csv_downloaded: CalculatorBaseEventProperties & { + chart_name?: string; + }; + contact_clicked: Record; + household_builder_opened: CalculatorBaseEventProperties & { + mode?: 'report' | 'standalone'; + }; + household_saved: CalculatorBaseEventProperties & { + household_id?: string; + marital_status: 'single' | 'married'; + num_children: number; + household_snapshot: HouseholdSnapshot; + mode?: 'report' | 'standalone'; + }; + household_variable_added: CalculatorBaseEventProperties & { + variable_name: string; + variable_label?: string; + entity_scope: 'person' | 'household'; + entity_name?: string; + people_count?: number; + selected_variable_count?: number; + }; + household_variable_removed: CalculatorBaseEventProperties & { + variable_name: string; + entity_scope: 'person' | 'household'; + entity_name?: string; + people_count?: number; + selected_variable_count?: number; + }; + landing_page_viewed: CalculatorBaseEventProperties & { + page: string; + }; + newsletter_signup: Record; + policy_created: CalculatorBaseEventProperties & { + parameter_count?: number; + has_label?: boolean; + policy_id?: string; + }; + python_code_copied: CalculatorBaseEventProperties; + report_created: CalculatorBaseEventProperties & { + report_config_snapshot: ReportConfigSnapshot; + }; + report_output_subpage_viewed: CalculatorBaseEventProperties & { + report_config_snapshot?: ReportConfigSnapshot; + }; + report_output_viewed: CalculatorBaseEventProperties & { + report_config_snapshot?: ReportConfigSnapshot; + }; + report_started: CalculatorBaseEventProperties & { + report_config_snapshot: ReportConfigSnapshot; + }; + society_wide_builder_opened: CalculatorBaseEventProperties; + tool_engaged: { + tool_slug: string; + tool_title: string; + }; +} + +export type CalculatorEventName = keyof CalculatorEventPayloadMap; diff --git a/app/src/utils/analyticsSnapshots.ts b/app/src/utils/analyticsSnapshots.ts new file mode 100644 index 000000000..5bafb01f8 --- /dev/null +++ b/app/src/utils/analyticsSnapshots.ts @@ -0,0 +1,225 @@ +import type { ReportBuilderState } from '@/pages/reportBuilder/types'; +import type { CalcStartConfig } from '@/types/calculation'; +import type { Household, HouseholdData } from '@/types/ingredients/Household'; +import type { Report } from '@/types/ingredients/Report'; +import type { Simulation } from '@/types/ingredients/Simulation'; +import type { PopulationStateProps, SimulationStateProps } from '@/types/pathwayState'; +import type { + CalcConfigSnapshot, + CalculatorCalcType, + HouseholdSnapshot, + ReportConfigSnapshot, + SimulationSnapshot, +} from './analyticsSchemas'; + +type SimulationLike = Simulation | SimulationStateProps | null | undefined; + +function normalizeCalcType( + calcType: 'household' | 'societyWide' | 'society_wide' | null | undefined +): CalculatorCalcType | undefined { + if (!calcType) { + return undefined; + } + + return calcType === 'societyWide' ? 'society_wide' : calcType; +} + +function isSimulationState(simulation: SimulationLike): simulation is SimulationStateProps { + return Boolean(simulation && 'policy' in simulation && 'population' in simulation); +} + +function getPopulationType( + population: PopulationStateProps | undefined +): 'household' | 'geography' | undefined { + if (!population) { + return undefined; + } + + if (population.household?.id) { + return 'household'; + } + + if (population.geography?.geographyId || population.geography?.id) { + return 'geography'; + } + + return undefined; +} + +function getPopulationLabel(population: PopulationStateProps | undefined): string | null | undefined { + return population?.label ?? population?.household?.id ?? population?.geography?.geographyId ?? null; +} + +function getPopulationId(population: PopulationStateProps | undefined): string | undefined { + return population?.household?.id ?? population?.geography?.geographyId ?? population?.geography?.id; +} + +function getVariableNames(householdData: HouseholdData): string[] { + const variableNames = new Set(); + + Object.entries(householdData).forEach(([entityName, entities]) => { + Object.values(entities).forEach((entity) => { + if (!entity || typeof entity !== 'object') { + return; + } + + Object.keys(entity).forEach((key) => { + if (entityName !== 'people' && key === 'members') { + return; + } + variableNames.add(key); + }); + }); + }); + + return Array.from(variableNames).sort(); +} + +export function buildHouseholdSnapshot( + household: Household | null | undefined +): HouseholdSnapshot | null { + if (!household) { + return null; + } + + const householdData = household.householdData ?? { people: {} }; + const people = Object.keys(householdData.people ?? {}).sort(); + const groupCounts = Object.fromEntries( + Object.entries(householdData) + .filter(([entityName]) => entityName !== 'people') + .map(([entityName, entities]) => [entityName, Object.keys(entities ?? {}).length]) + ); + + return { + household_id: household.id, + people_count: people.length, + people, + group_counts: groupCounts, + variable_names: getVariableNames(householdData), + household_data: householdData as unknown as Record, + }; +} + +export function buildSimulationSnapshot( + simulation: SimulationLike +): SimulationSnapshot | null { + if (!simulation) { + return null; + } + + if (isSimulationState(simulation)) { + return { + simulation_id: simulation.id, + label: simulation.label, + policy_id: simulation.policy.id, + population_id: getPopulationId(simulation.population), + population_type: getPopulationType(simulation.population), + population_label: getPopulationLabel(simulation.population), + }; + } + + return { + simulation_id: simulation.id, + label: simulation.label, + policy_id: simulation.policyId, + population_id: simulation.populationId, + population_type: simulation.populationType, + population_label: null, + }; +} + +export function buildReportBuilderSnapshot( + reportState: ReportBuilderState +): ReportConfigSnapshot { + const simulations = reportState.simulations + .map((simulation) => buildSimulationSnapshot(simulation)) + .filter((simulation): simulation is SimulationSnapshot => simulation !== null); + + const hasGeographySimulation = simulations.some( + (simulation) => simulation.population_type === 'geography' + ); + + return { + label: reportState.label, + year: reportState.year, + simulation_count: simulations.length, + calc_type: hasGeographySimulation ? 'society_wide' : 'household', + simulations, + }; +} + +export function buildPersistedReportSnapshot( + report: Report, + simulations: Array = [] +): ReportConfigSnapshot { + const simulationSnapshots = simulations + .map((simulation) => buildSimulationSnapshot(simulation)) + .filter((simulation): simulation is SimulationSnapshot => simulation !== null); + + const calcType = + normalizeCalcType( + simulationSnapshots.some((simulation) => simulation.population_type === 'geography') + ? 'society_wide' + : report.outputType === 'economy' + ? 'society_wide' + : report.outputType === 'household' + ? 'household' + : null + ) ?? undefined; + + return { + report_id: report.id, + label: report.label, + year: report.year, + simulation_count: report.simulationIds.length, + simulation_ids: report.simulationIds, + calc_type: calcType, + simulations: simulationSnapshots, + }; +} + +export function buildCalcConfigSnapshot(config: CalcStartConfig): CalcConfigSnapshot { + const simulations = [config.simulations.simulation1, config.simulations.simulation2] + .map((simulation) => buildSimulationSnapshot(simulation)) + .filter((simulation): simulation is SimulationSnapshot => simulation !== null); + + const households = [config.populations.household1, config.populations.household2] + .map((household) => buildHouseholdSnapshot(household)) + .filter((household): household is HouseholdSnapshot => household !== null); + + const geographies = [config.populations.geography1, config.populations.geography2] + .filter((geography): geography is NonNullable => Boolean(geography)) + .map((geography) => ({ + geography_id: geography.geographyId, + scope: geography.scope, + })); + + const calcType = normalizeCalcType( + config.simulations.simulation1.populationType === 'household' ? 'household' : 'society_wide' + ) as CalculatorCalcType; + + return { + calc_id: config.calcId, + target_type: config.targetType, + country_id: config.countryId, + year: config.year, + report_id: config.reportId, + calc_type: calcType, + simulations, + households, + geographies, + }; +} + +export function getHouseholdMaritalStatus( + household: Household | null | undefined +): 'single' | 'married' { + const people = Object.keys(household?.householdData.people ?? {}); + return people.includes('your partner') ? 'married' : 'single'; +} + +export function getHouseholdChildCount(household: Household | null | undefined): number { + return Object.keys(household?.householdData.people ?? {}).filter((personKey) => + personKey.includes('dependent') + ).length; +} diff --git a/app/src/utils/errorTracking.ts b/app/src/utils/errorTracking.ts new file mode 100644 index 000000000..bb3e6c8cd --- /dev/null +++ b/app/src/utils/errorTracking.ts @@ -0,0 +1,59 @@ +'use client'; + +import type { ErrorInfo } from 'react'; +import { getPostHogClient } from '@/utils/posthogClient'; + +type ErrorContext = Record; + +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(typeof error === 'string' ? error : 'Unknown calculator error'); +} + +export function captureCalculatorException( + error: unknown, + context: ErrorContext = {}, +) { + const posthog = getPostHogClient(); + + if (!posthog) { + return; + } + + posthog.captureException(normalizeError(error), { + surface: 'calculator', + ...context, + }); +} + +export function captureCalculationException( + error: unknown, + context: ErrorContext = {}, +) { + captureCalculatorException(error, { + source: 'calculation', + ...context, + }); +} + +export function captureRouteException(error: unknown, context: ErrorContext = {}) { + captureCalculatorException(error, { + source: 'route', + ...context, + }); +} + +export function captureReactBoundaryException( + error: unknown, + errorInfo?: ErrorInfo, + context: ErrorContext = {}, +) { + captureCalculatorException(error, { + source: 'react_error_boundary', + component_stack: errorInfo?.componentStack, + ...context, + }); +} diff --git a/app/src/utils/posthogClient.ts b/app/src/utils/posthogClient.ts new file mode 100644 index 000000000..0f44782bd --- /dev/null +++ b/app/src/utils/posthogClient.ts @@ -0,0 +1,18 @@ +'use client'; + +import posthog from 'posthog-js'; + +type PolicyEngineWindow = Window & { + __policyenginePostHogInitialized?: boolean; +}; + +export function isPostHogAvailable() { + return ( + typeof window !== 'undefined' && + Boolean((window as PolicyEngineWindow).__policyenginePostHogInitialized) + ); +} + +export function getPostHogClient() { + return isPostHogAvailable() ? posthog : null; +} diff --git a/bun.lock b/bun.lock index 7a90edf6b..b87c9ad82 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "policyengine-monorepo", @@ -36,6 +37,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", @@ -119,6 +121,8 @@ "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "^15.3.3", + "posthog-js": "^1.292.0", + "posthog-node": "^5.10.0", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -168,12 +172,14 @@ "name": "@policyengine/website", "version": "0.1.0", "dependencies": { - "@policyengine/design-system": "workspace:*", + "@policyengine/design-system": "*", "@tabler/icons-react": "^3.31.0", "framer-motion": "^12.38.0", "fuse.js": "^7.1.0", "jsonp": "^0.2.1", "next": "^15.3.3", + "posthog-js": "^1.292.0", + "posthog-node": "^5.10.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -503,6 +509,28 @@ "@normy/react-query": ["@normy/react-query@0.20.0", "", { "dependencies": { "@normy/core": "0.13.0", "@normy/query-core": "0.20.0" }, "peerDependencies": { "@tanstack/react-query": ">=5.4.3" } }, "sha512-+ghvgw41eggr30pmKZtjI4L3HBsyC9RCd4m0BNO5fMrJzbmO7sDVZ6Ga2mYe+QykciBUDj0ukE+MRG2LW/lEOQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], + + "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@plotly/d3": ["@plotly/d3@3.8.2", "", {}, "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA=="], @@ -523,6 +551,30 @@ "@policyengine/website": ["@policyengine/website@workspace:website"], + "@posthog/core": ["@posthog/core@1.24.5", "", {}, "sha512-umXx3kMjM+cTUTLDsdPFFU7aJa3uiH19EEoWKbE5QVME8WgVg7q1peMhK7y7n7xRmYJlA70eOrHQfWlzBQqeFQ=="], + + "@posthog/types": ["@posthog/types@1.364.5", "", {}, "sha512-lekH0rJ5NVuX0vj9XlSrGshHaNHOBMBcn1dv4dVpI8HVnLrqmSvMidRfYPYL78d2tsCEjs7qWD7yri77GQvzSg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -913,6 +965,8 @@ "@types/supercluster": ["@types/supercluster@7.1.3", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], @@ -1165,6 +1219,8 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], @@ -1301,6 +1357,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], + "draw-svg-path": ["draw-svg-path@1.0.0", "", { "dependencies": { "abs-svg-path": "~0.1.1", "normalize-svg-path": "~0.1.0" } }, "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg=="], "dtype": ["dtype@2.0.0", "", {}, "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg=="], @@ -1435,6 +1493,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], @@ -1825,6 +1885,8 @@ "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -2125,8 +2187,14 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "posthog-js": ["posthog-js@1.364.5", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.24.5", "@posthog/types": "1.364.5", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-A3Tn6czaqygxUss5P7dseMZ+zoUrIdKSQAvAnHWvMqjnQr8hvUEKlkrdF/LDzA79mXoKZKoIGtujv3V/M3PphA=="], + + "posthog-node": ["posthog-node@5.28.10", "", { "dependencies": { "@posthog/core": "1.24.5" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-FxZFnX/3a7Edc2VIpiyne3BiqmzZRQv5nAItaO1urZDhC5fPWZeeo04aeKl0LWKBUp7M7x6a2sQYHpqVztT4GQ=="], + "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], + "preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], @@ -2147,12 +2215,16 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "qified": ["qified@0.6.0", "", { "dependencies": { "hookified": "^1.14.0" } }, "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="], @@ -2597,6 +2669,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-vitals": ["web-vitals@5.2.0", "", {}, "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA=="], + "webgl-context": ["webgl-context@2.2.0", "", { "dependencies": { "get-canvas-context": "^1.0.1" } }, "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], @@ -2685,6 +2759,16 @@ "@maplibre/maplibre-gl-style-spec/tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="], + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + "@plotly/d3-sankey/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], "@plotly/d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], @@ -2851,12 +2935,8 @@ "plotly.js/d3-geo": ["d3-geo@1.12.1", "", { "dependencies": { "d3-array": "1" } }, "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg=="], - "policyengine-app-v2/framer-motion": ["framer-motion@12.29.0", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="], - "policyengine-app-v2/react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="], - "policyengine-app-v2/tailwindcss": ["tailwindcss@4.2.0", "", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="], - "policyengine-app-v2/vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -3101,10 +3181,6 @@ "plotly.js/d3-geo/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], - "policyengine-app-v2/framer-motion/motion-dom": ["motion-dom@12.29.0", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="], - - "policyengine-app-v2/framer-motion/motion-utils": ["motion-utils@12.27.2", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="], - "regl-scatter2d/color-rgba/color-parse": ["color-parse@1.4.3", "", { "dependencies": { "color-name": "^1.0.0" } }, "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A=="], "stream-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/calculator-app/.env.example b/calculator-app/.env.example new file mode 100644 index 000000000..2be5490d1 --- /dev/null +++ b/calculator-app/.env.example @@ -0,0 +1,8 @@ +# PostHog client configuration +NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_your_project_token_here +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com +NEXT_PUBLIC_APP_RELEASE=local + +# Optional server-side PostHog configuration +POSTHOG_PROJECT_TOKEN=phc_your_project_token_here +POSTHOG_HOST=https://us.i.posthog.com diff --git a/calculator-app/instrumentation-client.ts b/calculator-app/instrumentation-client.ts new file mode 100644 index 000000000..942270418 --- /dev/null +++ b/calculator-app/instrumentation-client.ts @@ -0,0 +1,32 @@ +import posthog from "posthog-js"; + +type PolicyEngineWindow = Window & { + __policyenginePostHogInitialized?: boolean; +}; + +const posthogToken = + process.env.NEXT_PUBLIC_POSTHOG_TOKEN ?? + process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; +const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; +const release = process.env.NEXT_PUBLIC_APP_RELEASE; + +if (posthogToken && posthogHost) { + posthog.init(posthogToken, { + api_host: posthogHost, + defaults: "2026-01-30", + loaded: (client) => { + client.register({ + release, + surface: "calculator", + }); + + if (process.env.NODE_ENV === "development") { + client.debug(); + } + }, + }); + + if (typeof window !== "undefined") { + (window as PolicyEngineWindow).__policyenginePostHogInitialized = true; + } +} diff --git a/calculator-app/instrumentation.ts b/calculator-app/instrumentation.ts new file mode 100644 index 000000000..2d86872cc --- /dev/null +++ b/calculator-app/instrumentation.ts @@ -0,0 +1,69 @@ +type RequestLike = { + headers?: Headers | { cookie?: string | string[] | undefined }; +}; + +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(typeof error === "string" ? error : "Unknown request error"); +} + +function getCookieHeader(request: RequestLike): string | undefined { + const headers = request.headers; + + if (!headers) { + return undefined; + } + + if (headers instanceof Headers) { + return headers.get("cookie") ?? undefined; + } + + const cookie = headers.cookie; + return Array.isArray(cookie) ? cookie.join("; ") : cookie; +} + +function getDistinctIdFromCookie(cookieHeader?: string): string | undefined { + if (!cookieHeader) { + return undefined; + } + + const postHogCookieMatch = cookieHeader.match(/ph_phc_.*?_posthog=([^;]+)/); + + if (!postHogCookieMatch?.[1]) { + return undefined; + } + + try { + const decodedCookie = decodeURIComponent(postHogCookieMatch[1]); + const postHogData = JSON.parse(decodedCookie) as { distinct_id?: string }; + return postHogData.distinct_id; + } catch (error) { + console.error("[Calculator] Failed to parse PostHog cookie:", error); + return undefined; + } +} + +export function register() {} + +export async function onRequestError( + error: unknown, + request: RequestLike, + _context: unknown, +) { + if (process.env.NEXT_RUNTIME !== "nodejs") { + return; + } + + const { getPostHogServer } = await import("./src/lib/posthog-server"); + const posthog = getPostHogServer(); + + if (!posthog) { + return; + } + + const distinctId = getDistinctIdFromCookie(getCookieHeader(request)); + await posthog.captureException(normalizeError(error), distinctId); +} diff --git a/calculator-app/package.json b/calculator-app/package.json index 6c6568337..52fd2a21f 100644 --- a/calculator-app/package.json +++ b/calculator-app/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.1.0", "scripts": { - "dev": "next dev --port 3001", + "dev": "sh -c 'next dev --port ${PORT:-3001}'", "build": "next build", "start": "next start", "typecheck": "tsc --noEmit" @@ -27,6 +27,8 @@ "fuse.js": "^7.0.0", "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", + "posthog-js": "^1.292.0", + "posthog-node": "^5.10.0", "radix-ui": "^1.4.3", "react-plotly.js": "^2.6.0", "react-redux": "^9.2.0", diff --git a/calculator-app/src/app/[countryId]/error.tsx b/calculator-app/src/app/[countryId]/error.tsx index f91446d05..4227740f1 100644 --- a/calculator-app/src/app/[countryId]/error.tsx +++ b/calculator-app/src/app/[countryId]/error.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { colors } from "@/designTokens"; +import { captureRouteException } from "@/utils/errorTracking"; export default function CountryError({ error, @@ -11,6 +12,10 @@ export default function CountryError({ reset: () => void; }) { useEffect(() => { + captureRouteException(error, { + boundary: "country_route", + digest: error.digest, + }); console.error("[Calculator] Route error:", error); }, [error]); diff --git a/calculator-app/src/app/error.tsx b/calculator-app/src/app/error.tsx index 97516b976..2a7e4280e 100644 --- a/calculator-app/src/app/error.tsx +++ b/calculator-app/src/app/error.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { colors } from "@/designTokens"; +import { captureRouteException } from "@/utils/errorTracking"; export default function GlobalError({ error, @@ -11,6 +12,10 @@ export default function GlobalError({ reset: () => void; }) { useEffect(() => { + captureRouteException(error, { + boundary: "root", + digest: error.digest, + }); console.error("[Calculator] Global error:", error); }, [error]); diff --git a/calculator-app/src/app/global-error.tsx b/calculator-app/src/app/global-error.tsx new file mode 100644 index 000000000..83f8336f3 --- /dev/null +++ b/calculator-app/src/app/global-error.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect } from "react"; +import { colors } from "@/designTokens"; +import { captureRouteException } from "@/utils/errorTracking"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + captureRouteException(error, { + boundary: "global", + digest: error.digest, + }); + console.error("[Calculator] Global layout error:", error); + }, [error]); + + return ( + + +
+

+ Error +

+

+ Something went wrong. Please try again. +

+ +
+ + + ); +} diff --git a/calculator-app/src/app/layout.tsx b/calculator-app/src/app/layout.tsx index 1356ba159..e18ec355e 100644 --- a/calculator-app/src/app/layout.tsx +++ b/calculator-app/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import { PostHogProvider } from "./providers"; export const metadata: Metadata = { title: "PolicyEngine Calculator", @@ -15,7 +16,9 @@ export default function RootLayout({ return ( -
{children}
+ +
{children}
+
); diff --git a/calculator-app/src/app/providers.tsx b/calculator-app/src/app/providers.tsx new file mode 100644 index 000000000..ee875470b --- /dev/null +++ b/calculator-app/src/app/providers.tsx @@ -0,0 +1,12 @@ +"use client"; + +import posthog from "posthog-js"; +import { PostHogProvider as PHProvider } from "posthog-js/react"; + +export function PostHogProvider({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/calculator-app/src/lib/posthog-server.ts b/calculator-app/src/lib/posthog-server.ts new file mode 100644 index 000000000..aa7a053da --- /dev/null +++ b/calculator-app/src/lib/posthog-server.ts @@ -0,0 +1,34 @@ +import { PostHog } from "posthog-node"; + +let posthogInstance: PostHog | null = null; + +function getPostHogToken() { + return ( + process.env.POSTHOG_PROJECT_TOKEN ?? + process.env.NEXT_PUBLIC_POSTHOG_TOKEN ?? + process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN + ); +} + +function getPostHogHost() { + return process.env.POSTHOG_HOST ?? process.env.NEXT_PUBLIC_POSTHOG_HOST; +} + +export function getPostHogServer() { + const token = getPostHogToken(); + const host = getPostHogHost(); + + if (!token || !host) { + return null; + } + + if (!posthogInstance) { + posthogInstance = new PostHog(token, { + host, + flushAt: 1, + flushInterval: 0, + }); + } + + return posthogInstance; +} diff --git a/package.json b/package.json index b167049ab..75f95adf1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "calculator-app" ], "scripts": { - "dev": "bun run design-system:build && bun run --filter=policyengine-app-v2 dev", + "dev": "bun run design-system:build && node scripts/dev-server-next.mjs", + "dev:legacy": "bun run design-system:build && bun run --filter=policyengine-app-v2 dev", "build": "turbo run build", "test": "turbo run test", "lint": "turbo run lint", diff --git a/scripts/dev-server-next.mjs b/scripts/dev-server-next.mjs new file mode 100644 index 000000000..eda1e0351 --- /dev/null +++ b/scripts/dev-server-next.mjs @@ -0,0 +1,147 @@ +import { spawn } from "child_process"; +import net from "net"; + +function tryConnect(port, host) { + return new Promise((resolve) => { + const socket = new net.Socket(); + const timeout = setTimeout(() => { + socket.destroy(); + resolve(false); + }, 1000); + + socket.once("connect", () => { + clearTimeout(timeout); + socket.destroy(); + resolve(true); + }); + + socket.once("error", () => { + clearTimeout(timeout); + socket.destroy(); + resolve(false); + }); + + socket.connect(port, host); + }); +} + +function tryListen(port, host) { + return new Promise((resolve) => { + const server = net.createServer(); + const timeout = setTimeout(() => { + server.close(() => resolve(false)); + }, 1000); + + server.once("error", () => { + clearTimeout(timeout); + resolve(false); + }); + + server.once("listening", () => { + clearTimeout(timeout); + server.close(() => resolve(true)); + }); + + server.listen({ + exclusive: true, + host, + port, + }); + }); +} + +async function isPortAvailable(port) { + const [ipv4InUse, ipv6InUse] = await Promise.all([ + tryConnect(port, "127.0.0.1"), + tryConnect(port, "::1"), + ]); + + if (ipv4InUse || ipv6InUse) { + return false; + } + + return tryListen(port, "127.0.0.1"); +} + +async function findAvailablePort(start) { + let port = start; + + while (port < start + 100) { + if (await isPortAvailable(port)) { + return port; + } + + port += 1; + } + + throw new Error(`No available port found in range ${start}-${start + 99}`); +} + +function parsePort(value, fallback) { + const parsed = Number(value ?? fallback); + + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid port value: ${value}`); + } + + return parsed; +} + +async function main() { + const requestedWebsitePort = parsePort(process.env.WEBSITE_PORT, 3000); + const requestedCalculatorPort = parsePort( + process.env.CALCULATOR_PORT, + requestedWebsitePort + 1, + ); + + const websitePort = process.env.WEBSITE_PORT + ? requestedWebsitePort + : await findAvailablePort(requestedWebsitePort); + const calculatorPort = process.env.CALCULATOR_PORT + ? requestedCalculatorPort + : await findAvailablePort(Math.max(requestedCalculatorPort, websitePort + 1)); + + if (websitePort === calculatorPort) { + throw new Error("Website and calculator ports must be different"); + } + + const websiteUrl = `http://localhost:${websitePort}`; + const calculatorUrl = `http://localhost:${calculatorPort}`; + + console.log(`\n Dev servers: Website :${websitePort}, Calculator :${calculatorPort}\n`); + + const env = { + ...process.env, + WEBSITE_PORT: String(websitePort), + CALCULATOR_PORT: String(calculatorPort), + }; + + const command = [ + "npx concurrently", + "--names website,calculator", + "--prefix-colors blue,green", + `"cd website && PORT=${websitePort} NEXT_PUBLIC_CALCULATOR_URL=${calculatorUrl} bun --bun run dev"`, + `"cd calculator-app && PORT=${calculatorPort} NEXT_PUBLIC_WEBSITE_URL=${websiteUrl} NEXT_PUBLIC_CALCULATOR_URL=${calculatorUrl} bun --bun run dev"`, + ].join(" "); + + const child = spawn(command, [], { + cwd: process.cwd(), + env, + shell: true, + stdio: "inherit", + }); + + child.on("error", (error) => { + console.error("Failed to start Next.js dev servers:", error); + process.exit(1); + }); + + child.on("close", (code) => { + process.exit(code ?? 0); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/website/.env.example b/website/.env.example index 56c985619..2be5490d1 100644 --- a/website/.env.example +++ b/website/.env.example @@ -1,4 +1,8 @@ -# Calculator app URL — used by "Enter PolicyEngine" CTA and other cross-app links. -# In production, this defaults to https://app.policyengine.org -# For local dev, set to the calculator's local URL: -NEXT_PUBLIC_CALCULATOR_URL=http://localhost:3001 +# PostHog client configuration +NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_your_project_token_here +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com +NEXT_PUBLIC_APP_RELEASE=local + +# Optional server-side PostHog configuration +POSTHOG_PROJECT_TOKEN=phc_your_project_token_here +POSTHOG_HOST=https://us.i.posthog.com diff --git a/website/instrumentation-client.ts b/website/instrumentation-client.ts new file mode 100644 index 000000000..6f25363a7 --- /dev/null +++ b/website/instrumentation-client.ts @@ -0,0 +1,32 @@ +import posthog from "posthog-js"; + +type PolicyEngineWindow = Window & { + __policyenginePostHogInitialized?: boolean; +}; + +const posthogToken = + process.env.NEXT_PUBLIC_POSTHOG_TOKEN ?? + process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; +const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; +const release = process.env.NEXT_PUBLIC_APP_RELEASE; + +if (posthogToken && posthogHost) { + posthog.init(posthogToken, { + api_host: posthogHost, + defaults: "2026-01-30", + loaded: (client) => { + client.register({ + release, + surface: "website", + }); + + if (process.env.NODE_ENV === "development") { + client.debug(); + } + }, + }); + + if (typeof window !== "undefined") { + (window as PolicyEngineWindow).__policyenginePostHogInitialized = true; + } +} diff --git a/website/instrumentation.ts b/website/instrumentation.ts new file mode 100644 index 000000000..f14c89cc2 --- /dev/null +++ b/website/instrumentation.ts @@ -0,0 +1,69 @@ +type RequestLike = { + headers?: Headers | { cookie?: string | string[] | undefined }; +}; + +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(typeof error === "string" ? error : "Unknown request error"); +} + +function getCookieHeader(request: RequestLike): string | undefined { + const headers = request.headers; + + if (!headers) { + return undefined; + } + + if (headers instanceof Headers) { + return headers.get("cookie") ?? undefined; + } + + const cookie = headers.cookie; + return Array.isArray(cookie) ? cookie.join("; ") : cookie; +} + +function getDistinctIdFromCookie(cookieHeader?: string): string | undefined { + if (!cookieHeader) { + return undefined; + } + + const postHogCookieMatch = cookieHeader.match(/ph_phc_.*?_posthog=([^;]+)/); + + if (!postHogCookieMatch?.[1]) { + return undefined; + } + + try { + const decodedCookie = decodeURIComponent(postHogCookieMatch[1]); + const postHogData = JSON.parse(decodedCookie) as { distinct_id?: string }; + return postHogData.distinct_id; + } catch (error) { + console.error("[Website] Failed to parse PostHog cookie:", error); + return undefined; + } +} + +export function register() {} + +export async function onRequestError( + error: unknown, + request: RequestLike, + _context: unknown, +) { + if (process.env.NEXT_RUNTIME !== "nodejs") { + return; + } + + const { getPostHogServer } = await import("./src/lib/posthog-server"); + const posthog = getPostHogServer(); + + if (!posthog) { + return; + } + + const distinctId = getDistinctIdFromCookie(getCookieHeader(request)); + await posthog.captureException(normalizeError(error), distinctId); +} diff --git a/website/package.json b/website/package.json index 43a4304d8..52474cdc1 100644 --- a/website/package.json +++ b/website/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.1.0", "scripts": { - "dev": "next dev --turbopack", + "dev": "sh -c 'next dev --turbopack --port ${PORT:-3000}'", "dev:full": "node scripts/dev-server.mjs", "build": "bash scripts/build.sh", "build:next": "next build", @@ -20,6 +20,8 @@ "jsonp": "^0.2.1", "framer-motion": "^12.38.0", "next": "^15.3.3", + "posthog-js": "^1.292.0", + "posthog-node": "^5.10.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/website/src/app/[countryId]/research/ResearchClient.tsx b/website/src/app/[countryId]/research/ResearchClient.tsx index ac7948349..ba58c92d2 100644 --- a/website/src/app/[countryId]/research/ResearchClient.tsx +++ b/website/src/app/[countryId]/research/ResearchClient.tsx @@ -25,6 +25,7 @@ import { type ResearchItem, } from "@/data/posts/postTransformers"; import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; +import { trackResearchFiltersChanged } from "@/lib/posthog-events"; /* ─── helpers ─── */ @@ -417,6 +418,7 @@ export default function ResearchClient({ countryId }: { countryId: string }) { ); const [expandedSection, setExpandedSection] = useState(null); const [usStatesExpanded, setUsStatesExpanded] = useState(false); + const hasTrackedFiltersRef = useRef(false); // Sync URL params useEffect(() => { @@ -511,6 +513,31 @@ export default function ResearchClient({ countryId }: { countryId: string }) { reset, ]); + useEffect(() => { + if (!hasTrackedFiltersRef.current) { + hasTrackedFiltersRef.current = true; + return; + } + + trackResearchFiltersChanged({ + country_id: countryId, + search_query: searchQuery, + selected_types: selectedTypes, + selected_topics: selectedTopics, + selected_locations: selectedLocations, + selected_authors: selectedAuthors, + result_count: filteredItems.length, + }); + }, [ + countryId, + filteredItems.length, + searchQuery, + selectedAuthors, + selectedLocations, + selectedTopics, + selectedTypes, + ]); + const visibleItems = useMemo( () => filteredItems.slice(0, visibleCount), [filteredItems, visibleCount], diff --git a/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx b/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx index 76590e3a1..c62d159d0 100644 --- a/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx +++ b/website/src/app/[countryId]/research/[slug]/ArticleClient.tsx @@ -8,7 +8,7 @@ * heading section, author bios, and share links. */ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import Link from "next/link"; import type { AuthorsCollection, BlogPost, Notebook } from "@/types/blog"; import { MarkdownFormatter } from "@/components/blog/MarkdownFormatter"; @@ -28,6 +28,7 @@ import { spacing, typography, } from "@policyengine/design-system/tokens"; +import { trackResearchArticleViewed } from "@/lib/posthog-events"; const authors = authorsData as AuthorsCollection; @@ -85,6 +86,18 @@ export default function ArticleClient({ : `/assets/posts/${post.image}` : ""; + useEffect(() => { + trackResearchArticleViewed({ + country_id: countryId, + slug: post.slug, + title: post.title, + author_count: post.authors.length, + topic_tags: post.tags.filter((tag) => topicLabels[tag]), + location_tags: post.tags.filter((tag) => locationLabels[tag]), + is_notebook: isNotebook, + }); + }, [countryId, isNotebook, post.authors.length, post.slug, post.tags, post.title]); + return ( <> {/* Header section */} diff --git a/website/src/app/error.tsx b/website/src/app/error.tsx new file mode 100644 index 000000000..4d6f1d647 --- /dev/null +++ b/website/src/app/error.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect } from "react"; +import { captureWebsiteException } from "@/lib/posthog-events"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + captureWebsiteException(error, { + boundary: "route", + digest: error.digest, + }); + console.error("[Website] Route error:", error); + }, [error]); + + return ( +
+

Something went wrong

+

+ We hit an unexpected problem loading this page. +

+ +
+ ); +} diff --git a/website/src/app/global-error.tsx b/website/src/app/global-error.tsx new file mode 100644 index 000000000..22f448acc --- /dev/null +++ b/website/src/app/global-error.tsx @@ -0,0 +1,37 @@ +"use client"; + +import NextError from "next/error"; +import { useEffect } from "react"; +import { captureWebsiteException } from "@/lib/posthog-events"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + captureWebsiteException(error, { + boundary: "global", + digest: error.digest, + }); + console.error("[Website] Global error:", error); + }, [error]); + + return ( + + +
+ + +
+ + + ); +} diff --git a/website/src/app/layout.tsx b/website/src/app/layout.tsx index bb4cc7bde..588a51c06 100644 --- a/website/src/app/layout.tsx +++ b/website/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import { PostHogProvider } from "./providers"; export const metadata: Metadata = { metadataBase: new URL("https://policyengine.org"), @@ -31,7 +32,7 @@ export default function RootLayout({ margin: 0, }} > - {children} + {children} ); diff --git a/website/src/app/providers.tsx b/website/src/app/providers.tsx new file mode 100644 index 000000000..ee875470b --- /dev/null +++ b/website/src/app/providers.tsx @@ -0,0 +1,12 @@ +"use client"; + +import posthog from "posthog-js"; +import { PostHogProvider as PHProvider } from "posthog-js/react"; + +export function PostHogProvider({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/website/src/components/Footer.tsx b/website/src/components/Footer.tsx index e8a8393af..add4dcfe4 100644 --- a/website/src/components/Footer.tsx +++ b/website/src/components/Footer.tsx @@ -18,6 +18,11 @@ import { typography, } from "@policyengine/design-system/tokens"; import { useCountryId } from "@/hooks/useCountryId"; +import { + trackNewsletterSignupFailed, + trackNewsletterSignupStarted, + trackNewsletterSignupSucceeded, +} from "@/lib/posthog-events"; const PolicyEngineLogo = "/assets/logos/policyengine/white.svg"; @@ -70,11 +75,16 @@ function FooterSubscribe() { if (!email) { setStatus("error"); setMessage("Please enter a valid email address."); + trackNewsletterSignupFailed({ + has_email: false, + reason: "missing_email", + }); return; } setStatus("loading"); setMessage(""); + trackNewsletterSignupStarted({ has_email: true }); const MAILCHIMP_URL = "https://policyengine.us5.list-manage.com/subscribe/post-json?u=e5ad35332666289a0f48013c5&id=71ed1f89d8&f_id=00f173e6f0"; @@ -89,15 +99,24 @@ function FooterSubscribe() { setMessage( "There was an issue processing your subscription; please try again later.", ); + trackNewsletterSignupFailed({ + has_email: true, + reason: "network_error", + }); return; } if (data?.result !== "error") { setStatus("success"); setMessage(data?.msg || "Successfully subscribed!"); + trackNewsletterSignupSucceeded({ has_email: true }); setEmail(""); } else { setStatus("error"); setMessage(data?.msg || "Subscription failed. Please try again."); + trackNewsletterSignupFailed({ + has_email: true, + reason: "mailchimp_error", + }); } }, ); diff --git a/website/src/components/Header.tsx b/website/src/components/Header.tsx index 3c06065a0..5e2907807 100644 --- a/website/src/components/Header.tsx +++ b/website/src/components/Header.tsx @@ -15,6 +15,7 @@ import { typography, } from "@policyengine/design-system/tokens"; import { useCountryId } from "@/hooks/useCountryId"; +import { trackCountrySwitched } from "@/lib/posthog-events"; const PolicyEngineLogo = "/assets/logos/policyengine/white.svg"; @@ -255,6 +256,11 @@ function CountrySelector() { const handleCountryChange = useCallback( (newCountryId: string) => { setOpen(false); + trackCountrySwitched({ + from_country_id: countryId, + to_country_id: newCountryId, + pathname, + }); const pathParts = pathname.split("/").filter(Boolean); if (pathParts.length > 0) { pathParts[0] = newCountryId; @@ -263,7 +269,7 @@ function CountrySelector() { router.push(`/${newCountryId}`); } }, - [pathname, router], + [countryId, pathname, router], ); useEffect(() => { diff --git a/website/src/components/home/HeroCTA.tsx b/website/src/components/home/HeroCTA.tsx index b5ffbc6c4..5ec31dfa0 100644 --- a/website/src/components/home/HeroCTA.tsx +++ b/website/src/components/home/HeroCTA.tsx @@ -8,6 +8,7 @@ import { spacing, typography, } from "@policyengine/design-system/tokens"; +import { trackEnterCalculatorClicked } from "@/lib/posthog-events"; const ctaVariant = { hidden: { opacity: 0, y: 20 }, @@ -24,6 +25,8 @@ const ctaVariant = { }; export default function HeroCTA({ countryId }: { countryId: string }) { + const destinationUrl = `${CALCULATOR_URL}/${countryId}/reports`; + return ( + trackEnterCalculatorClicked({ + country_id: countryId, + destination_url: destinationUrl, + }) + } whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ diff --git a/website/src/lib/posthog-events.ts b/website/src/lib/posthog-events.ts new file mode 100644 index 000000000..2fc4574fa --- /dev/null +++ b/website/src/lib/posthog-events.ts @@ -0,0 +1,114 @@ +"use client"; + +import type { Properties } from "posthog-js"; +import posthog from "posthog-js"; + +type WebsiteWindow = Window & { + __policyenginePostHogInitialized?: boolean; +}; + +type WebsiteProperties = Properties & { + release?: string; + surface?: "website"; +}; + +function isPostHogReady() { + return ( + typeof window !== "undefined" && + Boolean((window as WebsiteWindow).__policyenginePostHogInitialized) + ); +} + +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(typeof error === "string" ? error : "Unknown website error"); +} + +export function captureWebsiteEvent( + eventName: string, + properties: WebsiteProperties = {}, +) { + if (!isPostHogReady()) { + return; + } + + posthog.capture(eventName, { + surface: "website", + ...properties, + }); +} + +export function captureWebsiteException( + error: unknown, + properties: WebsiteProperties = {}, +) { + if (!isPostHogReady()) { + return; + } + + posthog.captureException(normalizeError(error), { + surface: "website", + ...properties, + }); +} + +export function trackEnterCalculatorClicked(properties: { + country_id: string; + destination_url: string; +}) { + captureWebsiteEvent("enter_calculator_clicked", properties); +} + +export function trackNewsletterSignupStarted(properties: { + has_email: boolean; +}) { + captureWebsiteEvent("newsletter_signup_started", properties); +} + +export function trackNewsletterSignupSucceeded(properties: { + has_email: boolean; +}) { + captureWebsiteEvent("newsletter_signup_succeeded", properties); +} + +export function trackNewsletterSignupFailed(properties: { + has_email: boolean; + reason: string; +}) { + captureWebsiteEvent("newsletter_signup_failed", properties); +} + +export function trackResearchArticleViewed(properties: { + country_id: string; + slug: string; + title: string; + author_count: number; + topic_tags: string[]; + location_tags: string[]; + is_notebook: boolean; +}) { + captureWebsiteEvent("research_article_viewed", properties); +} + +export function trackResearchFiltersChanged(properties: { + country_id: string; + search_query: string; + selected_types: string[]; + selected_topics: string[]; + selected_locations: string[]; + selected_authors: string[]; + result_count: number; +}) { + captureWebsiteEvent("research_filters_changed", properties); +} + +export function trackCountrySwitched(properties: { + from_country_id: string; + to_country_id: string; + pathname: string; +}) { + captureWebsiteEvent("country_switched", properties); +} diff --git a/website/src/lib/posthog-server.ts b/website/src/lib/posthog-server.ts new file mode 100644 index 000000000..aa7a053da --- /dev/null +++ b/website/src/lib/posthog-server.ts @@ -0,0 +1,34 @@ +import { PostHog } from "posthog-node"; + +let posthogInstance: PostHog | null = null; + +function getPostHogToken() { + return ( + process.env.POSTHOG_PROJECT_TOKEN ?? + process.env.NEXT_PUBLIC_POSTHOG_TOKEN ?? + process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN + ); +} + +function getPostHogHost() { + return process.env.POSTHOG_HOST ?? process.env.NEXT_PUBLIC_POSTHOG_HOST; +} + +export function getPostHogServer() { + const token = getPostHogToken(); + const host = getPostHogHost(); + + if (!token || !host) { + return null; + } + + if (!posthogInstance) { + posthogInstance = new PostHog(token, { + host, + flushAt: 1, + flushInterval: 0, + }); + } + + return posthogInstance; +} From 569c104f0d30b8a8286d69c173c6e5a4e87196ee Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 2 Apr 2026 23:30:25 +0200 Subject: [PATCH 2/5] Harden PostHog release and error tracking setup --- .node-version | 2 + .nvmrc | 2 + app/src/hooks/useCreateHousehold.ts | 7 ++ app/src/hooks/useCreatePolicy.ts | 7 ++ app/src/utils/errorTracking.ts | 7 ++ bun.lock | 154 ++++++++++++++++++++++++++++ calculator-app/.env.example | 5 + calculator-app/instrumentation.ts | 23 ++++- calculator-app/next.config.ts | 27 ++++- calculator-app/package.json | 3 +- website/.env.example | 5 + website/instrumentation.ts | 23 ++++- website/next.config.ts | 22 +++- website/package.json | 3 +- 14 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 .node-version create mode 100644 .nvmrc 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/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/utils/errorTracking.ts b/app/src/utils/errorTracking.ts index bb3e6c8cd..02bbfbe7b 100644 --- a/app/src/utils/errorTracking.ts +++ b/app/src/utils/errorTracking.ts @@ -39,6 +39,13 @@ export function captureCalculationException( }); } +export function captureApiException(error: unknown, context: ErrorContext = {}) { + captureCalculatorException(error, { + source: 'api', + ...context, + }); +} + export function captureRouteException(error: unknown, context: ErrorContext = {}) { captureCalculatorException(error, { source: 'route', diff --git a/bun.lock b/bun.lock index b87c9ad82..bd48457bb 100644 --- a/bun.lock +++ b/bun.lock @@ -108,6 +108,7 @@ "dependencies": { "@normy/react-query": "^0.20.0", "@policyengine/design-system": "workspace:*", + "@posthog/nextjs-config": "^1.0.3", "@reduxjs/toolkit": "^2.8.2", "@tabler/icons-react": "^3.34.0", "@tanstack/react-query": "^5.84.1", @@ -173,6 +174,7 @@ "version": "0.1.0", "dependencies": { "@policyengine/design-system": "*", + "@posthog/nextjs-config": "^1.0.3", "@tabler/icons-react": "^3.31.0", "framer-motion": "^12.38.0", "fuse.js": "^7.1.0", @@ -447,6 +449,8 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -551,10 +555,18 @@ "@policyengine/website": ["@policyengine/website@workspace:website"], + "@posthog/cli": ["@posthog/cli@0.7.4", "", { "dependencies": { "axios": "^1.13.5", "axios-proxy-builder": "^0.1.2", "console.table": "^0.10.0", "detect-libc": "^2.1.2", "rimraf": "^6.1.3" }, "bin": { "posthog-cli": "run-posthog-cli.js" } }, "sha512-HOUoqUil+dmC8jjy9QgRKYxkP+OoJDVmQBzk7OZ31JlBOBrTzBtqwOSw2jKRzUazdKheLIoZeuRGMiRAA22gfw=="], + "@posthog/core": ["@posthog/core@1.24.5", "", {}, "sha512-umXx3kMjM+cTUTLDsdPFFU7aJa3uiH19EEoWKbE5QVME8WgVg7q1peMhK7y7n7xRmYJlA70eOrHQfWlzBQqeFQ=="], + "@posthog/nextjs-config": ["@posthog/nextjs-config@1.8.28", "", { "dependencies": { "@posthog/cli": "~0.7.3", "@posthog/plugin-utils": "1.0.1", "@posthog/webpack-plugin": "1.3.6", "semver": "^7.7.2" }, "peerDependencies": { "next": ">12.1.0" } }, "sha512-S1ZXcP7GYf89iioSIaf85A1xry9dXL+V8fvwaB+OSr28flh4aCcHB8M4293o30wJ8t6PziP+qSn/E3bfzNs+xA=="], + + "@posthog/plugin-utils": ["@posthog/plugin-utils@1.0.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-+x3LFZlUVPNUPQBx9kvRV/hXd1j3JcYf9iYClJCORjuuA0RV5FICUZMMFbwsSOPuKoW9rQAaW3zFoQMYEfM+YA=="], + "@posthog/types": ["@posthog/types@1.364.5", "", {}, "sha512-lekH0rJ5NVuX0vj9XlSrGshHaNHOBMBcn1dv4dVpI8HVnLrqmSvMidRfYPYL78d2tsCEjs7qWD7yri77GQvzSg=="], + "@posthog/webpack-plugin": ["@posthog/webpack-plugin@1.3.6", "", { "dependencies": { "@posthog/cli": "~0.7.3", "@posthog/core": "1.24.6", "@posthog/plugin-utils": "1.0.1" }, "peerDependencies": { "webpack": "^5" } }, "sha512-gIfCT4EcVMdfiYf4zARl+sAKwZBmneNgfDxbfCfi1px6MGTll8pKOJZDYmnpzfP9XzSlIKHRvD4FiOlGIT+seA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -919,6 +931,10 @@ "@types/doctrine": ["@types/doctrine@0.0.9", "", {}, "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + + "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1027,6 +1043,40 @@ "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], + + "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], + + "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], + + "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], + + "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], + + "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], + + "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], + + "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], + + "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], + + "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], + + "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], + + "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], + + "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], + + "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], + + "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], + + "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="], @@ -1035,6 +1085,8 @@ "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], @@ -1043,6 +1095,10 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], + + "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1095,10 +1151,16 @@ "async-sema": ["async-sema@3.1.1", "", {}, "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], + "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="], + + "axios-proxy-builder": ["axios-proxy-builder@0.1.2", "", { "dependencies": { "tunnel": "^0.0.6" } }, "sha512-6uBVsBZzkB3tCC8iyx59mCjQckhB8+GQrI9Cop8eC7ybIsvs/KtnNgEBfRMSEa7GqK2VBGUzgjNYMdPIfotyPA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -1167,6 +1229,8 @@ "chromatic": ["chromatic@15.2.0", "", { "peerDependencies": { "@chromatic-com/cypress": "^0.*.* || ^1.0.0", "@chromatic-com/playwright": "^0.*.* || ^1.0.0" }, "optionalPeers": ["@chromatic-com/cypress", "@chromatic-com/playwright"], "bin": { "chroma": "dist/bin.js", "chromatic": "dist/bin.js", "chromatic-cli": "dist/bin.js" } }, "sha512-c9tDfE62aiPVPnVab8jQLz+9c9II/CUFZ6T2Kk3hi2hSL+HLkRwX3zjwRYW1z9Shn57R/ORBEpQ3ftufp8EgWA=="], + "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="], "clamp": ["clamp@1.0.1", "", {}, "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA=="], @@ -1177,6 +1241,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -1201,6 +1267,8 @@ "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -1213,6 +1281,8 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "console.table": ["console.table@0.10.0", "", { "dependencies": { "easy-table": "1.1.0" } }, "sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g=="], + "convert-hrtime": ["convert-hrtime@3.0.0", "", {}, "sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -1331,6 +1401,8 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], @@ -1339,6 +1411,8 @@ "defined": ["defined@1.0.1", "", {}, "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-kerning": ["detect-kerning@2.1.2", "", {}, "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw=="], @@ -1373,6 +1447,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "easy-table": ["easy-table@1.1.0", "", { "optionalDependencies": { "wcwidth": ">=1.0.1" } }, "sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA=="], + "edge-runtime": ["edge-runtime@2.5.9", "", { "dependencies": { "@edge-runtime/format": "2.2.1", "@edge-runtime/ponyfill": "2.4.2", "@edge-runtime/vm": "3.2.0", "async-listen": "3.0.1", "mri": "1.2.0", "picocolors": "1.0.0", "pretty-ms": "7.0.1", "signal-exit": "4.0.2", "time-span": "4.0.0" }, "bin": { "edge-runtime": "dist/cli/index.js" } }, "sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg=="], "electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="], @@ -1485,6 +1561,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fastest-levenshtein": ["fastest-levenshtein@1.0.16", "", {}, "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -1509,6 +1587,8 @@ "flatten-vertex-data": ["flatten-vertex-data@1.0.2", "", { "dependencies": { "dtype": "^2.0.0" } }, "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "font-atlas": ["font-atlas@2.1.0", "", { "dependencies": { "css-font": "^1.0.0" } }, "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg=="], "font-measure": ["font-measure@1.2.2", "", { "dependencies": { "css-font": "^1.2.0" } }, "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA=="], @@ -1517,6 +1597,8 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], @@ -1567,6 +1649,8 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global-modules": ["global-modules@2.0.0", "", { "dependencies": { "global-prefix": "^3.0.0" } }, "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A=="], "global-prefix": ["global-prefix@3.0.0", "", { "dependencies": { "ini": "^1.3.5", "kind-of": "^6.0.2", "which": "^1.3.1" } }, "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg=="], @@ -1807,6 +1891,8 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1879,6 +1965,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -1959,6 +2047,8 @@ "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], @@ -2063,6 +2153,8 @@ "needle": ["needle@2.9.1", "", { "dependencies": { "debug": "^3.2.6", "iconv-lite": "^0.4.4", "sax": "^1.2.4" }, "bin": { "needle": "./bin/needle" } }, "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "next": ["next@15.5.12", "", { "dependencies": { "@next/env": "15.5.12", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.12", "@next/swc-darwin-x64": "15.5.12", "@next/swc-linux-arm64-gnu": "15.5.12", "@next/swc-linux-arm64-musl": "15.5.12", "@next/swc-linux-x64-gnu": "15.5.12", "@next/swc-linux-x64-musl": "15.5.12", "@next/swc-win32-arm64-msvc": "15.5.12", "@next/swc-win32-x64-msvc": "15.5.12", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA=="], "next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], @@ -2219,6 +2311,8 @@ "protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "qified": ["qified@0.6.0", "", { "dependencies": { "hookified": "^1.14.0" } }, "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA=="], @@ -2325,6 +2419,8 @@ "right-now": ["right-now@1.0.0", "", {}, "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], + "rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="], "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], @@ -2351,6 +2447,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -2393,6 +2491,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "stack-trace": ["stack-trace@0.0.9", "", {}, "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ=="], @@ -2499,6 +2599,10 @@ "tar": ["tar@7.5.6", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA=="], + "terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="], + + "terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="], + "through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="], "time-span": ["time-span@4.0.0", "", { "dependencies": { "convert-hrtime": "^3.0.0" } }, "sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g=="], @@ -2563,6 +2667,8 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "turbo": ["turbo@2.7.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.6", "turbo-darwin-arm64": "2.7.6", "turbo-linux-64": "2.7.6", "turbo-linux-arm64": "2.7.6", "turbo-windows-64": "2.7.6", "turbo-windows-arm64": "2.7.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-PO9AvJLEsNLO+EYhF4zB+v10hOjsJe5kJW+S6tTbRv+TW7gf1Qer4mfjP9h3/y9h8ZiPvOrenxnEgDtFgaM5zw=="], "turbo-darwin-64": ["turbo-darwin-64@2.7.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-bYu0qnWju2Ha3EbIkPCk1SMLT3sltKh1P/Jy5FER6BmH++H5z+T5MHh3W1Xoers9rk4N1VdKvog9FO1pxQyjhw=="], @@ -2665,6 +2771,10 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="], + + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "weak-map": ["weak-map@1.0.8", "", {}, "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -2675,6 +2785,10 @@ "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "webpack": ["webpack@5.105.4", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw=="], + + "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], @@ -2777,6 +2891,8 @@ "@plotly/d3-sankey-circular/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "@posthog/webpack-plugin/@posthog/core": ["@posthog/core@1.24.6", "", {}, "sha512-9WkcRKqmXSWIJcca6m3VwA9YbFd4HiG2hKEtDq6FcwEHlvfDhQQUZ5/sJZ47Fw8OtyNMHQ6rW4+COttk4Bg5NQ=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@storybook/addon-docs/@storybook/react-dom-shim": ["@storybook/react-dom-shim@8.6.14", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^8.6.14" } }, "sha512-0hixr3dOy3f3M+HBofp3jtMQMS+sqzjKNgl7Arfuj3fvjmyXOks/yGjDImySR4imPtEllvPZfhiQNlejheaInw=="], @@ -2821,6 +2937,10 @@ "@vercel/static-config/ajv": ["ajv@8.6.3", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw=="], + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-keywords/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "cacheable/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], @@ -2957,6 +3077,10 @@ "regl-scatter2d/color-rgba": ["color-rgba@2.4.0", "", { "dependencies": { "color-parse": "^1.4.2", "color-space": "^2.0.0" } }, "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q=="], + "rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "stream-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -2985,6 +3109,8 @@ "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "victory-vendor/@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], "victory-vendor/d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -2995,6 +3121,14 @@ "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "webpack/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "webpack/enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "webpack/es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + + "webpack/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -3141,6 +3275,10 @@ "@vercel/static-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "color-normalize/color-rgba/color-parse": ["color-parse@1.4.3", "", { "dependencies": { "color-name": "^1.0.0" } }, "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A=="], "d3-scale/d3-array/internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], @@ -3183,6 +3321,14 @@ "regl-scatter2d/color-rgba/color-parse": ["color-parse@1.4.3", "", { "dependencies": { "color-name": "^1.0.0" } }, "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A=="], + "rimraf/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "rimraf/glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "rimraf/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "stream-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "stylelint/file-entry-cache/flat-cache": ["flat-cache@6.1.20", "", { "dependencies": { "cacheable": "^2.3.2", "flatted": "^3.3.3", "hookified": "^1.15.0" } }, "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ=="], @@ -3245,6 +3391,8 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "webpack/eslint-scope/estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -3270,5 +3418,11 @@ "maplibre-gl/global-prefix/which/isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "rimraf/glob/path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/calculator-app/.env.example b/calculator-app/.env.example index 2be5490d1..273c1ddcf 100644 --- a/calculator-app/.env.example +++ b/calculator-app/.env.example @@ -6,3 +6,8 @@ NEXT_PUBLIC_APP_RELEASE=local # Optional server-side PostHog configuration POSTHOG_PROJECT_TOKEN=phc_your_project_token_here POSTHOG_HOST=https://us.i.posthog.com +APP_RELEASE=local + +# Optional release/source-map upload configuration +POSTHOG_API_KEY=phx_your_personal_api_key_here +POSTHOG_PROJECT_ID=12345 diff --git a/calculator-app/instrumentation.ts b/calculator-app/instrumentation.ts index 2d86872cc..86973e579 100644 --- a/calculator-app/instrumentation.ts +++ b/calculator-app/instrumentation.ts @@ -1,5 +1,6 @@ type RequestLike = { headers?: Headers | { cookie?: string | string[] | undefined }; + url?: string; }; function normalizeError(error: unknown): Error { @@ -48,6 +49,22 @@ function getDistinctIdFromCookie(cookieHeader?: string): string | undefined { export function register() {} +function getServerRelease() { + return process.env.APP_RELEASE ?? process.env.NEXT_PUBLIC_APP_RELEASE; +} + +function getRequestPath(request: RequestLike): string | undefined { + if (!request.url) { + return undefined; + } + + try { + return new URL(request.url).pathname; + } catch { + return undefined; + } +} + export async function onRequestError( error: unknown, request: RequestLike, @@ -65,5 +82,9 @@ export async function onRequestError( } const distinctId = getDistinctIdFromCookie(getCookieHeader(request)); - await posthog.captureException(normalizeError(error), distinctId); + await posthog.captureException(normalizeError(error), distinctId, { + surface: "calculator", + release: getServerRelease(), + request_path: getRequestPath(request), + }); } diff --git a/calculator-app/next.config.ts b/calculator-app/next.config.ts index cdec2aa72..254e612c8 100644 --- a/calculator-app/next.config.ts +++ b/calculator-app/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withPostHogConfig } from "@posthog/nextjs-config"; import path from "path"; import { fileURLToPath } from "url"; @@ -24,6 +25,11 @@ const nextConfig: NextConfig = { ), "import.meta.env.SSR": "false", "import.meta.env.VITE_APP_MODE": JSON.stringify("calculator"), + "import.meta.env.VITE_APP_RELEASE": JSON.stringify( + process.env.NEXT_PUBLIC_APP_RELEASE || + process.env.APP_RELEASE || + "", + ), "import.meta.env.VITE_WEBSITE_URL": JSON.stringify( process.env.NEXT_PUBLIC_WEBSITE_URL || "", ), @@ -48,4 +54,23 @@ const nextConfig: NextConfig = { }; -export default nextConfig; +const posthogApiKey = process.env.POSTHOG_API_KEY; +const posthogProjectId = process.env.POSTHOG_PROJECT_ID; + +const posthogNextConfig = + posthogApiKey && posthogProjectId + ? withPostHogConfig(nextConfig, { + personalApiKey: posthogApiKey, + projectId: posthogProjectId, + host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + sourcemaps: { + enabled: true, + releaseName: "policyengine-calculator", + releaseVersion: + process.env.APP_RELEASE ?? process.env.NEXT_PUBLIC_APP_RELEASE, + deleteAfterUpload: true, + }, + }) + : nextConfig; + +export default posthogNextConfig; diff --git a/calculator-app/package.json b/calculator-app/package.json index 52fd2a21f..012f28c8b 100644 --- a/calculator-app/package.json +++ b/calculator-app/package.json @@ -5,10 +5,11 @@ "scripts": { "dev": "sh -c 'next dev --port ${PORT:-3001}'", "build": "next build", - "start": "next start", + "start": "sh -c 'next start --port ${PORT:-3001}'", "typecheck": "tsc --noEmit" }, "dependencies": { + "@posthog/nextjs-config": "^1.0.3", "next": "^15.3.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/website/.env.example b/website/.env.example index 2be5490d1..273c1ddcf 100644 --- a/website/.env.example +++ b/website/.env.example @@ -6,3 +6,8 @@ NEXT_PUBLIC_APP_RELEASE=local # Optional server-side PostHog configuration POSTHOG_PROJECT_TOKEN=phc_your_project_token_here POSTHOG_HOST=https://us.i.posthog.com +APP_RELEASE=local + +# Optional release/source-map upload configuration +POSTHOG_API_KEY=phx_your_personal_api_key_here +POSTHOG_PROJECT_ID=12345 diff --git a/website/instrumentation.ts b/website/instrumentation.ts index f14c89cc2..e4151e12d 100644 --- a/website/instrumentation.ts +++ b/website/instrumentation.ts @@ -1,5 +1,6 @@ type RequestLike = { headers?: Headers | { cookie?: string | string[] | undefined }; + url?: string; }; function normalizeError(error: unknown): Error { @@ -48,6 +49,22 @@ function getDistinctIdFromCookie(cookieHeader?: string): string | undefined { export function register() {} +function getServerRelease() { + return process.env.APP_RELEASE ?? process.env.NEXT_PUBLIC_APP_RELEASE; +} + +function getRequestPath(request: RequestLike): string | undefined { + if (!request.url) { + return undefined; + } + + try { + return new URL(request.url).pathname; + } catch { + return undefined; + } +} + export async function onRequestError( error: unknown, request: RequestLike, @@ -65,5 +82,9 @@ export async function onRequestError( } const distinctId = getDistinctIdFromCookie(getCookieHeader(request)); - await posthog.captureException(normalizeError(error), distinctId); + await posthog.captureException(normalizeError(error), distinctId, { + surface: "website", + release: getServerRelease(), + request_path: getRequestPath(request), + }); } diff --git a/website/next.config.ts b/website/next.config.ts index 308dfee2d..5c8528981 100644 --- a/website/next.config.ts +++ b/website/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withPostHogConfig } from "@posthog/nextjs-config"; const nextConfig: NextConfig = { async redirects() { @@ -70,4 +71,23 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +const posthogApiKey = process.env.POSTHOG_API_KEY; +const posthogProjectId = process.env.POSTHOG_PROJECT_ID; + +const posthogNextConfig = + posthogApiKey && posthogProjectId + ? withPostHogConfig(nextConfig, { + personalApiKey: posthogApiKey, + projectId: posthogProjectId, + host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + sourcemaps: { + enabled: true, + releaseName: "policyengine-website", + releaseVersion: + process.env.APP_RELEASE ?? process.env.NEXT_PUBLIC_APP_RELEASE, + deleteAfterUpload: true, + }, + }) + : nextConfig; + +export default posthogNextConfig; diff --git a/website/package.json b/website/package.json index 52474cdc1..587a47f8a 100644 --- a/website/package.json +++ b/website/package.json @@ -7,13 +7,14 @@ "dev:full": "node scripts/dev-server.mjs", "build": "bash scripts/build.sh", "build:next": "next build", - "start": "next start", + "start": "sh -c 'next start --port ${PORT:-3000}'", "lint": "next lint", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { + "@posthog/nextjs-config": "^1.0.3", "@policyengine/design-system": "*", "@tabler/icons-react": "^3.31.0", "fuse.js": "^7.1.0", From 6e8a2d36b8707093ae251eb9dea6da670c043e9f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 2 Apr 2026 23:41:12 +0200 Subject: [PATCH 3/5] Proxy PostHog traffic through Next apps --- calculator-app/instrumentation-client.ts | 5 +- calculator-app/next.config.ts | 10 +++ calculator-app/src/lib/posthogProxy.ts | 91 ++++++++++++++++++++++++ website/instrumentation-client.ts | 5 +- website/next.config.ts | 8 +++ website/src/lib/posthogProxy.ts | 91 ++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 calculator-app/src/lib/posthogProxy.ts create mode 100644 website/src/lib/posthogProxy.ts diff --git a/calculator-app/instrumentation-client.ts b/calculator-app/instrumentation-client.ts index 942270418..a89e22b0d 100644 --- a/calculator-app/instrumentation-client.ts +++ b/calculator-app/instrumentation-client.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import { getPostHogProxyConfig } from "./src/lib/posthogProxy"; type PolicyEngineWindow = Window & { __policyenginePostHogInitialized?: boolean; @@ -9,10 +10,12 @@ const posthogToken = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; const release = process.env.NEXT_PUBLIC_APP_RELEASE; +const posthogProxy = getPostHogProxyConfig(posthogHost); if (posthogToken && posthogHost) { posthog.init(posthogToken, { - api_host: posthogHost, + api_host: posthogProxy?.apiHost ?? posthogHost, + ui_host: posthogProxy?.uiHost, defaults: "2026-01-30", loaded: (client) => { client.register({ diff --git a/calculator-app/next.config.ts b/calculator-app/next.config.ts index 254e612c8..65a526569 100644 --- a/calculator-app/next.config.ts +++ b/calculator-app/next.config.ts @@ -2,15 +2,25 @@ import type { NextConfig } from "next"; import { withPostHogConfig } from "@posthog/nextjs-config"; import path from "path"; import { fileURLToPath } from "url"; +import { getPostHogProxyRewrites } from "./src/lib/posthogProxy"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const posthogProxyRewrites = getPostHogProxyRewrites( + process.env.NEXT_PUBLIC_POSTHOG_HOST +); const nextConfig: NextConfig = { // Compile TypeScript files from ../app/src/ (the shared Vite codebase) + skipTrailingSlashRedirect: posthogProxyRewrites.length > 0, + experimental: { externalDir: true, }, + async rewrites() { + return posthogProxyRewrites; + }, + webpack: (config, { dev }) => { // Polyfill import.meta.env for shared code written for Vite. // These are replaced at build time — no runtime cost. diff --git a/calculator-app/src/lib/posthogProxy.ts b/calculator-app/src/lib/posthogProxy.ts new file mode 100644 index 000000000..b1519ad6e --- /dev/null +++ b/calculator-app/src/lib/posthogProxy.ts @@ -0,0 +1,91 @@ +const POSTHOG_PROXY_PATH = "/_euclid"; + +type CloudRegion = "us" | "eu"; + +type PostHogProxyConfig = { + apiHost: string; + apiDestination: string; + assetDestination: string; + uiHost: string; +}; + +const CLOUD_REGION_HOSTS: Record< + string, + { + region: CloudRegion; + uiHost: string; + assetDestination: string; + } +> = { + "us.i.posthog.com": { + region: "us", + uiHost: "https://us.posthog.com", + assetDestination: "https://us-assets.i.posthog.com", + }, + "eu.i.posthog.com": { + region: "eu", + uiHost: "https://eu.posthog.com", + assetDestination: "https://eu-assets.i.posthog.com", + }, +}; + +function normalizeHost(host: string | null | undefined): URL | null { + if (!host) { + return null; + } + + try { + return new URL(host); + } catch { + return null; + } +} + +export function getPostHogProxyConfig( + host: string | null | undefined +): PostHogProxyConfig | null { + const normalizedHost = normalizeHost(host); + + if (!normalizedHost) { + return null; + } + + const cloudHost = CLOUD_REGION_HOSTS[normalizedHost.hostname]; + + if (!cloudHost) { + return null; + } + + return { + apiHost: POSTHOG_PROXY_PATH, + apiDestination: normalizedHost.origin, + assetDestination: cloudHost.assetDestination, + uiHost: cloudHost.uiHost, + }; +} + +export function getPostHogProxyRewrites( + host: string | null | undefined +): Array<{ source: string; destination: string }> { + const proxyConfig = getPostHogProxyConfig(host); + + if (!proxyConfig) { + return []; + } + + return [ + { + source: `${POSTHOG_PROXY_PATH}/static/:path*`, + destination: `${proxyConfig.assetDestination}/static/:path*`, + }, + { + source: `${POSTHOG_PROXY_PATH}/array/:path*`, + destination: `${proxyConfig.assetDestination}/array/:path*`, + }, + { + source: `${POSTHOG_PROXY_PATH}/:path*`, + destination: `${proxyConfig.apiDestination}/:path*`, + }, + ]; +} + diff --git a/website/instrumentation-client.ts b/website/instrumentation-client.ts index 6f25363a7..c8bcc8a0d 100644 --- a/website/instrumentation-client.ts +++ b/website/instrumentation-client.ts @@ -1,4 +1,5 @@ import posthog from "posthog-js"; +import { getPostHogProxyConfig } from "./src/lib/posthogProxy"; type PolicyEngineWindow = Window & { __policyenginePostHogInitialized?: boolean; @@ -9,10 +10,12 @@ const posthogToken = process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; const release = process.env.NEXT_PUBLIC_APP_RELEASE; +const posthogProxy = getPostHogProxyConfig(posthogHost); if (posthogToken && posthogHost) { posthog.init(posthogToken, { - api_host: posthogHost, + api_host: posthogProxy?.apiHost ?? posthogHost, + ui_host: posthogProxy?.uiHost, defaults: "2026-01-30", loaded: (client) => { client.register({ diff --git a/website/next.config.ts b/website/next.config.ts index 5c8528981..cdfd37e8e 100644 --- a/website/next.config.ts +++ b/website/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; import { withPostHogConfig } from "@posthog/nextjs-config"; +import { getPostHogProxyRewrites } from "./src/lib/posthogProxy"; + +const posthogProxyRewrites = getPostHogProxyRewrites( + process.env.NEXT_PUBLIC_POSTHOG_HOST +); const nextConfig: NextConfig = { + skipTrailingSlashRedirect: posthogProxyRewrites.length > 0, + async redirects() { return [ // Root → /us (temporary — will be replaced with geolocation) @@ -26,6 +33,7 @@ const nextConfig: NextConfig = { async rewrites() { return [ + ...posthogProxyRewrites, // State legislative tracker (Modal) { source: "/_tracker/:path*", diff --git a/website/src/lib/posthogProxy.ts b/website/src/lib/posthogProxy.ts new file mode 100644 index 000000000..b1519ad6e --- /dev/null +++ b/website/src/lib/posthogProxy.ts @@ -0,0 +1,91 @@ +const POSTHOG_PROXY_PATH = "/_euclid"; + +type CloudRegion = "us" | "eu"; + +type PostHogProxyConfig = { + apiHost: string; + apiDestination: string; + assetDestination: string; + uiHost: string; +}; + +const CLOUD_REGION_HOSTS: Record< + string, + { + region: CloudRegion; + uiHost: string; + assetDestination: string; + } +> = { + "us.i.posthog.com": { + region: "us", + uiHost: "https://us.posthog.com", + assetDestination: "https://us-assets.i.posthog.com", + }, + "eu.i.posthog.com": { + region: "eu", + uiHost: "https://eu.posthog.com", + assetDestination: "https://eu-assets.i.posthog.com", + }, +}; + +function normalizeHost(host: string | null | undefined): URL | null { + if (!host) { + return null; + } + + try { + return new URL(host); + } catch { + return null; + } +} + +export function getPostHogProxyConfig( + host: string | null | undefined +): PostHogProxyConfig | null { + const normalizedHost = normalizeHost(host); + + if (!normalizedHost) { + return null; + } + + const cloudHost = CLOUD_REGION_HOSTS[normalizedHost.hostname]; + + if (!cloudHost) { + return null; + } + + return { + apiHost: POSTHOG_PROXY_PATH, + apiDestination: normalizedHost.origin, + assetDestination: cloudHost.assetDestination, + uiHost: cloudHost.uiHost, + }; +} + +export function getPostHogProxyRewrites( + host: string | null | undefined +): Array<{ source: string; destination: string }> { + const proxyConfig = getPostHogProxyConfig(host); + + if (!proxyConfig) { + return []; + } + + return [ + { + source: `${POSTHOG_PROXY_PATH}/static/:path*`, + destination: `${proxyConfig.assetDestination}/static/:path*`, + }, + { + source: `${POSTHOG_PROXY_PATH}/array/:path*`, + destination: `${proxyConfig.assetDestination}/array/:path*`, + }, + { + source: `${POSTHOG_PROXY_PATH}/:path*`, + destination: `${proxyConfig.apiDestination}/:path*`, + }, + ]; +} + From cc996f7b64ee653663bf2112de2e264a79d4c5f1 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 2 Apr 2026 23:45:51 +0200 Subject: [PATCH 4/5] Track report builder configuration events --- .../components/ReportMetaPanel.tsx | 19 ++++- .../hooks/useSimulationCanvas.ts | 81 ++++++++++++++++--- app/src/utils/analytics.ts | 66 +++++++++++++++ app/src/utils/analyticsSchemas.ts | 27 +++++++ 4 files changed, 180 insertions(+), 13 deletions(-) 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