Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Operate And Ship

Analytics

PostHog wiring and the metrics that matter.

Last updated on

4 min read

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 flow into PostHog. Browser calls go through the /ingest reverse proxy. Server calls go directly. The onRequestError hook auto-captures, OTLP logs ship to PostHog Logs, and source maps upload at build time.

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

client.tsposthog-js wrappers: track, identify, setGroup, captureError, opt-in/out
server.tsposthog-node singleton with flushAt: 1; trackServer, captureServerError, getPostHogContext
events.tsDiscriminated union of every typed event the app fires
logger.tsOpenTelemetry log.info/warn/error helper that ships to PostHog Logs

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.

SurfaceEventProperties
Authuser_signed_upmethod
Authuser_logged_inmethod
Authuser_logged_out(none)
Orgorganization_createdorganization_name
Orgorganization_member_invitedrole
Billingsubscription_startedplan, interval
Billingsubscription_cancelledplan, optional reason
Checkoutcheckout_startedplan, interval
Checkoutcheckout_completedplan, amount_cents
Billing portalbilling_portal_openedsource
Storagefile_uploadedfile_type, size_bytes
AIai_chat_message_sentmessage_length
Settingssettings_updatedsetting
Securitytwo_factor_enabled(none)
Securitytwo_factor_disabled(none)
Securitypasskey_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:

HelperWhat 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 / hasOptedOutWrap 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

Was this page helpful?

On this page