Documentation source
Unified Analytics Instrumentation
Standardize product analytics on PostHog as the single sink, instrument a v1 event taxonomy across the app, and unify data-testid as the one selector attribute for PostHog, Playwright, and agent automation.
## Problem
Product analytics exists in name only. `features/analytics/recordAnalyticsEvent()` dual-writes to Postgres and PostHog, and there are ~8 call sites total (`ai.runtime.completed` in the agent runtime, `chat_started` in the chat route, `tool_run` in tools/execute + entity-tools, `entity_created`/`entity_updated` in entity-tools, plus two agent-connection events) — all using an ad-hoc snake_case event naming convention with no validated payload shape. Pageviews are captured by the PostHog provider, but there's no identify/group plumbing, no consistent coverage of core flows (entity CRUD from server actions, response submission, task execution, auth transitions, embedded tools), and no way to add a new event safely. The `analytics_events` Postgres table exists with no query paths that read it. Two analytics SDKs (PostHog client + PostHog server) are wired correctly, but the app is effectively blind to user behavior.
Separately, three neighboring systems have overlapped and confused the picture:
- `audit_logs` (postgres triggers) captures every row mutation for compliance — noisy, not for product analytics
- `activity` feed captures entity writes for UX display — product-facing, not telemetry
- `analytics_events` is the actual product analytics table, but is unused and unscoped
We need one clear sink for product analytics, a documented event taxonomy, consistent instrumentation across feature modules, and identity plumbing that plays well with our multi-tenant model and our embedded-tool roadmap.
## Solution
### 1. Responsibility split
```
PostHog → product analytics (events, funnels, retention, autocapture, flags, later replay)
Langfuse → LLM traces (unchanged)
Sentry → errors, Web Vitals, performance traces (unchanged)
Postgres → domain data only:
activity_log — entity write feed (UX-facing)
audit_logs — compliance record (postgres trigger)
session_events — execution transcript (agent work)
entity_responses — per-field versioned audit
cost_events — AI spend
user_recent_views — last-viewed watermark (unblocks spec #2)
```
`analytics_events` is deprecated in this spec and dropped in a follow-up migration after PostHog receipt is confirmed in production.
### 2. Event model
- **Naming:** `resource.verb[.qualifier?]`, lowercase snake, past-tense verb. Matches the existing `ai.runtime.completed` and `session_events.event_type` patterns.
- **`$pageview`:** stays PostHog-native. Domain super-properties (`tenant_slug`, `tenant_id`, `user_role`, `is_admin`) are registered via `posthog.register()` at auth-ready time on the client and merged into every subsequent event (including autocaptured clicks).
- **Selector attribute:** `data-testid` is the single source of truth for Playwright, PostHog autocapture, and agent DOM automation. Naming convention: `{feature}-{element}-{purpose}` (e.g., `entity-list-create-button`, `chat-composer-submit`). A custom ESLint rule (`no-untestable-interactive`) flags interactive shadcn primitives (`Button`, `Input`, `Select`, menu triggers) without one; scope is limited to top-of-funnel flows to avoid churn across 60+ components.
- **Event registry:** `features/analytics/events.ts` is the single source of truth. Every event has a Zod schema defining its allowed properties. `recordAnalyticsEvent()` validates the payload at the boundary; unknown properties are dropped with a dev-mode warning.
### 3. Identification strategy
| User state | `distinctId` | Groups |
|---|---|---|
| Logged-in user | `user.id` (UUID from Supabase auth) | `{ tenant: tenant_id }` |
| Signed-up pre-first-login | PostHog anonymous UUID → `identify(user.id)` on first login, merges history | none |
| Anonymous visitor (marketing, signup) | PostHog anonymous UUID | none |
| Embedded tool (anonymous external user) | `embed:{share_token}` | `{ tenant: tenant_id, tool: tool_slug }` |
| Server-initiated (heartbeat, Inngest) | `agent:{agent_id}` | `{ tenant: tenant_id }` |
Tenant is modeled as a PostHog **Group**, not just a property — unlocks tenant-level cohorts/funnels in the PostHog UI for B2B analysis. If the Group Analytics add-on is not enabled on the PostHog project, the calls degrade gracefully to filterable event properties with no code change needed.
### 4. v1 event coverage
Instrumented in this spec (~30 events). Cut ruthlessly — PostHog autocapture provides breadth for free.
- **Auth & tenant:** `auth.signed_up`, `auth.signed_in`, `auth.signed_out`, `auth.tenant_switched`, `auth.tenant_created`
- **Entity:** `entity.created`, `entity.updated`, `entity.deleted`, `entity.viewed` (also writes `user_recent_views` — unblocks spec #2)
- **Response:** `response.submitted`, `response.promoted`
- **Chat:** `chat.started`, `chat.message_sent`, `chat.agent_selected`
- **Tool:** `tool.invoked`, `tool.completed`, `tool.failed`, `tool.session_shared`, `tool.embed_opened`, `tool.embed_submitted`
- **Task/session:** `task.created`, `task.session_started`, `task.session_completed`, `task.session_failed`, `task.human_completed`
- **Agent:** `agent.dispatched`, `agent.heartbeat_run`, `ai.runtime.completed` (existing)
- **Activation:** `feature.adopted` — fires once per (user, feature) for the first use of key features; powers an activation funnel
Total: 29 explicit events plus PostHog-native `$pageview` and autocaptured clicks. `auth.signed_in` is the only auth event emitted client-side (immediately after the Supabase client reports success); all other server-initiated auth events fire from the server.
Deferred to follow-up specs: feature flags via PostHog, surveys, A/B experiments, session replay, tenant-facing dashboards (spec #2 covers "seen since last viewed", spec #3 covers notifications).
### 5. Emission rules
- **Server-first for domain events.** Everything in §4 except `chat.message_sent`, `tool.embed_opened`, `auth.signed_in` (captured client-side for latency), and UI-intent events fires from the server action that mutates state. Source of truth, no client drops, tenant + user known from the auth context.
- **Mandatory flushing.** Route handlers and server actions must use Next's `after()` helper or `await posthogServer.shutdown()` before returning. Inngest steps flush at the end of each step, not at the end of the function. Unflushed batches are dropped in serverless.
- **Client-only events:** `chat.message_sent` (fires before server round-trip for latency attribution), `tool.embed_opened` (runs on customer's site, no server round-trip), autocaptured clicks.
- **No dual emission.** If an event could fire from both server and client (e.g., `entity.created` from a server action and from an optimistic client update), the client version is omitted — the server is authoritative.
### 6. Privacy & PII
- **Stay cookieless** for v1. No consent banner, no session replay. Preserves current privacy posture and onboarding frictionlessness.
- **Property allowlist per event** enforced by the Zod registry in `features/analytics/events.ts`. Unknown properties are dropped with a dev-mode warning logged to the server console.
- **Never sent:** raw field values, document content, chat message bodies, email addresses (user is identified by UUID only), API keys, full tool outputs. Only IDs, slugs, counts, durations, status enums, and event-specific categorical properties.
- **DNT honored.** If `navigator.doNotTrack === '1'`, the PostHog client initializes with `opt_out_capturing_by_default: true`.
### 7. Rollout & deprecation
Delivered as one PR into `dev`. Sequencing:
1. **Registry & helpers first.** `features/analytics/events.ts` (Zod registry), `features/analytics/identify.ts` (identify/group helpers), refactor `recordAnalyticsEvent()` to PostHog-only + Zod validation + `after()`-compatible flush.
2. **Selector grooming.** Add `data-testid` to ~50 top-of-funnel interactive elements (sidebar, entity list actions, chat composer, tool CTAs, task actions). Turn on the ESLint rule.
3. **Per-module instrumentation.** One commit per feature module: entities, responses, chat, tools, tasks, agents, auth. Each commit wires the relevant v1 events at their server entry points.
4. **E2E assertion helper.** `e2e/helpers/posthog.ts` intercepts `/ingest/e/` POSTs and exposes `expectEvent()` for smoke-test coverage.
5. **Deprecate `analytics_events`.** Add `@deprecated` JSDoc on any remaining references, mark the table deprecated in `content/docs/data-model.mdx`. Drop table in a separate PR after 1 week of PostHog receipt confirmation in production.
### 8. Observability contract
The PostHog project for Amble is the canonical product-analytics sink. The following are guaranteed by this spec and must hold after any future change:
- Every event in the v1 list is receivable from production
- Every event has a Zod schema and a unit test asserting its shape
- Every pageview carries super-properties for `tenant_slug`, `tenant_id`, `user_role`, `is_admin`
- Every event from a logged-in user carries `distinctId = user.id` and `groups.tenant = tenant_id`
- No event payload contains an email address, a raw field value, a chat body, or an API key
## Trade-offs
- **PostHog-only vs dual-write.** We're removing the DB sink. Reversal path is cheap (reinstate the DB path in `recordAnalyticsEvent()`), but we lose today's ability to SQL-query raw events. Acceptable because nothing queries `analytics_events` today; future tenant-facing dashboards (spec #2/#3) will either pull from PostHog's Export API into materialized views or hook directly into domain tables (`activity_log`, `session_events`, `entity_responses`) which already carry the behavioral signal.
- **Group Analytics is a paid PostHog add-on.** Worth the cost for B2B — tenant-level funnels/cohorts are table stakes for our clients. Code degrades gracefully if not enabled.
- **Cookieless excludes session replay.** Intentional for v1. When a PE/consulting client asks for replay, we'll add a consent banner for logged-in users as a follow-up — we already have the auth context to gate it.
- **Zod registry adds boilerplate per event.** ~8 lines per new event. Payoff: refactor-safe renames, PII allowlist enforcement, test schemas for free.
- **Server-first emission couples analytics to server action latency.** `after()` and `shutdown()` mitigate but don't eliminate. Critical events (`entity.created`) will cost ~5–20ms of extra flush time. Acceptable because the alternative is dropped events in serverless.
## Acceptance Criteria
1. `recordAnalyticsEvent()` is PostHog-only (no DB write) and validates payloads against a Zod event registry
2. `features/analytics/events.ts` defines all ~30 v1 events with Zod schemas
3. Every v1 event has a unit test in `features/analytics/events.test.ts` asserting its schema
4. Every v1 event is emitted from at least one server call site (except the documented client-only events) — verified by grep in the PR description
5. Logged-in user events carry `distinctId = user.id` and `groups.tenant = tenant_id`
6. Embedded tool events carry `distinctId = embed:{share_token}` and `groups.{tenant, tool}`
7. `data-testid` is present on every interactive shadcn primitive in the top-of-funnel flows (sidebar, entity list, chat composer, tool CTAs, task actions) — ESLint rule enforced
8. `e2e/helpers/posthog.ts` intercepts ingestion POSTs and exposes `expectEvent()`; at least one e2e test uses it to assert `entity.created` and `response.submitted`
9. `analytics_events` table is marked `@deprecated` in code and in `content/docs/data-model.mdx`; no new writes land in it
10. `content/docs/features/analytics-cost.mdx` is updated with the new architecture, event taxonomy table, identification strategy, and PII policy
11. `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` all pass
12. PostHog production project shows events from all v1 names within 24 hours of merge
## Files
### New
- `features/analytics/events.ts` — Zod registry of all v1 events
- `features/analytics/events.test.ts` — schema assertions
- `features/analytics/identify.ts` — identify/group helper wrappers
- `e2e/helpers/posthog.ts` — E2E ingestion interception + `expectEvent()`
- `supabase/migrations/YYYYMMDD_deprecate_analytics_events.sql` — deprecation comment only; drop in follow-up PR
### Modified
- `features/analytics/record.ts` — PostHog-only, Zod validation, `after()`-compatible flush
- `features/analytics/record.test.ts` — drop DB-path tests, add group/identify coverage
- `lib/analytics/posthog-server.ts` — expose `shutdown()` + `flushImmediate()` helpers
- `lib/analytics/posthog-provider.tsx` — register super-properties at auth-ready, honor DNT, call `identify()` on auth state change
- `features/entities/server/actions.ts` — emit `entity.created`, `entity.updated`, `entity.deleted`; also write `user_recent_views` for `entity.viewed`
- `features/responses/server/actions.ts` — emit `response.submitted`, `response.promoted`
- `features/chat/server/actions.ts` — emit `chat.started`; client emits `chat.message_sent`, `chat.agent_selected`
- `features/tools/server/actions.ts` — emit `tool.invoked`, `tool.completed`, `tool.failed`, `tool.session_shared`, `tool.embed_submitted`; client emits `tool.embed_opened`
- `features/tasks/server/trigger.ts` — emit `task.created`, `task.session_started`, `task.session_completed`, `task.session_failed`
- `features/sessions/server/complete-human-session.ts` — emit `task.human_completed`
- `features/tenant/server/actions.ts` — emit `auth.tenant_created`, `auth.tenant_switched`
- `app/auth/**` — emit `auth.signed_up`, `auth.signed_out`; client emits `auth.signed_in`
- `components/app-shell/app-shell.tsx` and key interactive shadcn usages — add `data-testid` attributes
- `CLAUDE.md` — document analytics call-site contract, data-testid naming convention
- `content/docs/features/analytics-cost.mdx` — update with new architecture, taxonomy, identification, PII policy
### Deprecated (referenced, not changed)
- `analytics_events` table — marked deprecated, dropped in follow-up PR