Analytics
PostHog wiring and the metrics that matter.
PostHog 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 |
| 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. The site banner calls these after the visitor chooses. |
Consent banner
The banner fails closed: posthog-js does not download until the visitor accepts, unless the visit is known not to need prior consent. On every page response the proxy sets syntaxkit_consent_required to 1 (ask) or 0 (no prior consent needed). The browser treats a missing or unreadable cookie as 1, so a visitor who blocks cookies never loads analytics.
SYNTAXKIT_COOKIE_CONSENT chooses the policy. It is read at runtime, and pnpm setup:doctor prints the active mode.
| Mode | Who sees the banner |
|---|---|
geo (default) | Everyone, except visitors from the countries in CONSENT_NOT_REQUIRED_COUNTRIES (apps/web/lib/cookie-consent-policy.ts): the United States and its territories, Australia, New Zealand, and Switzerland. An unknown country, Cloudflare's XX and T1, and a malformed header all see the banner. |
always | Every visitor. |
never | No one; PostHog loads for every visitor. Use it only with another lawful basis or your own consent tool. The doctor warns in production. |
The geo list names countries where prior consent is not needed, not countries where it is. A country missing from the list costs a banner instead of skipping one, so every EEA member (including Iceland and Liechtenstein) and every EU territory with its own country code (Åland, Guadeloupe, Martinique, Réunion, French Guiana, Mayotte, and others) is asked without being listed. Switzerland is on the list because the revised FADP requires information and a way to refuse, not prior consent; the "Cookie settings" link is that way to refuse. Canada is not, because Quebec's Law 25 can require tracking to be off by default. The list is a product default, not legal advice: edit it with your own counsel.
The country comes from x-vercel-ip-country (only when the VERCEL environment variable is set, because elsewhere any client could send it), then cf-ipcountry, then x-syntaxkit-country from a reverse proxy you run, then SYNTAXKIT_GEO_COUNTRY. A self-hosted deploy without geo headers asks every visitor, unless SYNTAXKIT_GEO_COUNTRY names one country for all of them. If your origin is reachable without your proxy, a visitor can send these headers; under the fail-closed policy that only hides the sender's own banner.
Accept writes syntaxkit_consent=accepted and calls optIn(). Reject writes syntaxkit_consent=rejected and calls optOut(). The "Cookie settings" link in the marketing footer and under the auth card reopens the banner for every visitor, so anyone can change or withdraw a choice later. Withdrawing calls opt_out_capturing() on a loaded SDK, which also deletes PostHog's cookie and storage (the SDK is initialized with opt_out_persistence_by_default). If the SDK has not loaded on that page, the facade removes PostHog's stored cookie and storage itself. A rejected choice blocks analytics even where consent is not required. Without NEXT_PUBLIC_POSTHOG_KEY, neither the banner nor the link renders.
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.