diff --git a/.changeset/posthog-explicit-local-evaluation.md b/.changeset/posthog-explicit-local-evaluation.md new file mode 100644 index 00000000..a7d8713a --- /dev/null +++ b/.changeset/posthog-explicit-local-evaluation.md @@ -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`, `flag`) 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` 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. diff --git a/.gitignore b/.gitignore index 3fe798fa..7f0dff01 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ npm-debug.log **/blob-report/ **/playwright/.cache/ examples/shirt-shop-vercel/ +*.tgz .source .next diff --git a/apps/docs/content/docs/providers/posthog.mdx b/apps/docs/content/docs/providers/posthog.mdx index 3324e5c1..8439d69d 100644 --- a/apps/docs/content/docs/providers/posthog.mdx +++ b/apps/docs/content/docs/providers/posthog.mdx @@ -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({ + key: "posthog-flag", + adapter: postHogAdapter, identify, }); -export const myFlagVariant = flag({ - key: "posthog-feature-flag-value", - adapter: postHogAdapter.featureFlagValue(), +export const myFlagVariant = flag({ + 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, }); @@ -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' @@ -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`) 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 = 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({ 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" @@ -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, + }, +}) +``` + + + `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. + + +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 @@ -117,22 +223,27 @@ 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. + getPostHogProviderData({ > Learn more about the Flags Explorer + + +## Additional resources + +- [Cutting Costs](https://posthog.com/docs/feature-flags/cutting-costs) +- [Local Evaluation](https://posthog.com/docs/feature-flags/local-evaluation) diff --git a/packages/adapter-posthog/README.md b/packages/adapter-posthog/README.md index 3e458fd7..6b1ba418 100644 --- a/packages/adapter-posthog/README.md +++ b/packages/adapter-posthog/README.md @@ -19,11 +19,50 @@ import { postHogAdapter } from "@flags-sdk/posthog"; export const marketingGate = flag({ // The key in PostHog key: "my_posthog_flag_key_here", - // The PostHog feature to use (isFeatureEnabled, featureFlagValue, featureFlagPayload) - adapter: postHogAdapter.featureFlagValue(), + adapter: postHogAdapter, }); ``` +## Environment variables + +Always required, read by `postHogAdapter`: + +```bash +# Regional API host, determines where your data lives +POSTHOG_HOST=https://us.i.posthog.com # or https://eu.i.posthog.com +# Settings > Project > Project API Key +POSTHOG_PROJECT_API_KEY=phc_... +``` + +Optional, opts `postHogAdapter` into local evaluation: + +```bash +# Settings > Project > Feature flags secret key +POSTHOG_SECRET_KEY=phs_... +``` + +For the Flags Explorer, read by `getProviderData` only: + +```bash +# Settings > User > Personal API keys +POSTHOG_PERSONAL_API_KEY=phx_... +# Settings > Project > Project ID +POSTHOG_PROJECT_ID=521742 +``` + +## Evaluation modes + +- **Remote (default):** with only `POSTHOG_PROJECT_API_KEY` and `POSTHOG_HOST` + set, each evaluation calls PostHog. No background polling; request volume scales + with traffic. Recommended for serverless. +- **Local:** set `POSTHOG_SECRET_KEY` (`phs_...`) to opt in. `posthog-node` polls flag + definitions (~30s) and evaluates in-process for lower latency. Polling runs **per + warm server process** and counts against your PostHog feature flag request quota + regardless of user traffic. + +`POSTHOG_PERSONAL_API_KEY` is used only by the Flags Explorer (`getProviderData`) and +does not enable local evaluation. + ## Runtimes | Runtime | Supported | diff --git a/packages/adapter-posthog/package.json b/packages/adapter-posthog/package.json index 86ee3e23..e59982e7 100644 --- a/packages/adapter-posthog/package.json +++ b/packages/adapter-posthog/package.json @@ -6,7 +6,6 @@ "flags-sdk", "posthog", "vercel", - "edge config", "feature flags", "flags" ], @@ -22,6 +21,9 @@ "author": "Aaron Morris ", "sideEffects": false, "type": "module", + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, "exports": { ".": { "import": "./dist/index.js", @@ -50,8 +52,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@vercel/edge-config": "^1.4.3", - "posthog-node": "4.11.1" + "posthog-node": "5.45.0" }, "devDependencies": { "@types/node": "22.14.0", diff --git a/packages/adapter-posthog/src/index.test.ts b/packages/adapter-posthog/src/index.test.ts index cd4f521d..045c51cc 100644 --- a/packages/adapter-posthog/src/index.test.ts +++ b/packages/adapter-posthog/src/index.test.ts @@ -1,12 +1,19 @@ import type { ReadonlyHeaders, ReadonlyRequestCookies } from 'flags'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { type PostHogEntities, postHogAdapter } from '.'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + type PostHogEntities, + postHogAdapter, + resetDefaultPostHogAdapter, +} from '.'; + +const snapshotMock = { + getFlag: vi.fn(), + getFlagPayload: vi.fn(), + isEnabled: vi.fn(), +}; const postHogClientMock = { - isFeatureEnabled: vi.fn(), - getFeatureFlag: vi.fn(), - getFeatureFlagPayload: vi.fn(), - getRemoteConfigPayload: vi.fn(), + evaluateFlags: vi.fn(() => snapshotMock), }; vi.mock('posthog-node', () => ({ @@ -17,82 +24,263 @@ vi.mock('posthog-node', () => ({ }), })); +const headers = {} as ReadonlyHeaders; +const cookies = {} as ReadonlyRequestCookies; +const entities: PostHogEntities = { distinctId: 'user_1' }; + +// `flag()` resolves an adapter with `typeof a === 'function' ? a() : a`, so +// passing `postHogAdapter` (uninvoked) and `postHogAdapter()` (invoked) both +// yield the same adapter. This mirrors that resolution without pulling in the +// full `flags/next` runtime. +function resolve(adapterOrFactory: unknown) { + return typeof adapterOrFactory === 'function' + ? (adapterOrFactory as () => any)() + : adapterOrFactory; +} + describe('postHogAdapter', () => { - it('isFeatureEnabled should be a function', () => { - expect(postHogAdapter.isFeatureEnabled).toBeInstanceOf(Function); + it('is a callable adapter factory', () => { + expect(postHogAdapter).toBeInstanceOf(Function); + expect(postHogAdapter.payload).toBeInstanceOf(Function); }); describe('with a missing environment', () => { it('should throw an error', () => { - expect(() => postHogAdapter.isFeatureEnabled()).toThrowError( - 'PostHog Adapter: Missing NEXT_PUBLIC_POSTHOG_KEY environment variable', + delete process.env.POSTHOG_PROJECT_API_KEY; + resetDefaultPostHogAdapter(); + expect(() => postHogAdapter()).toThrowError( + 'PostHog Adapter: Missing POSTHOG_PROJECT_API_KEY environment variable', ); }); }); describe('with an environment', () => { beforeAll(() => { - process.env.NEXT_PUBLIC_POSTHOG_KEY = 'test-posthog-key'; - process.env.NEXT_PUBLIC_POSTHOG_HOST = 'https://us.i.posthog.com'; + process.env.POSTHOG_PROJECT_API_KEY = 'test-posthog-key'; + process.env.POSTHOG_HOST = 'https://us.i.posthog.com'; + resetDefaultPostHogAdapter(); }); - describe('isFeatureEnabled', () => { - it('should decide', async () => { - postHogClientMock.isFeatureEnabled.mockReturnValue(true); + afterEach(() => { + vi.clearAllMocks(); + }); - const valuePromise = postHogAdapter.isFeatureEnabled().decide({ + it('is usable invoked or uninvoked (same underlying adapter)', () => { + const fromUninvoked = resolve(postHogAdapter); + const fromInvoked = resolve(postHogAdapter()); + expect(fromUninvoked.adapterId).toBe(fromInvoked.adapterId); + expect(typeof fromUninvoked.decide).toBe('function'); + expect(typeof fromUninvoked.bulkDecide).toBe('function'); + }); + + it('gives the payload adapter a distinct adapterId', () => { + expect(resolve(postHogAdapter.payload).adapterId).not.toBe( + resolve(postHogAdapter).adapterId, + ); + }); + + describe('value adapter', () => { + it('decides the flag value', async () => { + snapshotMock.getFlag.mockReturnValue('test_group_1'); + + const value = await postHogAdapter().decide({ key: 'test-flag', - headers: {} as ReadonlyHeaders, - cookies: {} as ReadonlyRequestCookies, - entities: {} as PostHogEntities, + headers, + cookies, + entities, defaultValue: false, }); - await expect(valuePromise).resolves.toEqual(true); - expect(postHogClientMock.isFeatureEnabled).toHaveBeenCalled(); + expect(value).toEqual('test_group_1'); + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledWith('user_1', { + flagKeys: ['test-flag'], + }); }); - }); - describe('featureValue', () => { - it('should decide', async () => { - postHogClientMock.getFeatureFlag.mockReturnValue('test_group_1'); + it('falls back to defaultValue when the flag is missing', async () => { + snapshotMock.getFlag.mockReturnValue(undefined); - const valuePromise = postHogAdapter.featureFlagValue().decide({ - key: 'test-flag', - headers: {} as ReadonlyHeaders, - cookies: {} as ReadonlyRequestCookies, - entities: {} as PostHogEntities, + const value = await postHogAdapter().decide({ + key: 'missing', + headers, + cookies, + entities, defaultValue: false, }); - await expect(valuePromise).resolves.toEqual('test_group_1'); - expect(postHogClientMock.getFeatureFlag).toHaveBeenCalled(); + expect(value).toBe(false); + }); + + it('returns false for a disabled flag (not the default)', async () => { + snapshotMock.getFlag.mockReturnValue(false); + + const value = await postHogAdapter().decide({ + key: 'disabled', + headers, + cookies, + entities, + defaultValue: true, + }); + + expect(value).toBe(false); + }); + + it('throws when the flag is missing and no default is set', async () => { + snapshotMock.getFlag.mockReturnValue(undefined); + + await expect( + postHogAdapter().decide({ + key: 'missing', + headers, + cookies, + entities, + }), + ).rejects.toThrow('PostHog Adapter found no value for missing'); + }); + + it('bulkDecides the group in a single evaluateFlags call', async () => { + snapshotMock.getFlag.mockImplementation((key: string) => + key === 'a' ? true : 'variant', + ); + + const result = await postHogAdapter().bulkDecide!({ + flags: [{ key: 'a' }, { key: 'b' }], + entities, + headers, + cookies, + }); + + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledTimes(1); + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledWith('user_1', { + flagKeys: ['a', 'b'], + }); + expect(result).toEqual({ a: true, b: 'variant' }); + }); + + it('bulkDecide omits flags with no value', async () => { + snapshotMock.getFlag.mockImplementation((key: string) => + key === 'a' ? true : undefined, + ); + + const result = await postHogAdapter().bulkDecide!({ + flags: [{ key: 'a' }, { key: 'b' }], + entities, + headers, + cookies, + }); + + expect(result).toEqual({ a: true }); }); }); - describe('featurePayload', () => { - it('should decide', async () => { - postHogClientMock.getFeatureFlag.mockReturnValue('test_group_1'); - postHogClientMock.getFeatureFlagPayload.mockReturnValue({ - text: 'hello world', + describe('payload adapter', () => { + it('decides the flag payload', async () => { + snapshotMock.getFlagPayload.mockReturnValue({ text: 'hello world' }); + + const value = await postHogAdapter.payload().decide({ + key: 'test-flag', + headers, + cookies, + entities, + defaultValue: {}, }); - const valuePromise = postHogAdapter - .featureFlagPayload( - (payload) => (payload as { text: string }).text, - ) - .decide({ - key: 'test-flag', - headers: {} as ReadonlyHeaders, - cookies: {} as ReadonlyRequestCookies, - entities: {} as PostHogEntities, - defaultValue: 'default', - }); - - await expect(valuePromise).resolves.toEqual('hello world'); - expect(postHogClientMock.getFeatureFlag).toHaveBeenCalled(); - expect(postHogClientMock.getFeatureFlagPayload).toHaveBeenCalled(); + expect(value).toEqual({ text: 'hello world' }); + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledWith('user_1', { + flagKeys: ['test-flag'], + }); + }); + + it('falls back to defaultValue when there is no payload', async () => { + snapshotMock.getFlagPayload.mockReturnValue(undefined); + + const value = await postHogAdapter.payload().decide({ + key: 'missing', + headers, + cookies, + entities, + defaultValue: { fallback: true }, + }); + + expect(value).toEqual({ fallback: true }); + }); + + it('bulkDecides payloads in a single evaluateFlags call', async () => { + snapshotMock.getFlagPayload.mockImplementation((key: string) => + key === 'a' ? { a: 1 } : undefined, + ); + + const result = await postHogAdapter.payload().bulkDecide!({ + flags: [{ key: 'a' }, { key: 'b' }], + entities, + headers, + cookies, + }); + + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledTimes(1); + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledWith('user_1', { + flagKeys: ['a', 'b'], + }); + expect(result).toEqual({ a: { a: 1 } }); }); }); }); }); + +describe('default adapter evaluation mode', () => { + const OLD_ENV = process.env; + + beforeAll(() => { + process.env.POSTHOG_PROJECT_API_KEY = 'test-posthog-key'; + process.env.POSTHOG_HOST = 'https://us.i.posthog.com'; + }); + + afterEach(() => { + process.env = OLD_ENV; + vi.resetModules(); + }); + + // The default adapter is memoized and reads env lazily on first use, so each + // case starts from a fresh module registry to construct a new singleton. + async function constructDefaultAdapter() { + vi.resetModules(); + const { PostHog } = await import('posthog-node'); + const { postHogAdapter: freshAdapter } = await import('.'); + // Invoking the adapter constructs the underlying PostHog client. + freshAdapter(); + return vi.mocked(PostHog).mock.calls.at(-1)?.[1] ?? {}; + } + + it('evaluates remotely by default (no secret key, no local evaluation)', async () => { + process.env = { ...OLD_ENV }; + delete process.env.POSTHOG_SECRET_KEY; + + const options = await constructDefaultAdapter(); + + expect(options.secretKey).toBeUndefined(); + expect(options.personalApiKey).toBeUndefined(); + expect(options.enableLocalEvaluation).toBe(false); + }); + + it('does not enable local evaluation from POSTHOG_PERSONAL_API_KEY', async () => { + process.env = { ...OLD_ENV }; + delete process.env.POSTHOG_SECRET_KEY; + process.env.POSTHOG_PERSONAL_API_KEY = 'phx_personal'; + + const options = await constructDefaultAdapter(); + + expect(options.secretKey).toBeUndefined(); + expect(options.personalApiKey).toBeUndefined(); + expect(options.enableLocalEvaluation).toBe(false); + }); + + it('enables local evaluation when POSTHOG_SECRET_KEY is set', async () => { + process.env = { ...OLD_ENV }; + process.env.POSTHOG_SECRET_KEY = 'phs_secret'; + + const options = await constructDefaultAdapter(); + + expect(options.secretKey).toBe('phs_secret'); + expect(options.enableLocalEvaluation).toBe(true); + }); +}); diff --git a/packages/adapter-posthog/src/index.ts b/packages/adapter-posthog/src/index.ts index 4cd28242..3860fa2b 100644 --- a/packages/adapter-posthog/src/index.ts +++ b/packages/adapter-posthog/src/index.ts @@ -1,87 +1,93 @@ -import { PostHog } from 'posthog-node'; +import type { Adapter } from 'flags'; +import { PostHog, type PostHogOptions } from 'posthog-node'; import type { JsonType, PostHogAdapter, PostHogEntities } from './types'; export { getProviderData } from './provider'; export type { PostHogEntities, JsonType }; +type FlagEvaluations = Awaited>; + +// Builds an adapter (with a single-flag `decide` and a batched `bulkDecide`) +// around one way of reading a value out of an `evaluateFlags` snapshot. The +// value adapter reads `getFlag`, the payload adapter reads `getFlagPayload`. +function createFlagAdapter( + client: PostHog, + adapterId: symbol, + read: (snapshot: FlagEvaluations, key: string) => unknown, + isPresent: (value: unknown) => boolean, + noun: 'value' | 'payload', +): Adapter { + return { + adapterId, + async decide({ key, entities, defaultValue }) { + const { distinctId } = parseEntities(entities); + const snapshot = await client.evaluateFlags(distinctId, { + flagKeys: [key], + }); + const value = read(snapshot, key); + if (!isPresent(value)) { + if (typeof defaultValue !== 'undefined') return defaultValue; + throw new Error( + `PostHog Adapter found no ${noun} for ${key} and no default value was provided.`, + ); + } + return value; + }, + async bulkDecide({ flags, entities }) { + const { distinctId } = parseEntities(entities); + const snapshot = await client.evaluateFlags(distinctId, { + flagKeys: flags.map(({ key }) => key), + }); + const out: Record = {}; + for (const { key } of flags) { + const value = read(snapshot, key); + // Omit absent values so the SDK applies each flag's `defaultValue`. + if (isPresent(value)) out[key] = value; + } + return out; + }, + }; +} + export function createPostHogAdapter({ postHogKey, postHogOptions, }: { - postHogKey: ConstructorParameters[0]; - postHogOptions: ConstructorParameters[1]; + postHogKey: string; + postHogOptions: PostHogOptions; }): PostHogAdapter { const client = new PostHog(postHogKey, postHogOptions); - const result: PostHogAdapter = { - isFeatureEnabled: (options) => { - return { - async decide({ key, entities, defaultValue }): Promise { - const parsedEntities = parseEntities(entities); - const result = - (await client.isFeatureEnabled( - trimKey(key), - parsedEntities.distinctId, - options, - )) ?? (defaultValue as boolean | undefined); - if (result === undefined) { - throw new Error( - `PostHog Adapter isFeatureEnabled returned undefined for ${trimKey(key)} and no default value was provided.`, - ); - } - return result; - }, - }; - }, - featureFlagValue: (options) => { - return { - async decide({ key, entities, defaultValue }) { - const parsedEntities = parseEntities(entities); - const flagValue = await client.getFeatureFlag( - trimKey(key), - parsedEntities.distinctId, - options, - ); - if (flagValue === undefined) { - if (typeof defaultValue !== 'undefined') { - return defaultValue as string | boolean; - } - throw new Error( - `PostHog Adapter featureFlagValue found undefined for ${trimKey(key)} and no default value was provided.`, - ); - } - return flagValue; - }, - }; - }, - featureFlagPayload: ( - getValue: (payload: JsonType) => T, - options?: { sendFeatureFlagEvents?: boolean }, - ) => { - return { - async decide({ key, entities, defaultValue }) { - const parsedEntities = parseEntities(entities); - const payload = await client.getFeatureFlagPayload( - trimKey(key), - parsedEntities.distinctId, - undefined, - options, - ); - if (!payload) { - if (typeof defaultValue !== 'undefined') { - return defaultValue as T; - } - throw new Error( - `PostHog Adapter featureFlagPayload found undefined for ${trimKey(key)} and no default value was provided.`, - ); - } - return getValue(payload as JsonType); - }, - }; - }, - }; + // Stable identities captured in this closure so every adapter object the + // factories below hand out shares them. `evaluate()` batches flags whose + // adapters share an `adapterId` (and `identify` source) into a single + // `evaluateFlags` request. Value and payload need separate ids because + // `bulkDecide` returns one value per key and can't tell from the key alone + // whether the caller wanted the flag value or its payload. + const valueAdapter = createFlagAdapter( + client, + Symbol('postHogAdapter.value'), + // `getFlag` returns `false` for a disabled flag (a real value) and + // `undefined` only when the flag was not returned by the evaluation. + (snapshot, key) => snapshot.getFlag(key), + (value) => value !== undefined, + 'value', + ); + + const payloadAdapter = createFlagAdapter( + client, + Symbol('postHogAdapter.payload'), + (snapshot, key) => snapshot.getFlagPayload(key), + (value) => Boolean(value), + 'payload', + ); - return result; + const adapter = (() => + valueAdapter as Adapter) as PostHogAdapter; + adapter.payload = () => + payloadAdapter as Adapter; + + return adapter; } function parseEntities(entities?: PostHogEntities): PostHogEntities { @@ -102,22 +108,33 @@ function assertEnv(name: string): string { return value; } -// Read until the first `.` -// This supports defining multiple flags with the same key -// Ex. with my-flag.is-enabled, my-flag.variant and my-flag.payload -function trimKey(key: string): string { - return key.split('.')[0] as string; +let defaultPostHogAdapter: PostHogAdapter | undefined; + +/** + * Internal function for testing purposes + */ +export function resetDefaultPostHogAdapter() { + defaultPostHogAdapter = undefined; } -let defaultPostHogAdapter: ReturnType | undefined; -function getOrCreateDefaultAdapter() { +function getOrCreateDefaultAdapter(): PostHogAdapter { if (!defaultPostHogAdapter) { + // Evaluation mode is an explicit choice, not a side effect of any other + // credential. Local evaluation is opt-in via POSTHOG_SECRET_KEY (a `phs_` + // project secret key). When it is set, posthog-node polls flag definitions + // and evaluates flags in-process; otherwise flags are evaluated remotely. + // + // Note: POSTHOG_PERSONAL_API_KEY is only used by getProviderData (Flags + // Explorer discovery) and is intentionally never passed to the runtime + // client, so it does not enable local-evaluation polling. + const secretKey = process.env.POSTHOG_SECRET_KEY; + defaultPostHogAdapter = createPostHogAdapter({ - postHogKey: assertEnv('NEXT_PUBLIC_POSTHOG_KEY'), + postHogKey: assertEnv('POSTHOG_PROJECT_API_KEY'), postHogOptions: { - host: assertEnv('NEXT_PUBLIC_POSTHOG_HOST'), - personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY, - featureFlagsPollingInterval: 10_000, + host: assertEnv('POSTHOG_HOST'), + secretKey, + enableLocalEvaluation: Boolean(secretKey), // Presumption: Server IP is likely not a good proxy for user location disableGeoip: true, }, @@ -126,11 +143,15 @@ function getOrCreateDefaultAdapter() { return defaultPostHogAdapter; } -export const postHogAdapter: PostHogAdapter = { - isFeatureEnabled: (...args) => - getOrCreateDefaultAdapter().isFeatureEnabled(...args), - featureFlagValue: (...args) => - getOrCreateDefaultAdapter().featureFlagValue(...args), - featureFlagPayload: (...args) => - getOrCreateDefaultAdapter().featureFlagPayload(...args), -}; +/** + * The default PostHog adapter, initialized lazily from environment variables on + * first use. Pass it uninvoked (`adapter: postHogAdapter`) or invoked + * (`adapter: postHogAdapter()`) to read a flag's value, or use + * `postHogAdapter.payload` to read the flag's attached payload. + */ +export const postHogAdapter: PostHogAdapter = Object.assign( + () => getOrCreateDefaultAdapter()(), + { + payload: () => getOrCreateDefaultAdapter().payload(), + }, +); diff --git a/packages/adapter-posthog/src/provider/index.ts b/packages/adapter-posthog/src/provider/index.ts index fa89cf78..590e2fb6 100644 --- a/packages/adapter-posthog/src/provider/index.ts +++ b/packages/adapter-posthog/src/provider/index.ts @@ -42,7 +42,7 @@ export async function getProviderData(options: { } catch { hints.push({ key: 'posthog/missing-app-host', - text: 'Missing NEXT_PUBLIC_POSTHOG_HOST environment variable', + text: 'Missing POSTHOG_HOST environment variable', }); } } @@ -142,10 +142,10 @@ export async function getProviderData(options: { } export const getAppHost = (apiHost?: string) => { - const host = apiHost ?? process.env.NEXT_PUBLIC_POSTHOG_HOST; + const host = apiHost ?? process.env.POSTHOG_HOST; if (!host) { - throw new Error('NEXT_PUBLIC_POSTHOG_HOST is not set'); + throw new Error('POSTHOG_HOST is not set'); } let hostname: string; diff --git a/packages/adapter-posthog/src/types.ts b/packages/adapter-posthog/src/types.ts index fa303e8a..f24246a5 100644 --- a/packages/adapter-posthog/src/types.ts +++ b/packages/adapter-posthog/src/types.ts @@ -14,19 +14,15 @@ interface PostHogEntities { distinctId: string; } -type PostHogAdapter = { - isFeatureEnabled: (options?: { - sendFeatureFlagEvents?: boolean; - }) => Adapter; - featureFlagValue: (options?: { - sendFeatureFlagEvents?: boolean; - }) => Adapter; - featureFlagPayload: ( - getValue: (payload: JsonType) => T, - options?: { - sendFeatureFlagEvents?: boolean; - }, - ) => Adapter; +/** + * A PostHog adapter for the Flags SDK. + * + * Call it (or pass it uninvoked, e.g. `adapter: postHogAdapter`) to read a + * flag's evaluated value. Use `.payload` to read the flag's attached payload + * instead. Both forms participate in bulk evaluation via `evaluate()`. + */ +type PostHogAdapter = (() => Adapter) & { + payload: () => Adapter; }; export type { Adapter, PostHogEntities, PostHogAdapter, JsonType }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 897d3384..09e19044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -657,12 +657,9 @@ importers: packages/adapter-posthog: dependencies: - '@vercel/edge-config': - specifier: ^1.4.3 - version: 1.4.3(@opentelemetry/api@1.9.0)(next@16.2.12(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.3.0-canary-fef12a01-20260413))(react@19.3.0-canary-fef12a01-20260413)) posthog-node: - specifier: 4.11.1 - version: 4.11.1 + specifier: 5.45.0 + version: 5.45.0 devDependencies: '@types/node': specifier: 22.14.0 @@ -1116,18 +1113,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} @@ -1140,11 +1129,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -1162,10 +1146,6 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1326,9 +1306,6 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -2076,6 +2053,12 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@posthog/core@1.43.1': + resolution: {integrity: sha512-hGM8f5sp3we6Em/RQHXbmyYm554hUx9+9jhf92ZQgDS4/xW72KHyZiO9wcFye+qAx2cJAOhOCDIznmO1FV8IbA==} + + '@posthog/types@1.397.0': + resolution: {integrity: sha512-Pa7FtsBo3V0XrhY4y8Xquwp8U07syuZ2IGQdlsRyxhz0yejQLceFjI58UssCXE5zDCDj3km5l7H3ufD6rHChCg==} + '@publint/pack@0.1.4': resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} @@ -4858,10 +4841,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -4984,9 +4963,6 @@ packages: async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -4995,9 +4971,6 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} - axios@1.18.1: - resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -5217,10 +5190,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -5530,10 +5499,6 @@ packages: delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -5995,10 +5960,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - framer-motion@12.34.0: resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==} peerDependencies: @@ -6315,10 +6276,6 @@ packages: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -7241,18 +7198,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -7762,9 +7711,14 @@ packages: resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} engines: {node: ^10 || ^12 || >=14} - posthog-node@4.11.1: - resolution: {integrity: sha512-Hdby0Rq2K1rzjHxQdUmFBEP2UqAR/tY4d0WbNp7GxkYUK0YZvAD15MkeorSo4b8X7zgosHfQJVRxGx0HWSYPBA==} - engines: {node: '>=15.0.0'} + posthog-node@5.45.0: + resolution: {integrity: sha512-wCPydi0qtuVP+PMyyCI12Cl0/id4CODMxijMjrX9FRPBxZus/0SqO2TTiia5Psk2JHfIgdP+Hd85LdXYRB6liQ==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -7781,10 +7735,6 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} - psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -9137,12 +9087,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-option@7.29.7': {} @@ -9152,10 +9098,6 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -9180,11 +9122,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -9429,11 +9366,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -9734,7 +9666,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.11.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -10073,6 +10005,12 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@posthog/core@1.43.1': + dependencies: + '@posthog/types': 1.397.0 + + '@posthog/types@1.397.0': {} + '@publint/pack@0.1.4': {} '@radix-ui/number@1.1.1': {} @@ -13151,12 +13089,6 @@ snapshots: acorn@8.16.0: {} - agent-base@6.0.2: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - agent-base@7.1.4: {} ai@6.0.206(zod@4.3.6): @@ -13299,24 +13231,12 @@ snapshots: async-sema@3.1.1: {} - asynckit@0.4.0: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.11.1: {} - axios@1.18.1: - dependencies: - follow-redirects: 1.16.0 - form-data: 4.0.6 - https-proxy-agent: 5.0.1 - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - - supports-color - axobject-query@4.1.0: {} bail@2.0.2: {} @@ -13526,10 +13446,6 @@ snapshots: colorette@2.0.20: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - comma-separated-tokens@2.0.3: {} commander@10.0.1: {} @@ -13865,8 +13781,6 @@ snapshots: dependencies: robust-predicates: 3.0.2 - delayed-stream@1.0.0: {} - dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -13974,7 +13888,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -14050,11 +13964,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -14183,7 +14097,7 @@ snapshots: eslint: 9.38.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@2.7.0)) - hasown: 2.0.2 + hasown: 2.0.4 is-core-module: 2.16.1 is-glob: 4.0.3 minimatch: 3.1.5 @@ -14211,7 +14125,7 @@ snapshots: damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 eslint: 9.38.0(jiti@2.7.0) - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.5 @@ -14222,7 +14136,7 @@ snapshots: eslint-plugin-react-hooks@7.0.1(eslint@9.38.0(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.7 eslint: 9.38.0(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.3.6 @@ -14240,7 +14154,7 @@ snapshots: es-iterator-helpers: 1.3.1 eslint: 9.38.0(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 @@ -14481,14 +14395,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - framer-motion@12.34.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: motion-dom: 12.34.0 @@ -14627,7 +14533,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.4 is-callable: 1.2.7 functions-have-names@1.2.3: {} @@ -14650,7 +14556,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -14919,13 +14825,6 @@ snapshots: transitivePeerDependencies: - debug - https-proxy-agent@5.0.1: - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -14986,7 +14885,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.4 side-channel: 1.1.0 internmap@1.0.1: {} @@ -15106,7 +15005,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-set@2.0.3: {} @@ -16040,14 +15939,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -16665,12 +16558,9 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-node@4.11.1: + posthog-node@5.45.0: dependencies: - axios: 1.18.1 - transitivePeerDependencies: - - debug - - supports-color + '@posthog/core': 1.43.1 prelude-ls@1.2.1: {} @@ -16684,8 +16574,6 @@ snapshots: property-information@7.1.0: {} - proxy-from-env@2.1.0: {} - psl@1.15.0: dependencies: punycode: 2.3.1 diff --git a/skills/flags-sdk/references/providers.md b/skills/flags-sdk/references/providers.md index 7c2a3cee..f1737f77 100644 --- a/skills/flags-sdk/references/providers.md +++ b/skills/flags-sdk/references/providers.md @@ -400,38 +400,47 @@ Package: `@flags-sdk/posthog` pnpm i @flags-sdk/posthog ``` -Env vars: -- `NEXT_PUBLIC_POSTHOG_KEY` -- `NEXT_PUBLIC_POSTHOG_HOST` (e.g. `https://us.i.posthog.com`) +Env vars, always required: +- `POSTHOG_HOST` (e.g. `https://us.i.posthog.com` or `https://eu.i.posthog.com`) +- `POSTHOG_PROJECT_API_KEY` (`phc_...`) + +Optional, opts into local evaluation (background polling) instead of remote: +- `POSTHOG_SECRET_KEY` (`phs_...`) + +For the Flags Explorer (`getProviderData` only): +- `POSTHOG_PERSONAL_API_KEY` (`phx_...`) +- `POSTHOG_PROJECT_ID` (e.g. `521742`) ### Methods ```ts import { postHogAdapter } from '@flags-sdk/posthog'; -// Boolean check -export const myFlag = flag({ +// Value — boolean flag. Pass the adapter uninvoked or invoked, both work. +export const myFlag = flag({ key: 'my-flag', - adapter: postHogAdapter.isFeatureEnabled(), + adapter: postHogAdapter, identify, }); -// Multivariate value -export const myVariant = flag({ +// Value — multivariate flag resolves to the variant string +export const myVariant = flag({ key: 'my-flag', - adapter: postHogAdapter.featureFlagValue(), + adapter: postHogAdapter, identify, }); // Payload export const myPayload = flag({ key: 'my-flag', - adapter: postHogAdapter.featureFlagPayload((v) => v), + adapter: postHogAdapter.payload, defaultValue: {}, identify, }); ``` +`identify` must return `{ distinctId }`. + ### Flags Explorer Requires: `POSTHOG_PERSONAL_API_KEY`, `POSTHOG_PROJECT_ID` @@ -441,8 +450,8 @@ import { getProviderData as getPostHogProviderData } from '@flags-sdk/posthog'; 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!, }), ); ```