Analytics (PostHog)
Leave Localhost uses PostHog as its first-class product analytics provider. All product and backend code talks to a single provider-neutral boundary, @leavelocalhost/analytics; only that package and the Convex analytics facade import the PostHog SDKs.
Leave Localhost uses PostHog as its first-class product
analytics provider. All product and backend code talks to a single
provider-neutral boundary, @leavelocalhost/analytics; only that package and
the Convex analytics facade import the PostHog SDKs.
Analytics is optional and disabled by default. With no configuration the app runs normally: the browser SDK is never initialized, the Convex facade never schedules work, and no network request is made. See Disabled mode.
Analytics is not an audit log. Security- and compliance-relevant actions are recorded separately and authoritatively in Convex
audit_events(see Audit Log). Audit retention/access requirements do not apply to PostHog events, and audit metadata is never copied into analytics by default.
Architecture
packages/analytics/ # the only place the browser SDK is imported
src/events.ts # canonical, typed event catalog (pure TS)
src/provider.ts # AnalyticsProvider interface
src/noop.ts # no-op provider (disabled mode)
src/posthog/browser.ts # posthog-js adapter
src/client.tsx # AnalyticsRoot + useAnalytics (React surface)
packages/backend/convex/analytics/ # the only place @posthog/convex is imported
index.ts # typed, gated, non-blocking backend facadeApplication code uses only @leavelocalhost/analytics/* (frontend) or the
Convex facade in convex/analytics (backend). Direct posthog-js,
@posthog/react, or @posthog/convex imports outside those modules are not
allowed.
Setup
-
Create a PostHog account and project.
-
Copy the project API key (starts with
phc_) and note your host (https://us.i.posthog.comorhttps://eu.i.posthog.com). -
Configure the frontend apps (
apps/app/.env,apps/marketing/.env):NEXT_PUBLIC_ANALYTICS_ENABLED=true NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_... # Optional, defaults to https://us.i.posthog.com NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -
Configure the Convex backend.
POSTHOG_COMPONENT_ENABLEDis a deployment-time switch: set it in the Convex deployment and in the environment that runsconvex devorconvex deploy.ANALYTICS_ENABLED=true POSTHOG_COMPONENT_ENABLED=true POSTHOG_PROJECT_TOKEN=phc_... # Optional POSTHOG_HOST=https://us.i.posthog.comRun an enabled production deployment with both deployment-time values set:
POSTHOG_COMPONENT_ENABLED=true POSTHOG_PROJECT_TOKEN=phc_... \ bun --cwd packages/backend convex deployWhen
POSTHOG_COMPONENT_ENABLEDis absent, the PostHog component is not registered at all. Disabled deployments therefore require no PostHog credentials and do not schedule the component's feature-flag refresh cron.
Identity and groups
- Person distinct ID: the app
users._id. The browser (AnalyticsIdentity) and the backend facade both use it, so frontend and backend events join to the same person. - Person properties: stable, non-sensitive traits only (email, name).
- Group: PostHog group type
organization, keyed by the Better Auth organization id. Organization-scoped backend events set this group automatically.
Usage
Frontend
"use client";
import { useAnalytics } from "@leavelocalhost/analytics/client";
export function UpgradeButton() {
const analytics = useAnalytics();
return (
<button
onClick={() =>
analytics.track({
name: "paid_feature_used",
properties: {
organizationId,
feature: "export",
planKey: "pro",
},
})
}
>
Upgrade
</button>
);
}AnalyticsRoot (mounted once in each app's root layout) initializes PostHog,
owns the single page-view tracker, and provides the client. Outside an enabled
AnalyticsRoot, useAnalytics() returns the no-op provider, so call sites
never need a conditional.
Backend (Convex)
Import the typed helpers from the facade — never the component directly:
import { track } from "./analytics";
// inside a mutation/action handler, after the state change succeeds:
await track(ctx, "organization_created", {
distinctId: user._id,
organizationId: organization.id,
organizationKind: "team",
});Facade calls are no-ops when analytics is disabled, swallow their own errors (analytics can never roll back a mutation or change a webhook response), and schedule delivery via Convex so they return immediately.
Event catalog
Defined in packages/analytics/src/events.ts and shared (by type) with the
backend facade.
Pageviews are handled separately: the browser page tracker calls the
provider-neutral analytics.page({ path }) operation on every committed
navigation, which the PostHog adapter maps to PostHog's native $pageview
event (so PostHog's built-in web analytics work). It is intentionally not a
catalog track() event.
| Event | Emitted from | Key properties |
|---|---|---|
user_signed_up | convex/auth.ts user trigger | — |
organization_created | convex/organizations/mutations.ts | organizationId, organizationKind |
organization_invitation_sent | convex/members.ts | organizationId, role |
onboarding_completed | convex/users.ts (updateUsername) | organizationId? |
subscription_changed | convex/billing/grants.ts | organizationId, billingProvider, planKey, status, changeType |
paid_feature_used | product code (seam) | organizationId, feature, planKey? |
webhook_processed / webhook_processing_failed | reserved seam (see note) | provider, eventType, outcome / failureCode |
Note:
paid_feature_usedand thewebhook_*events are defined in the catalog but not yet wired to a single chokepoint.subscription_changedalready captures the meaningful billing outcome at the grant-sync transaction; add the webhook delivery events at the top-level handlers if you need delivery-level observability.
Recommended SaaS events to grow into
Acquisition (user_signed_up), activation (onboarding_completed), workspace
collaboration (organization_created, organization_invitation_sent),
conversion and billing lifecycle (subscription_changed), and paid-feature use
(paid_feature_used). Keep the catalog small and deliberate; never send raw
webhook payloads, payment data, secrets, or audit metadata.
Disabled mode
Analytics is disabled when the relevant ANALYTICS_ENABLED flag
(NEXT_PUBLIC_ANALYTICS_ENABLED in the browser, ANALYTICS_ENABLED in Convex)
is not exactly "true", or when the project token is absent. In disabled mode:
- the browser PostHog SDK is not initialized and makes no request;
- the Convex facade schedules no capture/identify/group work and makes no PostHog request;
- no PostHog credential is required in any app, web, or Convex env file;
- every analytics call is a safe no-op with no changed product behavior;
- feature-flag reads return their documented safe default.
Feature flags
Feature flags are not wired up by default. The provider interface exposes
getFeatureFlag, and @posthog/convex supports local and remote evaluation,
but flags are intentionally left inactive. To adopt them: add
POSTHOG_PERSONAL_API_KEY and the polling configuration to the enabled
component registration in convex.config.ts (this activates the
local-evaluation refresh cron), default every flag to the safe
implementation, never gate authorization/data-isolation/billing on a remote
flag, expose typed internal hooks (not PostHog hooks), and remove flags once a
rollout completes.
Optional advanced capabilities
These are dashboard/setup decisions, separate from this code:
- Convex log forwarding to PostHog via OTLP (Convex Pro).
- Exception reporting via Convex's PostHog Error Tracking destination (Convex Pro) — coordinate with the existing Sentry integration before enabling both.
- AI observability (
@posthog/ai) if the backend later makes LLM calls. - Convex table sync to PostHog's data warehouse (Convex Professional) — use a minimal, privacy-reviewed allow-list.
Replacing the provider
Implement AnalyticsProvider (and a Convex equivalent) for the new SDK behind
packages/analytics and convex/analytics. Because application code depends
only on the neutral boundary and the typed event catalog, no product or backend
business code needs to change.
Sentry
Leave Localhost is pre-configured with Sentry for error tracking in the Next.js frontend (apps/app).
Super Admin Panel
A small, read-first platform admin area at /admin, for operators/owners of the product — not for workspace members. It is intentionally minimal: visibility first, plus one guarded support action (suspend/reactivate a workspace).