Leave Localhost logoLeave LocalhostDocs
Observability

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 facade

Application 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

  1. Create a PostHog account and project.

  2. Copy the project API key (starts with phc_) and note your host (https://us.i.posthog.com or https://eu.i.posthog.com).

  3. 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
  4. Configure the Convex backend. POSTHOG_COMPONENT_ENABLED is a deployment-time switch: set it in the Convex deployment and in the environment that runs convex dev or convex deploy.

    ANALYTICS_ENABLED=true
    POSTHOG_COMPONENT_ENABLED=true
    POSTHOG_PROJECT_TOKEN=phc_...
    # Optional
    POSTHOG_HOST=https://us.i.posthog.com

    Run an enabled production deployment with both deployment-time values set:

    POSTHOG_COMPONENT_ENABLED=true POSTHOG_PROJECT_TOKEN=phc_... \
      bun --cwd packages/backend convex deploy

    When POSTHOG_COMPONENT_ENABLED is 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.

EventEmitted fromKey properties
user_signed_upconvex/auth.ts user trigger
organization_createdconvex/organizations/mutations.tsorganizationId, organizationKind
organization_invitation_sentconvex/members.tsorganizationId, role
onboarding_completedconvex/users.ts (updateUsername)organizationId?
subscription_changedconvex/billing/grants.tsorganizationId, billingProvider, planKey, status, changeType
paid_feature_usedproduct code (seam)organizationId, feature, planKey?
webhook_processed / webhook_processing_failedreserved seam (see note)provider, eventType, outcome / failureCode

Note: paid_feature_used and the webhook_* events are defined in the catalog but not yet wired to a single chokepoint. subscription_changed already 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.

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.

On this page