Skip to content
Operate And Ship

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 read

On This Page

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.

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

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 helper for PostHog Logs (owned by Monitoring)

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.

SurfaceEventProperties
Authuser_signed_upmethod (email | github | google | passkey)
Authuser_logged_inmethod (email | github | google | passkey)
Authuser_logged_out(none)
Orgorganization_createdorganization_name
Orgorganization_member_invitedrole
Billingsubscription_startedplan, interval (monthly | yearly)
Billingsubscription_cancelledplan, optional reason
Checkoutcheckout_startedplan, interval (monthly | yearly)
Checkoutcheckout_completedplan, amount_cents
Billing portalbilling_portal_openedsource (manage_billing | change_plan | downgrade)
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. 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:

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

Also useful: Billing and Setup.

Was this page helpful?

On this page