Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .changeset/posthog-explicit-local-evaluation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
"@flags-sdk/posthog": major
---

Modernize the PostHog adapter. This release is breaking in five ways:

- **Environment variables were renamed.** `NEXT_PUBLIC_POSTHOG_KEY` → `POSTHOG_PROJECT_API_KEY` and `NEXT_PUBLIC_POSTHOG_HOST` → `POSTHOG_HOST`.
- **Local vs. remote evaluation is now an explicit choice.** The default adapter evaluates remotely unless you set `POSTHOG_SECRET_KEY`.
- **The three adapter methods collapsed into a single callable adapter.** `isFeatureEnabled()` / `featureFlagValue()` / `featureFlagPayload()` become `postHogAdapter` and `postHogAdapter.payload`.
- **A flag's `key` is used as the PostHog flag key verbatim.** The old "read until the first `.`" convention is gone.
- **The per-call `sendFeatureFlagEvents` option and the `featureFlagPayload` `getValue` mapper are removed.**

It also upgrades `posthog-node` from v4.11.1 to v5.45.0 (which raises the required
Node.js version), adds bulk evaluation support, drops `posthog-node`'s runtime
deprecation warnings, and removes the unused `@vercel/edge-config` dependency.

This release requires `flags@^4.2.0`, which is where the uninvoked-adapter shorthand
and bulk evaluation landed.

## Environment variables

The adapter runs server-side only, so its credentials were never meant to be exposed
to the browser. The `NEXT_PUBLIC_` prefixed variables are renamed accordingly, and the
project API key variable now says which key it wants:

```diff
- NEXT_PUBLIC_POSTHOG_KEY=phc_...
- NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
+ POSTHOG_PROJECT_API_KEY=phc_...
+ POSTHOG_HOST=https://us.i.posthog.com
```

`POSTHOG_HOST` is also what `getProviderData` derives the app host from, and
`POSTHOG_PERSONAL_API_KEY` / `POSTHOG_PROJECT_ID` are unchanged.

## Explicit local vs. remote evaluation

Previously the default `postHogAdapter` passed `POSTHOG_PERSONAL_API_KEY` into the
runtime `posthog-node` client. When that variable was set, this enabled local
evaluation and started a feature-flag poller in every warm server process — on
serverless that could generate a large, traffic-independent volume of PostHog feature
flag requests, as a side effect of a credential you may only have set for the Flags
Explorer.

The default adapter now evaluates flags remotely unless you opt in to local
evaluation by setting `POSTHOG_SECRET_KEY` (a `phs_...` project secret key). When set,
`posthog-node` polls flag definitions and evaluates flags in-process. When using
`createPostHogAdapter`, control it explicitly via `postHogOptions`
(`secretKey` + `enableLocalEvaluation`).

`POSTHOG_PERSONAL_API_KEY` continues to be used only by `getProviderData` (Flags
Explorer discovery) and no longer affects runtime evaluation.

## Single callable adapter

The three adapter methods (`isFeatureEnabled`, `featureFlagValue`, `featureFlagPayload`)
are collapsed into a single callable adapter, matching `@flags-sdk/vercel`. Pass it
uninvoked or invoked, and use `.payload` for a flag's attached payload:

```ts
// before
import { postHogAdapter } from '@flags-sdk/posthog';

flag({ key: 'my-flag', adapter: postHogAdapter.isFeatureEnabled() });
flag({ key: 'my-flag', adapter: postHogAdapter.featureFlagValue() });
flag({ key: 'my-flag', adapter: postHogAdapter.featureFlagPayload((v) => v) });

// after
import { postHogAdapter } from '@flags-sdk/posthog';

flag({ key: 'my-flag', adapter: postHogAdapter }); // or postHogAdapter()
flag({ key: 'my-flag', adapter: postHogAdapter.payload }); // or .payload()
```

`isFeatureEnabled` and `featureFlagValue` merged into the value adapter, which returns
whatever PostHog evaluated the flag to: a boolean for a boolean flag, the variant
`string` for a multivariate flag. Type the flag (`flag<boolean>`, `flag<string>`) to
describe the value you expect.

Note that `isFeatureEnabled` used to coerce a multivariate flag's variant to `true`.
Nothing coerces now, so a flag that previously read `true` via `isFeatureEnabled` will
read e.g. `'variant-a'`. Declaring `flag<boolean>` only changes the TypeScript type —
if you relied on the boolean, narrow the value in your own `decide` or at the call site.

A flag's `key` is now used as the PostHog feature flag key verbatim. The previous
convention of trimming everything after the first `.` (so `my-flag.variant` read the
PostHog flag `my-flag`) has been removed; use the exact PostHog flag key as your flag
`key`.

## Upgraded `posthog-node`, migrated to `evaluateFlags`, added bulk evaluation

`posthog-node` is upgraded from v4.11.1 to v5.45.0. Internally the adapter now uses its
`evaluateFlags` instead of the deprecated `isFeatureEnabled` / `getFeatureFlag` /
`getFeatureFlagPayload` methods, removing the deprecation warnings those log at runtime.

The adapter also implements `bulkDecide`, so
[`evaluate()`](https://flags-sdk.dev/frameworks/next/bulk-evaluation) resolves flags
that share an `identify` source through a single `evaluateFlags` call — one `/flags`
request when evaluating remotely, one in-process evaluation when evaluating locally.
Flag values and flag payloads are batched separately, so a flag and its payload still
resolve through two calls.

The per-call `sendFeatureFlagEvents` option and the `featureFlagPayload` `getValue`
mapper are removed (neither has an `evaluateFlags` equivalent); map payloads in your
own flag code instead.

## Node.js version requirement

`posthog-node@5.45.0` requires Node.js `^20.20.0 || >=22.22.0`, and this adapter now
declares the same `engines` constraint.

## Removed `@vercel/edge-config` dependency

The adapter never used it. It is dropped from `dependencies` (and from the package
keywords), so installs no longer pull it in.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ npm-debug.log
**/blob-report/
**/playwright/.cache/
examples/shirt-shop-vercel/
*.tgz

.source
.next
Expand Down
171 changes: 144 additions & 27 deletions apps/docs/content/docs/providers/posthog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,24 @@ import { flag } from "flags/next";
import { postHogAdapter } from '@flags-sdk/posthog'
import identify from "@/lib/identify";

export const myFlag = flag({
key: "posthog-is-feature-enabled",
adapter: postHogAdapter.isFeatureEnabled(),
// Reads the flag's evaluated value. Pass the adapter uninvoked
// (`postHogAdapter`) or invoked (`postHogAdapter()`) — both work.
export const myFlag = flag<boolean>({
key: "posthog-flag",
adapter: postHogAdapter,
identify,
});

export const myFlagVariant = flag({
key: "posthog-feature-flag-value",
adapter: postHogAdapter.featureFlagValue(),
export const myFlagVariant = flag<string>({
key: "posthog-multivariate-flag",
adapter: postHogAdapter,
identify,
});

// Reads the flag's attached payload with `.payload`.
export const myFlagPayload = flag({
key: "posthog-feature-flag-payload",
adapter: postHogAdapter.featureFlagPayload((v) => v),
key: "posthog-flag-with-payload",
adapter: postHogAdapter.payload,
defaultValue: {},
identify,
});
Expand All @@ -47,16 +50,39 @@ Install the required dependencies:
pnpm i @flags-sdk/posthog
```

Set up required environment variables:
### Environment variables

**Always required**, read by `postHogAdapter`:

```bash title=".env.local"
# The regional API host, which determines where your data lives
# Settings > Project > Project API Key
POSTHOG_HOST=https://us.i.posthog.com
# or https://eu.i.posthog.com

# Your project API key
# Settings > Project > Project API Key
POSTHOG_PROJECT_API_KEY=phc_...
```

**Optional**, opts `postHogAdapter` into [local evaluation](#local-evaluation), where
flag definitions are polled in the background instead of evaluating each flag remotely:

```bash title=".env.local"
# https://posthog.com/docs/libraries/next-js
# Or Settings > Project > Project API Key
NEXT_PUBLIC_POSTHOG_KEY=key
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# Settings > Project > Feature flags secret key
POSTHOG_SECRET_KEY=phs_...
```

**For the [Flags Explorer](#flags-explorer)**, read by `getProviderData` only:

```bash title=".env.local"
# Settings > User > Personal API keys
POSTHOG_PERSONAL_API_KEY=phx_...
# Settings > Project > Project ID
POSTHOG_PROJECT_ID=521742
```

Import the PostHog adapter for Flags SDK, which uses the environment variables above as defaults:
Import the PostHog adapter for Flags SDK, which reads `POSTHOG_PROJECT_API_KEY`, `POSTHOG_HOST` and `POSTHOG_SECRET_KEY` when it is first used:

```ts title="flags.ts"
import { postHogAdapter } from '@flags-sdk/posthog'
Expand All @@ -68,33 +94,55 @@ If needed, you can instead initialize the adapter with your own options by impor
import { createPostHogAdapter } from '@flags-sdk/posthog'

const postHogAdapter = createPostHogAdapter({
postHogKey: process.env.NEXT_PUBLIC_POSTHOG_KEY,
postHogKey: process.env.POSTHOG_PROJECT_API_KEY!,
postHogOptions: {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
host: process.env.POSTHOG_HOST,
// ...
},
})
```

You can define flags using the PostHog adapter's methods, which are:
The `postHogAdapter` is a single callable adapter. You can pass it directly, or
invoke it — both are equivalent:

- `isFeatureEnabled`: Returns a boolean indicating whether a feature flag is enabled
- `featureFlagValue`: Returns a boolean, or a `string` for multivariate flags
- `featureFlagPayload`: Maps a PostHog flag's payload to a structured flag value
- `postHogAdapter` (or `postHogAdapter()`): resolves the flag's evaluated value. For
a boolean flag this is a boolean; for a multivariate flag it is the variant `string`.
Type the flag (e.g. `flag<boolean>`) to get the value type you expect.
- `postHogAdapter.payload` (or `postHogAdapter.payload()`): resolves the flag's
attached payload.

The flag's `key` is used as the PostHog feature flag key as-is. Every flag needs an
`identify` function returning the entities the adapter evaluates against:

```ts title="lib/identify.ts"
import type { Identify } from 'flags'
import type { PostHogEntities } from '@flags-sdk/posthog'

export const identify: Identify<PostHogEntities> = async () => {
return { distinctId: 'user-123' }
}
```

The adapter throws if `entities` is missing, so a flag without `identify` will fail at
evaluation time.

```ts title="app/flags.ts"
import { flag } from 'flags/next'
import { postHogAdapter } from '@flags-sdk/posthog'
import { identify } from '@/lib/identify'

export const exampleFlag = flag({
export const exampleFlag = flag<boolean>({
key: 'example-flag',
defaultValue: false,
adapter: postHogAdapter.isFeatureEnabled(),
adapter: postHogAdapter,
identify,
})
```

Flags backed by this adapter participate in [bulk evaluation](/frameworks/next/bulk-evaluation):
`evaluate()` resolves flags that share an `identify` source through a single PostHog
request.

Then use it in your framework:

```tsx title="app/page.tsx"
Expand All @@ -107,6 +155,64 @@ export default async function Page() {
}
```

## Evaluation modes

PostHog can evaluate flags remotely or locally.

### Remote evaluation (default)

With only `POSTHOG_PROJECT_API_KEY` and `POSTHOG_HOST` set, the adapter evaluates
remotely. PostHog makes a network request for every feature flag evaluation, and each request
is billed separately. Having to make a network request for every flag evaluation
also adds latency. If you provide user ids, PostHog will look up additional properties from its database.

### Local evaluation

Setting `POSTHOG_SECRET_KEY` (`phs_...`) is the only thing that switches evaluation
modes — the default adapter enables local evaluation exactly when that key is present.

In this mode, `posthog-node` periodically fetches your flag definitions in the
background (every 30s by default) and evaluates flags in-process against those cached
definitions, avoiding a network round trip on most checks after initialization. Because evaluation happens
locally, you're responsible for providing every property the flag's release conditions
depend on.

Each background poll is billed as 10 flag requests, independent of how many checks it
serves, so for a long-running process this is usually far cheaper than paying per
check. But the poller runs per compute instance, so PostHog recommends against local
evaluation in short-lived compute, where each invocation would otherwise
start its own poller and multiply cost rather than amortize it. Use remote evaluation
there instead. You can widen the polling interval to trade slower propagation of flag
changes for lower polling cost.

With [Fluid Compute](https://vercel.com/fluid) you may see latency and cost benefits from using local evaluation.
It depends on your traffic patterns and load.

You can also enable it explicitly with `createPostHogAdapter`:

```ts title="flags.ts"
import { createPostHogAdapter } from '@flags-sdk/posthog'

const postHogAdapter = createPostHogAdapter({
postHogKey: process.env.POSTHOG_PROJECT_API_KEY!,
postHogOptions: {
host: process.env.POSTHOG_HOST,
secretKey: process.env.POSTHOG_SECRET_KEY,
enableLocalEvaluation: true,
},
})
```

<Callout type="info">
`POSTHOG_PERSONAL_API_KEY` is used only by the Flags Explorer (`getProviderData`,
below) to discover flag definitions. It is never passed to the runtime client and
does **not** enable local evaluation.
</Callout>

The default adapter also sets `disableGeoip: true`, since the server's IP is not a good
proxy for the user's location. Use `createPostHogAdapter` if you want the GeoIP-derived
person properties.

## Flags Explorer

### How to inform the Flags Explorer about flags
Expand All @@ -117,26 +223,37 @@ For getProviderData, you will also need a personal API key and your project ID.

```bash title=".env.local"
# Settings > User > Personal API keys
POSTHOG_PERSONAL_API_KEY=key
POSTHOG_PERSONAL_API_KEY=phx_...
# Settings > Project > Project ID
POSTHOG_PROJECT_ID=projectId
POSTHOG_PROJECT_ID=521742
```

```ts title="app/.well-known/vercel/flags/route.ts#next"
import { createFlagsDiscoveryEndpoint } from 'flags/next'
import { getProviderData as getPostHogProviderData } from '@flags-sdk/posthog'
import * as flags from '../../../../flags'

export const GET = createFlagsDiscoveryEndpoint(() => getPostHogProviderData({
personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY,
projectId: process.env.NEXT_PUBLIC_POSTHOG_PROJECT_ID,
personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY!,
projectId: process.env.POSTHOG_PROJECT_ID!,
}))
```

`getProviderData` calls PostHog's app host, which it derives from `POSTHOG_HOST`
(`https://us.i.posthog.com` becomes `https://us.posthog.com`, and the EU host maps
accordingly). Pass `appHost` explicitly if you use a self-hosted or proxied instance.
Missing credentials or host are reported back as hints in the Flags Explorer rather
than throwing.

<LearnMore
icon="arrow"
href="https://vercel.com/docs/flags/flags-explorer"
target="_blank"
>
Learn more about the Flags Explorer
</LearnMore>


## Additional resources

- [Cutting Costs](https://posthog.com/docs/feature-flags/cutting-costs)
- [Local Evaluation](https://posthog.com/docs/feature-flags/local-evaluation)
Loading
Loading