Skip to main content

Product analytics (PostHog)

ContentsVision uses PostHog for product analytics: who is doing what, how often, and where they get stuck. It is captured from two sides at once, the browser and the backend, sharing one identity model so an event raised on the server and an event raised in the browser land on the same person and the same team.

It is off by default. With no key configured the backend's wrapper no-ops and the frontend ships with no tracking, so neither touches the network. This keeps local development and the test suite silent and offline. Turn it on by setting the keys in configuration.

Identity model

Both sides key on the local database IDs so events line up:

ConceptValueFrontendBackend
Person (distinct_id)User.id (UUID)posthog.identify(user.id, …)analytics.capture(distinct_id=user_id, …)
Team groupTeam.id (UUID)posthog.group('team', user.team_id)groups={'team': team_id}

Because the same UUIDs are used on both sides, a team's server events (uploads, job completions) and its browser events (page views, clicks) aggregate together under one group, and a user's whole journey is one timeline.

Backend capture

The backend wrapper is app/core/analytics.py. It is deliberately small and fail-safe: every function is a no-op when POSTHOG_API_KEY is unset, the PostHog SDK is imported lazily (so it need not even be installed when analytics is off), and a capture failure is swallowed and logged, never raised. This mirrors the activity log rule that a logging failure must not break the user's real action.

Every user action (the activity-log hook)

The single highest-leverage hook is in services/activity.py. The app already funnels every meaningful mutation through one log_activity() call; analytics piggybacks on it, so one capture there covers them all:

sign-in / sign-out, create / update / delete claim, submit claim to Adjust Square, create room, upload video / photos / audio, edit / approve / delete item, bulk edit, auto-approve, merge, add / edit / remove item photo, settings and prompt changes, restore (undo), and admin impersonation.

The action_type becomes the event name; the human label, target, and affected entity ride along as properties.

Job outcomes (the pipeline hook)

Pipeline completion happens asynchronously, after the request that started it is long gone, so it cannot go through log_activity. Instead services/pipeline.py calls _track_job_outcome() at every finalize point (video, photo, grouped-photo, and audio pipelines, on both success and failure). It resolves the owning team and the uploader from the job's claim and captures:

EventWhenKey properties
job_processing_completedA pipeline finishes successfullysource_type, items_found, rooms_detected, duration_seconds, billed_credits, photo_request_count
job_processing_failedA pipeline raisesthe same, plus a truncated error_message

These are server truth: they fire whether or not anyone has the app open to watch.

The same finalize points also fire the optional media-processing Slack notification (slack_notifier.notify_job_processed()), which is off unless SLACK_WEBHOOK_URL is set. Both hooks are best-effort and never raise into the pipeline.

Shutdown

The FastAPI lifespan calls analytics.shutdown() on exit, which flushes any queued events before the process stops.

Frontend capture

components/PostHogProvider.tsx is mounted once in the root layout, inside the auth provider. It initialises PostHog (lib/posthog.ts) and wires three things:

  • Page views. App Router does not reload the page on navigation, so automatic pageview is disabled and a $pageview is sent manually on every route change (under a Suspense boundary, as useSearchParams requires).
  • Autocapture. Clicks, form submits, and input changes are captured automatically (metadata only, never input values).
  • Identity. On login the user is identified and joined to their team group; on logout the client is reset so the next person starts fresh. While an admin is impersonating someone, every event is flagged with impersonating: true so those sessions can be excluded from real usage metrics.

A thin lib/analytics.ts track() helper adds a few client-only events that have no server equivalent: buy_credits_click (an external link), inventory_exported (a direct file download), and job_processing_confirmed / job_processing_cancelled (the credit-confirmation decision). Everything else is left to autocapture and the backend hooks, so events are not double-counted.

Session replay and PII

Claims carry personal data (insured names, addresses, claim numbers), so session replay is configured to mask all inputs by default. Two further controls:

  • Add the ph-no-capture class to any element to exclude it from both replay and autocapture.
  • To mask all on-screen text (not just inputs), set maskTextSelector: '*' in lib/posthog.ts. It is off by default because it makes replays much less useful; turn it on if your compliance posture requires it.

Person profiles are created for identified users only, so the anonymous login redirect does not spawn person records.

Turning it on

  1. Create a project in PostHog (or self-host) and copy its Project API Key (phc_…).
  2. Set POSTHOG_API_KEY (backend) and NEXT_PUBLIC_POSTHOG_KEY (frontend) to that same key, plus the matching *_HOST if you are not on US Cloud. See configuration.
  3. Rebuild the frontend (the NEXT_PUBLIC_* keys are build-time) and restart the backend.

To turn it back off, clear the keys and rebuild / restart.