Analytics
PostHog product analytics, web analytics, and session replay. Typed event catalog, identify-on-login, org groups, and a same-origin /ingest proxy. Enabled when NEXT_PUBLIC_POSTHOG_KEY is set; host vars default to US Cloud. Error tracking and logs live on Monitoring.
Last updated on
5 min readOn This Page
How Telemetry Flows
This page covers product events: track / trackServer, identification, and org groups. The diagram also shows error capture, OTLP logs, and source maps. Those belong to Monitoring. Both pages share the same PostHog project and /ingest proxy.
Browser SDK calls go through same-origin /ingest so ad blockers and the CSP stay out of the way. Server calls use posthog-node against the PostHog host directly. Proxy setup details live on Monitoring.
Package Layout
Each subpath is a separate export: @syntaxkit/analytics/client, /server, /events, or /logger. Import only what you need. The browser bundle never sees the Node SDK; the server never imports posthog-js.
The Event Catalog
Sixteen events ship typed and grouped 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 (email | github | google | passkey) |
| Auth | user_logged_in | method (email | github | google | passkey) |
| Auth | user_logged_out | (none) |
| Org | organization_created | organization_name |
| Org | organization_member_invited | role |
| Billing | subscription_started | plan, interval (monthly | yearly) |
| Billing | subscription_cancelled | plan, optional reason |
| Checkout | checkout_started | plan, interval (monthly | yearly) |
| Checkout | checkout_completed | plan, amount_cents |
| Billing portal | billing_portal_opened | source (manage_billing | change_plan | downgrade) |
| 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. Each event name locks 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 is not on user_signed_up.
Tracking Events From The Client
Import track from @syntaxkit/analytics/client and fire after the action succeeds:
"use client";
import { track } from "@syntaxkit/analytics/client";
await signIn.email({ email, password });
track("user_logged_in", { method: "email" });Client track is fully typed against the catalog. The same module also exports identity and consent helpers:
| Helper | What it does |
|---|---|
identify(userId, traits) | Attach the browser session to 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 a cookie-banner integration. |
Tracking Events From The Server
Use trackServer from @syntaxkit/analytics/server in oRPC handlers, Better Auth callbacks, and Stripe webhooks. The PostHog Node client uses flushAt: 1 and flushInterval: 0, so each capture flushes before the promise resolves:
import { trackServer } from "@syntaxkit/analytics/server";
await trackServer(userId, "subscription_started", {
plan: "pro",
interval: "monthly",
});Known event names still get autocomplete. Properties are Record<string, unknown> on the server so one-off webhook events can compile without a catalog entry.
Always await trackServer(...) in serverless (Vercel, Lambda, Fly). Without the await, the runtime can exit before the flush finishes and the event never leaves.
Identification And Org Groups
apps/web/components/analytics/identify-user.tsx mounts in the root layout. It identifies the user on login, resets on logout, and sets the active organization 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.
}setGroup unlocks org-level analysis: funnels per org, retention by plan, conversion by member count. PostHog can attach group properties to historical events once the group exists, so wiring it early pays off later.
Web Analytics And Session Replay
You get page views, clicks, and form interactions without tagging each element. The kit inits PostHog with defaults: "2026-01-30". Session replay is on in production and off in development:
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: process.env.NEXT_PUBLIC_POSTHOG_UI_HOST || DEFAULT_POSTHOG_UI_HOST,
defaults: "2026-01-30",
disable_session_recording: process.env.NODE_ENV === "development",
});Replays link to errors from captureError, so a failed signup gives you the stack trace and a short video of what the user saw. Error capture itself is documented on Monitoring.
Adding A New Event
Add it to the typed catalog
Open packages/analytics/src/events.ts and append a member to the AnalyticsEvent union:
| { name: "your_event"; properties: { foo: string; count: number } }TypeScript picks up the new name in EventName and the properties in EventProperties<"your_event">.
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 });On the client, mismatched or missing required properties fail to compile.
Build the insight in PostHog
PostHog discovers new event names soon after the first capture. Build a funnel, trend, or retention insight on your_event. No schema migration in the kit.
Graceful Degradation
Analytics gates on NEXT_PUBLIC_POSTHOG_KEY alone. NEXT_PUBLIC_POSTHOG_HOST and NEXT_PUBLIC_POSTHOG_UI_HOST default to US Cloud (https://us.i.posthog.com and https://us.posthog.com). Every helper in @syntaxkit/analytics/client and /server returns early when the key is unset. A fresh clone runs with zero capture: no warnings, no broken UI. Run pnpm setup:doctor to see whether analytics is active.
Leave the key unset and the integration stays off. To swap providers, replace the four files in packages/analytics/src/ and keep the same public API. The rest of the app stays the same.
Where To Go Next
Webhooks And Async Workflows
One inbound Stripe webhook with signature verification, two-layer idempotency, and stale-claim recovery in Postgres. No background queue ships; after(), OutboundEffect, and common providers to plug in are below.
Monitoring
Error tracking, OpenTelemetry logs, and source maps on the same PostHog project as Analytics. Enabled when NEXT_PUBLIC_POSTHOG_KEY is set; host vars default to US Cloud. Health probes for uptime monitors.
