Analytics
PostHog wiring and the metrics that matter.
Last updated on
4 min readPostHog is the single backbone for telemetry: product analytics, web analytics, session replay, plus error tracking and logs over on Monitoring. The kit ships a typed event catalog, automatic identification on login, org-level grouping, and a same-origin reverse proxy so ad blockers and the strict CSP don't get in the way. Every call gates on the NEXT_PUBLIC_POSTHOG_* env vars: a fresh clone with none set ships zero analytics to the browser and captures nothing on the server.
How telemetry flows
Browser and server calls funnel into one PostHog project through a reverse proxy.
The event catalog
Sixteen pre-typed events, organized by surface, enforced at compile time.
Track events
Fire typed events from client components and server contexts.
Add an event
Append to the typed catalog, fire it, build the insight in PostHog.
How Telemetry Flows
The analytics half (browser and server track* calls, identification, group properties) is everything on this page. The rest of the diagram (the onRequestError hook, OTLP logs, source maps) belongs to Monitoring, which shares the same backbone.
The diagram source lives at apps/docs/diagrams/posthog-topology.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.
Package Layout
Each subpath is exported separately, so consumers import @syntaxkit/analytics/client, /server, /events, or /logger without pulling in the others. The browser bundle never sees the Node SDK; the server never imports posthog-js.
The Event Catalog
Sixteen events ship pre-typed, organized by surface. The catalog is a discriminated union, so track(name, properties) rejects mismatched property shapes at compile time.
| Surface | Event | Properties |
|---|---|---|
| Auth | user_signed_up | method |
| Auth | user_logged_in | method |
| Auth | user_logged_out | (none) |
| Org | organization_created | organization_name |
| Org | organization_member_invited | role |
| Billing | subscription_started | plan, interval |
| Billing | subscription_cancelled | plan, optional reason |
| Checkout | checkout_started | plan, interval |
| Checkout | checkout_completed | plan, amount_cents |
| Billing portal | billing_portal_opened | source |
| Storage | file_uploaded | file_type, size_bytes |
| AI | ai_chat_message_sent | message_length |
| Settings | settings_updated | setting |
| Security | two_factor_enabled | (none) |
| Security | two_factor_disabled | (none) |
| Security | passkey_registered | (none) |
How the typed catalog enforces correctness
The catalog is a discriminated union keyed on name, so each event name is locked to its own property shape:
export type AnalyticsEvent =
| { name: "user_signed_up"; properties: { method: AuthMethod } }
| {
name: "subscription_started";
properties: { plan: string; interval: "monthly" | "yearly" };
}
// ... 14 more
export type EventName = AnalyticsEvent["name"];
export type EventProperties<N extends EventName> = Extract<
AnalyticsEvent,
{ name: N }
>["properties"];track("user_signed_up", { method: "email" }) works; track("user_signed_up", { plan: "pro" }) is a compile-time error because plan isn't part of the user_signed_up shape.
Tracking Events From The Client
Import from @syntaxkit/analytics/client and call track:
"use client";
import { track } from "@syntaxkit/analytics/client";
await signIn.email({ email, password });
track("user_logged_in", { method: "email" });The same SDK exports a few identity and consent helpers:
| Helper | What it does |
|---|---|
identify(userId, traits) | Associate the current browser with a user after login. |
setPersonProperties(props, propsSetOnce) | Apply $set and $set_once person-property updates. |
reset() | Clear identity on logout. |
optOut / optIn / hasOptedOut | Wrap PostHog's consent API for cookie-banner integration. |
Tracking Events From The Server
Server contexts (oRPC handlers, Better Auth callbacks, Stripe webhooks) use trackServer from @syntaxkit/analytics/server. The PostHog Node singleton runs with flushAt: 1, flushInterval: 0, so every capture flushes synchronously and the helper is await-able:
import { trackServer } from "@syntaxkit/analytics/server";
await trackServer(userId, "subscription_started", {
plan: "pro",
interval: "monthly",
});Always await trackServer(...) in serverless contexts (Vercel, Lambda, Fly machines). The synchronous flush only completes if the function waits for it; otherwise the runtime can terminate before the event leaves the box.
Identification And Org Groups
apps/web/components/analytics/identify-user.tsx is a tiny client component mounted in the root layout. It identifies the user on login, resets on logout, and sets the active org as a PostHog group:
"use client";
import { useEffect, useRef } from "react";
import { useSession } from "@/lib/auth-client";
import { identify, reset, setGroup } from "@syntaxkit/analytics/client";
export function IdentifyUser() {
const { data: session } = useSession();
// ...
useEffect(() => {
if (session?.user) {
identify(session.user.id, {
email: session.user.email,
name: session.user.name,
});
} else {
reset();
}
}, [session]);
// setGroup("organization", activeOrgId, { name, member_count, created_at })
// runs once the active org loads.
}The setGroup call powers org-level analysis: per-org funnels, retention by plan, conversion by member count. It's worth doing even before you have a question to answer: PostHog backfills group data into historical events as soon as the group is set.
Web Analytics And Session Replay
PostHog's auto-capture covers page views, clicks, and form interactions out of the box via the kit's defaults: "2026-01-30" config, no tag-by-tag wiring. Session replay is enabled in production and disabled in dev:
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: process.env.NEXT_PUBLIC_POSTHOG_UI_HOST,
defaults: "2026-01-30",
disable_session_recording: process.env.NODE_ENV === "development",
});Replays auto-link to errors captured via captureError, so a failed signup gives you both the stack trace and a 30-second video of what the user saw and clicked.
Adding A New Event
Add it to the typed catalog
Open packages/analytics/src/events.ts and append a new entry to the AnalyticsEvent union:
| { name: "your_event"; properties: { foo: string; count: number } }TypeScript propagates the new name into EventName and the properties into EventProperties<"your_event"> automatically.
Fire it from the client or the server
Client (@syntaxkit/analytics/client):
track("your_event", { foo: "bar", count: 1 });Server (@syntaxkit/analytics/server):
await trackServer(userId, "your_event", { foo: "bar", count: 1 });Mismatched or missing required properties fail to compile.
Build the insight in PostHog
PostHog auto-discovers new event names within minutes of the first capture. Build a funnel, trend, or retention insight keyed on your_event, with no schema migration on the kit side.
Graceful Degradation
Every function in @syntaxkit/analytics/client and /server returns early unless NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST, and NEXT_PUBLIC_POSTHOG_UI_HOST are all set. A fresh clone runs without analytics: no warnings, no broken UI. Run pnpm setup:doctor to confirm whether analytics is active in your env.
Leave the env vars unset and the integration disappears. To swap providers, replace the four files in packages/analytics/src/ and keep the same public API. The rest of the app doesn't change.
Where To Go Next
Monitoring
The error tracking, structured logs, and source map half of the same PostHog backbone.
Authentication
Where the user_signed_up, user_logged_in, two_factor_enabled, and passkey_registered events fire.
Billing
Where the subscription, checkout, and billing-portal events fire.
Setup
The full env-var matrix including the optional PostHog reverse proxy and source-map upload secrets.
