Convex

Convex runs your backend as short-lived isolates, so the usual posthog-node shutdown flow doesn't apply. This doc covers what does: capturing events and flags from Convex functions, matching frontend and backend events to the same person, tracing LLM calls, and syncing Convex tables into PostHog.

You want toUse
Capture events and evaluate feature flags from inside Convex functions@posthog/convex component
Stitch frontend and backend events into one user timelineFrontend + backend stitching
Trace LLM calls or @convex-dev/agent runsAI observability
Query your Convex tables alongside PostHog event dataConvex data warehouse source
Forward Convex logs to PostHog LogsConvex dashboard log streams (no code)
Forward Convex exceptions to PostHog Error TrackingConvex dashboard exception reporting (no code)

Convex dashboard PostHog Logs configuration form
Convex dashboard PostHog Error Tracking configuration form

Get your PostHog project token

Every integration on this page needs the same two values:

  • Project token - Starts with phc_. Found in PostHog under Settings > Project > General, labeled Project token.
  • Host - https://us.i.posthog.com for PostHog US Cloud, https://eu.i.posthog.com for EU Cloud, or your own URL for self-hosted PostHog.

Capture events and flags from your Convex code

The @posthog/convex component lets you capture events, identify users, evaluate feature flags, and forward exceptions directly from your Convex queries, mutations, and actions.

Requires Convex 1.39 or newer.

1. Install the component

npm install @posthog/convex

Register it in convex/convex.config.ts and forward your credentials from the app down to the component:

TypeScript
// convex/convex.config.ts
import { defineApp } from "convex/server"
import { v } from "convex/values"
import posthog from "@posthog/convex/convex.config.js"
const app = defineApp({
env: {
POSTHOG_PROJECT_TOKEN: v.string(),
POSTHOG_HOST: v.optional(v.string()),
POSTHOG_PERSONAL_API_KEY: v.optional(v.string()),
POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS: v.optional(v.string()),
},
})
app.use(posthog, {
env: {
POSTHOG_PROJECT_TOKEN: app.env.POSTHOG_PROJECT_TOKEN,
POSTHOG_HOST: app.env.POSTHOG_HOST,
POSTHOG_PERSONAL_API_KEY: app.env.POSTHOG_PERSONAL_API_KEY,
POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS: app.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS,
},
})
export default app

Environment variables:

Env varRequiredWhat it does
POSTHOG_PROJECT_TOKENYesYour project token (phc_). Sends events and evaluates flags remotely.
POSTHOG_HOSTNoDefaults to https://us.i.posthog.com. Use https://eu.i.posthog.com for EU Cloud or your self-hosted URL.
POSTHOG_PERSONAL_API_KEYNoA feature flags secure API key (phs_, recommended) or personal API key (phx_). Setting it enables local flag evaluation and starts the refresh cron.
POSTHOG_FLAGS_POLLING_INTERVAL_SECONDSNoCron interval for refreshing flag definitions. Defaults to 60. Raise it on free-tier dev deployments to cut function-call usage.

2. Set your PostHog credentials

sh
npx convex env set POSTHOG_PROJECT_TOKEN <ph_project_token>
npx convex env set POSTHOG_HOST https://us.i.posthog.com

For local feature flag evaluation (covered in step 5), also set a feature flags secure API key:

sh
npx convex env set POSTHOG_PERSONAL_API_KEY phs_your_feature_flags_secure_api_key
Setting the personal API key starts the cron

POSTHOG_PERSONAL_API_KEY is checked at deploy time, so after setting it in production you need to redeploy for the cron to register. To skip the cron entirely (e.g. on a free-tier dev deployment), leave the var unset.

3. Initialize the client

Create convex/posthog.ts. Other backend functions import the posthog instance from here. Credentials live on the component, so the constructor just needs the component reference:

TypeScript
// convex/posthog.ts
import { PostHog } from "@posthog/convex"
import { components } from "./_generated/api"
export const posthog = new PostHog(components.posthog)

4. Capture events and identify users

capture and identify work in mutations and actions. They schedule the PostHog API call via ctx.scheduler.runAfter, so they return immediately without blocking the caller.

TypeScript
// convex/users.ts
import { mutation } from "./_generated/server"
import { v } from "convex/values"
import { getAuthUserId } from "@convex-dev/auth/server"
import { posthog } from "./posthog"
export const updateProfile = mutation({
args: { plan: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (userId === null) throw new Error("Not authenticated")
await ctx.db.patch(userId, { plan: args.plan })
await posthog.identify(ctx, {
distinctId: userId,
properties: { plan: args.plan },
})
await posthog.capture(ctx, {
distinctId: userId,
event: "plan_changed",
properties: { plan: args.plan },
})
},
})

Use the same Convex user ID as the distinctId everywhere so PostHog can stitch events from every source onto one person. See stitching frontend and backend below for the frontend setup.

5. Evaluate feature flags

@posthog/convex supports two flag evaluation paths:

  • Local (getFeatureFlag, isFeatureEnabled, getFeatureFlagPayload, getAllFlags) runs against flag definitions cached on your Convex deployment. Works in queries, mutations, and actions. No per-call network request, and a query re-runs automatically when cached definitions refresh. Requires POSTHOG_PERSONAL_API_KEY. Use this when you can.
  • Remote (evaluateFlag, evaluateFlagPayload, evaluateAllFlags) hits PostHog's /flags endpoint on every call. Action-only. Handles every flag, including the ones local can't, and needs no personal API key.

Local evaluation runs automatically once POSTHOG_PERSONAL_API_KEY is set. The component registers its own cron at deploy time and refreshes flag definitions every minute, so you don't write your own. Tune the cadence with POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS. If you call a local-eval method without the key configured, the client throws and points you at the remote evaluate* methods.

Read a flag from a query:

TypeScript
// convex/pricing.ts
import { query } from "./_generated/server"
import { v } from "convex/values"
import { posthog } from "./posthog"
export const getDiscount = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
const variant = await posthog.getFeatureFlag(ctx, {
key: "discount-campaign",
distinctId: args.userId,
})
if (variant === "variant-a") return { discount: 20 }
if (variant === "variant-b") return { discount: 10 }
return { discount: 0 }
},
})

A React client subscribed to this query re-renders the next time the cron refreshes flag definitions, up to the configured interval (one minute by default).

To force a refresh between cron ticks (for example, right after creating a flag in development), call posthog.reloadFeatureFlags(ctx) from an action.

If you're running an experiment against a locally-evaluated flag, fire an exposure event yourself from a mutation or action, since local eval can't schedule capture from inside a query:

TypeScript
await posthog.capture(ctx, {
event: "$feature_flag_called",
distinctId: userId,
properties: {
$feature_flag: "discount-campaign",
$feature_flag_response: variant,
locally_evaluated: true,
},
})
When local eval can't reach a verdict

A handful of flag types can't be resolved locally (experience continuity flags, static cohorts, flags whose targeting depends on person properties you don't pass in). For those, getFeatureFlag returns undefined. Call evaluateFlag from an action to hit /flags directly. See the full list of limitations.

6. Capture exceptions with custom properties

If you want every uncaught exception forwarded to PostHog automatically without wrapping each call site, configure Convex's first-party PostHog Error Tracking destination in the Convex dashboard. Use captureException when you want to attach custom properties at a specific call site:

TypeScript
// convex/billing.ts
import { action } from "./_generated/server"
import { v } from "convex/values"
import { posthog } from "./posthog"
export const chargeCard = action({
args: { userId: v.string(), amount: v.number() },
handler: async (ctx, args) => {
try {
await callStripe(args.amount)
} catch (error) {
await posthog.captureException(ctx, {
error,
distinctId: args.userId,
additionalProperties: { amount: args.amount },
})
throw error
}
},
})

Stitch frontend and backend events together

PostHog stitches frontend and backend events onto the same person when both sides use the same distinctId string.

Install posthog-js and @posthog/react in your frontend. The example below uses React, but any JavaScript framework PostHog supports works the same way: call posthog.identify() with the same string the backend uses as distinctId.

npm install posthog-js @posthog/react

Initialize PostHog at the root of your app, the same way you would in any React project:

TSX
// src/main.tsx
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import posthog from "posthog-js"
import { PostHogProvider } from "@posthog/react"
import App from "./App"
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
})
createRoot(document.getElementById("root")!).render(
<StrictMode>
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</StrictMode>
)

Then, right after a user signs in via Convex Auth (or Clerk, Auth0, etc.), call identify on the frontend with the same Convex user ID your backend uses. The currentUser query referenced below is whatever Convex query exposes the authenticated user to your client (the Convex Auth docs cover defining it):

TSX
// src/components/AuthSync.tsx
import { useEffect } from "react"
import { useQuery } from "convex/react"
import { usePostHog } from "@posthog/react"
import { api } from "../../convex/_generated/api"
export function AuthSync() {
const posthog = usePostHog()
const me = useQuery(api.users.currentUser) // your own query
useEffect(() => {
if (me?._id) {
posthog.identify(me._id, { email: me.email, name: me.name })
}
}, [me?._id, posthog])
return null
}

Mount <AuthSync /> once near the root of your app, inside the <PostHogProvider> and below your Convex <Authenticated> boundary if you have one. The me?._id guard skips the identify call until authentication completes.

Session replays, server-side mutations, exceptions, and logs all land on one timeline for that user.

Session replays

Remember you'll also need to configure the environment variables on the frontend to point to your PostHog instance.

sh
npx convex env set VITE_PUBLIC_POSTHOG_PROJECT_TOKEN <ph_project_token>
npx convex env set VITE_PUBLIC_POSTHOG_HOST https://us.i.posthog.com

Trace LLM calls and Convex Agent

If your app calls LLM providers from Convex actions, or uses @convex-dev/agent, forward the traces to PostHog AI observability to capture generations, traces, spans, token counts, latency, and cost per model call.

Setup uses @posthog/ai and OpenTelemetry rather than the @posthog/convex component. The full installation steps live on the Convex AI observability installation page, covering both vanilla Vercel AI SDK or OpenAI calls and experimental_telemetry on @convex-dev/agent.

Sync Convex tables into PostHog

If you want to query Convex data alongside event data, use the Convex data warehouse source to stream your tables into PostHog's warehouse. You can then query them with SQL, join them to event data, and build insights over the union.

Requires a Convex Professional plan for Convex's streaming export API.

Further reading

Community questions

Was this page useful?

Questions about this page? or post a community question.