diff --git a/.changeset/openfeature-node-provider.md b/.changeset/openfeature-node-provider.md new file mode 100644 index 0000000000..8b8afd6724 --- /dev/null +++ b/.changeset/openfeature-node-provider.md @@ -0,0 +1,5 @@ +--- +'@posthog/openfeature-node-provider': minor +--- + +Initial release of the official PostHog provider for the OpenFeature server SDK, backed by `posthog-node`. diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 2918a9d01b..00bf11c466 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -71,6 +71,7 @@ module.exports = { 'packages/mcp/**', 'packages/nextjs-config/**', 'packages/nuxt/**', + 'packages/openfeature-node-provider/**', 'packages/openfeature-web-provider/**', 'packages/react-native/**', 'packages/node/**', diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index dab205c532..0b14ecc49f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -24,6 +24,7 @@ - [ ] @posthog/next - [ ] @posthog/nextjs-config - [ ] @posthog/nuxt +- [ ] @posthog/openfeature-node-provider - [ ] @posthog/openfeature-web-provider - [ ] @posthog/rollup-plugin - [ ] @posthog/webpack-plugin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98803653c3..2e69c20699 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -268,6 +268,7 @@ jobs: - name: '@posthog/next' - name: '@posthog/nextjs-config' - name: '@posthog/nuxt' + - name: '@posthog/openfeature-node-provider' - name: '@posthog/openfeature-web-provider' - name: '@posthog/plugin-utils' - name: '@posthog/rollup-plugin' diff --git a/AGENTS.md b/AGENTS.md index 4969b54dcb..54ff692bdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ The repository contains the following SDK packages in `./packages/`: | `nuxt/` | `@posthog/nuxt` | Nuxt framework module | | `next/` | `@posthog/next` | Next.js framework module | | `nextjs-config/` | `@posthog/nextjs-config` | Next.js configuration helper | +| `openfeature-node-provider/` | `@posthog/openfeature-node-provider` | OpenFeature server provider (posthog-node) | | `openfeature-web-provider/` | `@posthog/openfeature-web-provider` | OpenFeature web provider (posthog-js) | | `plugin-utils/` | `@posthog/plugin-utils` | Shared CLI and sourcemap utilities for plugins | | `types/` | `@posthog/types` | TypeScript type definitions for the SDK | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1acdfc3baf..3388db40d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,5 +11,6 @@ PostHog JS is a mono-repo. The changelogs are in the individual packages - [@posthog/convex](./packages/convex/CHANGELOG.md) - [@posthog/next](./packages/next/CHANGELOG.md) - [@posthog/nextjs-config](./packages/nextjs-config/CHANGELOG.md) +- [@posthog/openfeature-node-provider](./packages/openfeature-node-provider/CHANGELOG.md) - [@posthog/openfeature-web-provider](./packages/openfeature-web-provider/CHANGELOG.md) - [@posthog/plugin-utils](./packages/plugin-utils/CHANGELOG.md) diff --git a/README.md b/README.md index 7408defd22..4d25d38b70 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Please see the main [PostHog docs](https://posthog.com/docs). - [@posthog/convex](./packages/convex/README.md) - [@posthog/nextjs-config](./packages/nextjs-config/README.md) - [@posthog/nuxt](./packages/nuxt/README.md) +- [@posthog/openfeature-node-provider](./packages/openfeature-node-provider/README.md) - [@posthog/openfeature-web-provider](./packages/openfeature-web-provider/README.md) - [@posthog/plugin-utils](./packages/plugin-utils/README.md) - [@posthog/rollup-plugin](./packages/rollup-plugin/README.md) diff --git a/examples/example-openfeature-node/.gitignore b/examples/example-openfeature-node/.gitignore new file mode 100644 index 0000000000..3af767a6f1 --- /dev/null +++ b/examples/example-openfeature-node/.gitignore @@ -0,0 +1,5 @@ +/node_modules +.DS_Store +.env +.env.local +*.log diff --git a/examples/example-openfeature-node/.npmrc b/examples/example-openfeature-node/.npmrc new file mode 100644 index 0000000000..c0f538dcf3 --- /dev/null +++ b/examples/example-openfeature-node/.npmrc @@ -0,0 +1,2 @@ +# Minimum age (in days) before a package version can be installed +min-release-age=7 diff --git a/examples/example-openfeature-node/README.md b/examples/example-openfeature-node/README.md new file mode 100644 index 0000000000..9dbe7bdbbc --- /dev/null +++ b/examples/example-openfeature-node/README.md @@ -0,0 +1,32 @@ +# example-openfeature-node + +Minimal Node example of the **[`@posthog/openfeature-node-provider`](../../packages/openfeature-node-provider)** — evaluating PostHog feature flags through the standard [OpenFeature](https://openfeature.dev) server SDK, backed by `posthog-node`. + +The browser equivalent lives in [`example-openfeature-web`](../example-openfeature-web) (see `@posthog/openfeature-web-provider`). + +## What it shows + +- Constructing a `posthog-node` client (you own its lifecycle; call `shutdown()` on exit). +- Registering `PostHogServerProvider` with `OpenFeature.setProviderAndWait(...)`. +- Async flag evaluation via the vendor-neutral OpenFeature client + (`getBooleanValue` / `getStringValue` / `getObjectValue`), with the distinct id + supplied per-call through the evaluation context's `targetingKey`. + +See [`index.ts`](./index.ts). + +## Run it + +These examples install workspace packages as tarballs (see [`../README.md`](../README.md)). + +1. From the repo root, build the tarballs: + ```bash + pnpm package:watch + ``` +2. In this folder, install and run: + ```bash + pnpm install + POSTHOG_PROJECT_API_KEY= pnpm start + ``` + Use real flag keys from your project. + +> The lockfile is generated on first `pnpm install` per the examples tarball workflow. diff --git a/examples/example-openfeature-node/index.ts b/examples/example-openfeature-node/index.ts new file mode 100644 index 0000000000..3646ebae7f --- /dev/null +++ b/examples/example-openfeature-node/index.ts @@ -0,0 +1,44 @@ +import { OpenFeature } from '@openfeature/server-sdk' +import { PostHogServerProvider } from '@posthog/openfeature-node-provider' +import { PostHog } from 'posthog-node' + +// Configure via env vars or edit inline. +const PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY ?? '' +const HOST = process.env.POSTHOG_HOST ?? 'https://us.i.posthog.com' + +async function main(): Promise { + // 1. You own the posthog-node lifecycle. Pass a personalApiKey to enable + // local evaluation. + const posthog = new PostHog(PROJECT_API_KEY, { host: HOST }) + + // 2. Register the PostHog provider with OpenFeature. + await OpenFeature.setProviderAndWait(new PostHogServerProvider(posthog)) + const client = OpenFeature.getClient() + + // 3. Evaluate flags through the vendor-neutral OpenFeature API. The distinct + // id comes from the evaluation context's targetingKey; other attributes + // map to person/group properties. Swap these keys for real flags. + const context = { targetingKey: 'user_distinct_id', plan: 'enterprise' } + // The reads are independent, so evaluate them concurrently. + const [boolean, multivariate, payload] = await Promise.all([ + client.getBooleanValue('my-boolean-flag', false, context), + client.getStringValue('my-multivariate-flag', 'control', context), + client.getObjectValue('my-payload-flag', {}, context), + ]) + const result = { + 'my-boolean-flag': boolean, + 'my-multivariate-flag': multivariate, + 'my-payload-flag': payload, + } + + // eslint-disable-next-line no-console + console.log(result) + + await posthog.shutdown() +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('OpenFeature example failed:', err) + process.exit(1) +}) diff --git a/examples/example-openfeature-node/package.json b/examples/example-openfeature-node/package.json new file mode 100644 index 0000000000..0a288f6d27 --- /dev/null +++ b/examples/example-openfeature-node/package.json @@ -0,0 +1,23 @@ +{ + "name": "example-openfeature-node", + "version": "1.0.0", + "private": true, + "engines": { + "node": ">=22.22" + }, + "packageManager": "pnpm@10.15.0", + "license": "MIT", + "scripts": { + "start": "ts-node index.ts" + }, + "dependencies": { + "@openfeature/server-sdk": "^1.22.0", + "@posthog/openfeature-node-provider": "*", + "posthog-node": "*" + }, + "devDependencies": { + "@types/node": "20.19.7", + "ts-node": "^10.8.2", + "typescript": "^5.8.2" + } +} diff --git a/examples/example-openfeature-node/pnpm-workspace.yaml b/examples/example-openfeature-node/pnpm-workspace.yaml new file mode 100644 index 0000000000..40dc009f8d --- /dev/null +++ b/examples/example-openfeature-node/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +pnpmfile: ../.pnpmfile.cjs +minimumReleaseAge: 10080 + +blockExoticSubdeps: true +trustPolicy: no-downgrade diff --git a/examples/example-openfeature-node/tsconfig.json b/examples/example-openfeature-node/tsconfig.json new file mode 100644 index 0000000000..760dd505fa --- /dev/null +++ b/examples/example-openfeature-node/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["es2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/packages/openfeature-node-provider/.prettierrc b/packages/openfeature-node-provider/.prettierrc new file mode 100644 index 0000000000..5b7d09a033 --- /dev/null +++ b/packages/openfeature-node-provider/.prettierrc @@ -0,0 +1,7 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "printWidth": 120 +} diff --git a/packages/openfeature-node-provider/README.md b/packages/openfeature-node-provider/README.md new file mode 100644 index 0000000000..ba92fecf64 --- /dev/null +++ b/packages/openfeature-node-provider/README.md @@ -0,0 +1,13 @@ +# @posthog/openfeature-node-provider + +Official [PostHog](https://posthog.com) provider for the [OpenFeature](https://openfeature.dev) **server** +SDK ([`@openfeature/server-sdk`](https://openfeature.dev/docs/reference/technologies/server/javascript)), +backed by [`posthog-node`](https://posthog.com/docs/libraries/node). + +For the browser, use [`@posthog/openfeature-web-provider`](../openfeature-web-provider). + +## Documentation + +Installation and usage instructions live in the PostHog docs, so they stay in one place and don't drift: + +**https://posthog.com/docs/feature-flags/installation/openfeature-js** diff --git a/packages/openfeature-node-provider/jest.config.mjs b/packages/openfeature-node-provider/jest.config.mjs new file mode 100644 index 0000000000..0446edfe76 --- /dev/null +++ b/packages/openfeature-node-provider/jest.config.mjs @@ -0,0 +1,12 @@ +import { createDefaultPreset } from 'ts-jest' + +const tsJestTransformCfg = createDefaultPreset().transform + +/** @type {import("jest").Config} **/ +export default { + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + transform: { + ...tsJestTransformCfg, + }, +} diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json new file mode 100644 index 0000000000..0d96aeebfc --- /dev/null +++ b/packages/openfeature-node-provider/package.json @@ -0,0 +1,74 @@ +{ + "name": "@posthog/openfeature-node-provider", + "version": "0.0.0", + "bugs": { + "url": "https://github.com/PostHog/posthog-js/issues" + }, + "description": "Official PostHog provider for the OpenFeature server SDK (Node), backed by posthog-node", + "repository": { + "type": "git", + "url": "git+https://github.com/PostHog/posthog-js.git", + "directory": "packages/openfeature-node-provider" + }, + "homepage": "https://github.com/PostHog/posthog-js/tree/main/packages/openfeature-node-provider#readme", + "author": { + "name": "PostHog", + "email": "engineering@posthog.com", + "url": "https://posthog.com" + }, + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "scripts": { + "clean": "rimraf dist", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "build": "rslib build", + "dev": "rslib build --watch", + "test:unit": "jest", + "prepublishOnly": "pnpm lint && pnpm test:unit && pnpm build", + "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "files": [ + "dist/" + ], + "keywords": [ + "posthog", + "openfeature", + "feature-flags", + "node", + "server" + ], + "peerDependencies": { + "@openfeature/core": "^1.11.0", + "@openfeature/server-sdk": "^1.17.0", + "posthog-node": ">=5.21.2" + }, + "devDependencies": { + "@openfeature/core": "^1.11.0", + "@openfeature/server-sdk": "^1.22.0", + "@posthog-tooling/tsconfig-base": "workspace:*", + "@rslib/core": "catalog:", + "@types/jest": "catalog:", + "@types/node": "^22.15.23", + "jest": "catalog:", + "posthog-node": "workspace:*", + "ts-jest": "catalog:" + } +} diff --git a/packages/openfeature-node-provider/rslib.config.ts b/packages/openfeature-node-provider/rslib.config.ts new file mode 100644 index 0000000000..a39ae0e33b --- /dev/null +++ b/packages/openfeature-node-provider/rslib.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@rslib/core' + +export default defineConfig({ + lib: [ + { format: 'esm', syntax: 'es2023', dts: true, bundle: false }, + { format: 'cjs', syntax: 'es2023', dts: true, bundle: false }, + ], + output: { + // Server provider: targets the Node.js runtime. + target: 'node', + }, + source: { + entry: { + index: ['src/**/*', '!src/__tests__/**/*', '!src/**/*.spec.ts'], + }, + tsconfigPath: './tsconfig.build.json', + }, +}) diff --git a/packages/openfeature-node-provider/src/__tests__/provider.spec.ts b/packages/openfeature-node-provider/src/__tests__/provider.spec.ts new file mode 100644 index 0000000000..dee4cdba59 --- /dev/null +++ b/packages/openfeature-node-provider/src/__tests__/provider.spec.ts @@ -0,0 +1,310 @@ +import { ErrorCode, OpenFeature, StandardResolutionReasons, type ResolutionDetails } from '@openfeature/server-sdk' +import type { PostHog } from 'posthog-node' + +import { PostHogServerProvider } from '../provider' + +type FlagResult = { + key: string + enabled: boolean + variant?: string + payload?: unknown +} + +function makeClient(result: FlagResult | undefined): { + client: PostHog + getFeatureFlagResult: jest.Mock + reloadFeatureFlags: jest.Mock + on: jest.Mock + emit: (event: string, ...args: unknown[]) => void +} { + const getFeatureFlagResult = jest.fn().mockResolvedValue(result) + const reloadFeatureFlags = jest.fn().mockResolvedValue(undefined) + // Mirror posthog-node's event emitter: `on` registers a listener and returns + // an unsubscribe fn; `emit` fans out to registered listeners. + const listeners: Record void>> = {} + const on = jest.fn((event: string, cb: (...args: unknown[]) => void) => { + ;(listeners[event] ??= []).push(cb) + return () => { + listeners[event] = (listeners[event] ?? []).filter((fn) => fn !== cb) + } + }) + const emit = (event: string, ...args: unknown[]): void => (listeners[event] ?? []).forEach((fn) => fn(...args)) + return { + client: { getFeatureFlagResult, reloadFeatureFlags, on } as unknown as PostHog, + getFeatureFlagResult, + reloadFeatureFlags, + on, + emit, + } +} + +const CTX = { targetingKey: 'user_1' } + +type Resolve = (provider: PostHogServerProvider) => Promise> + +describe('PostHogServerProvider', () => { + it('identifies as a server provider', () => { + const { client } = makeClient(undefined) + const provider = new PostHogServerProvider(client) + expect(provider.metadata.name).toBe('PostHogServerProvider') + expect(provider.runsOn).toBe('server') + }) + + describe('resolution', () => { + it.each<[string, FlagResult, Resolve, Partial>]>([ + [ + 'boolean enabled → true / TARGETING_MATCH', + { key: 'flag', enabled: true }, + (p) => p.resolveBooleanEvaluation('flag', false, CTX), + { value: true, reason: StandardResolutionReasons.TARGETING_MATCH }, + ], + [ + 'boolean disabled → false / DEFAULT', + { key: 'flag', enabled: false }, + (p) => p.resolveBooleanEvaluation('flag', true, CTX), + { value: false, reason: StandardResolutionReasons.DEFAULT }, + ], + [ + 'string → multivariate variant', + { key: 'flag', enabled: true, variant: 'control' }, + (p) => p.resolveStringEvaluation('flag', 'x', CTX), + { value: 'control', variant: 'control' }, + ], + [ + 'number → parsed variant', + { key: 'flag', enabled: true, variant: '42' }, + (p) => p.resolveNumberEvaluation('flag', 0, CTX), + { value: 42 }, + ], + [ + 'object → JSON payload', + { key: 'flag', enabled: true, payload: { color: 'blue', count: 3 } }, + (p) => p.resolveObjectEvaluation('flag', {}, CTX), + { value: { color: 'blue', count: 3 } }, + ], + [ + 'disabled flag as string → default / DEFAULT', + { key: 'flag', enabled: false }, + (p) => p.resolveStringEvaluation('flag', 'fallback', CTX), + { value: 'fallback', reason: StandardResolutionReasons.DEFAULT }, + ], + [ + 'disabled flag as number → default / DEFAULT', + { key: 'flag', enabled: false }, + (p) => p.resolveNumberEvaluation('flag', 42, CTX), + { value: 42, reason: StandardResolutionReasons.DEFAULT }, + ], + [ + 'disabled flag as object → default / DEFAULT', + { key: 'flag', enabled: false }, + (p) => p.resolveObjectEvaluation('flag', { fallback: true }, CTX), + { value: { fallback: true }, reason: StandardResolutionReasons.DEFAULT }, + ], + ])('resolves %s', async (_name, result, resolve, expected) => { + const { client } = makeClient(result) + const details = await resolve(new PostHogServerProvider(client)) + expect(details).toMatchObject(expected) + }) + + it.each<[string, FlagResult | undefined, Resolve, ErrorCode]>([ + [ + 'string from an enabled boolean flag (no variant)', + { key: 'flag', enabled: true }, + (p) => p.resolveStringEvaluation('flag', 'x', CTX), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'number from an enabled boolean flag (no variant)', + { key: 'flag', enabled: true }, + (p) => p.resolveNumberEvaluation('flag', 0, CTX), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'number from a non-numeric variant', + { key: 'flag', enabled: true, variant: 'not-a-number' }, + (p) => p.resolveNumberEvaluation('flag', 0, CTX), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'number from an empty-string variant (Number("") is 0, not NaN)', + { key: 'flag', enabled: true, variant: '' }, + (p) => p.resolveNumberEvaluation('flag', 0, CTX), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'object from an enabled flag with no payload', + { key: 'flag', enabled: true, variant: 'x' }, + (p) => p.resolveObjectEvaluation('flag', {}, CTX), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'object from a non-object payload', + { key: 'flag', enabled: true, payload: 'a string' }, + (p) => p.resolveObjectEvaluation('flag', {}, CTX), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'missing flag (client returns undefined)', + undefined, + (p) => p.resolveBooleanEvaluation('missing', false, CTX), + ErrorCode.FLAG_NOT_FOUND, + ], + ])('throws on %s', async (_name, result, resolve, code) => { + const { client } = makeClient(result) + await expect(resolve(new PostHogServerProvider(client))).rejects.toMatchObject({ code }) + }) + }) + + describe('distinct id resolution', () => { + it('uses the targetingKey as the distinct id', async () => { + const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogServerProvider(client) + await provider.resolveBooleanEvaluation('flag', false, { targetingKey: 'abc' }) + expect(getFeatureFlagResult).toHaveBeenCalledWith('flag', 'abc', expect.any(Object)) + }) + + it('falls back to defaultDistinctId when no targetingKey is set', async () => { + const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogServerProvider(client, { defaultDistinctId: 'anonymous' }) + await provider.resolveBooleanEvaluation('flag', false, {}) + expect(getFeatureFlagResult).toHaveBeenCalledWith('flag', 'anonymous', expect.any(Object)) + }) + + it('throws TargetingKeyMissing when neither is available', async () => { + const { client } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogServerProvider(client) + await expect(provider.resolveBooleanEvaluation('flag', false, {})).rejects.toMatchObject({ + code: ErrorCode.TARGETING_KEY_MISSING, + }) + }) + }) + + describe('context mapping', () => { + it('forwards groups, groupProperties, and person properties', async () => { + const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogServerProvider(client) + await provider.resolveBooleanEvaluation('flag', false, { + targetingKey: 'user_1', + plan: 'enterprise', + groups: { organization: 'acme' }, + groupProperties: { organization: { tier: 'gold' } }, + }) + expect(getFeatureFlagResult).toHaveBeenCalledWith('flag', 'user_1', { + groups: { organization: 'acme' }, + personProperties: { plan: 'enterprise' }, + groupProperties: { organization: { tier: 'gold' } }, + sendFeatureFlagEvents: true, + }) + }) + + it('forwards non-string property values unchanged (no string coercion)', async () => { + const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogServerProvider(client) + await provider.resolveBooleanEvaluation('flag', false, { + targetingKey: 'user_1', + age: 42, + beta: true, + groups: { organization: 'acme' }, + groupProperties: { organization: { seats: 25 } }, + }) + expect(getFeatureFlagResult).toHaveBeenCalledWith( + 'flag', + 'user_1', + expect.objectContaining({ + personProperties: { age: 42, beta: true }, + groupProperties: { organization: { seats: 25 } }, + }) + ) + }) + + it('omits empty inputs and respects sendFeatureFlagEvents: false', async () => { + const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogServerProvider(client, { sendFeatureFlagEvents: false }) + await provider.resolveBooleanEvaluation('flag', false, CTX) + expect(getFeatureFlagResult).toHaveBeenCalledWith('flag', 'user_1', { + groups: undefined, + personProperties: undefined, + groupProperties: undefined, + sendFeatureFlagEvents: false, + }) + }) + }) + + describe('initialize', () => { + it('preloads flags via reloadFeatureFlags', async () => { + const { client, reloadFeatureFlags } = makeClient(undefined) + const provider = new PostHogServerProvider(client) + await provider.initialize() + expect(reloadFeatureFlags).toHaveBeenCalledTimes(1) + }) + + it('warns (without rejecting) when the client emits an error during preload', async () => { + const { client, reloadFeatureFlags, emit } = makeClient(undefined) + const preloadError = new Error('bad response') + // posthog-node never rejects reloadFeatureFlags — it surfaces the failure + // on the `error` event, which fires while the reload is in flight. + reloadFeatureFlags.mockImplementationOnce(async () => { + emit('error', preloadError) + }) + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const provider = new PostHogServerProvider(client) + await expect(provider.initialize()).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('flag preload failed'), preloadError) + } finally { + warn.mockRestore() + } + }) + + it('does not warn when preloading succeeds', async () => { + const { client } = makeClient(undefined) + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await new PostHogServerProvider(client).initialize() + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + }) + + describe('end-to-end through the OpenFeature client', () => { + afterEach(async () => { + await OpenFeature.close() + }) + + it('resolves values and details through the real client', async () => { + const { client } = makeClient({ key: 'flag', enabled: true, variant: 'control', payload: { a: 1 } }) + await OpenFeature.setProviderAndWait(new PostHogServerProvider(client)) + const ofClient = OpenFeature.getClient() + + expect(await ofClient.getBooleanValue('flag', false, CTX)).toBe(true) + expect(await ofClient.getStringValue('flag', 'x', CTX)).toBe('control') + + const details = await ofClient.getObjectDetails('flag', {}, CTX) + expect(details.value).toEqual({ a: 1 }) + expect(details.reason).toBe(StandardResolutionReasons.TARGETING_MATCH) + }) + + it('returns the default value with an error code on a type mismatch', async () => { + const { client } = makeClient({ key: 'flag', enabled: true }) + await OpenFeature.setProviderAndWait(new PostHogServerProvider(client)) + const ofClient = OpenFeature.getClient() + + const details = await ofClient.getStringDetails('flag', 'fallback', CTX) + expect(details.value).toBe('fallback') + expect(details.errorCode).toBe(ErrorCode.TYPE_MISMATCH) + expect(details.reason).toBe(StandardResolutionReasons.ERROR) + }) + + it('returns the default value with FLAG_NOT_FOUND for a missing flag', async () => { + const { client } = makeClient(undefined) + await OpenFeature.setProviderAndWait(new PostHogServerProvider(client)) + const ofClient = OpenFeature.getClient() + + const details = await ofClient.getBooleanDetails('missing', true, CTX) + expect(details.value).toBe(true) + expect(details.errorCode).toBe(ErrorCode.FLAG_NOT_FOUND) + }) + }) +}) diff --git a/packages/openfeature-node-provider/src/index.ts b/packages/openfeature-node-provider/src/index.ts new file mode 100644 index 0000000000..51683a01f9 --- /dev/null +++ b/packages/openfeature-node-provider/src/index.ts @@ -0,0 +1,8 @@ +/** + * Official PostHog provider for the OpenFeature **server** SDK + * (`@openfeature/server-sdk`), backed by `posthog-node`. + * + * import { PostHogServerProvider } from '@posthog/openfeature-node-provider' + */ +export { PostHogServerProvider, type PostHogServerProviderOptions } from './provider' +export { GROUPS_KEY, GROUP_PROPERTIES_KEY, type PostHogFlagResult, type SplitContext } from './mapping' diff --git a/packages/openfeature-node-provider/src/mapping.ts b/packages/openfeature-node-provider/src/mapping.ts new file mode 100644 index 0000000000..b7a318f54a --- /dev/null +++ b/packages/openfeature-node-provider/src/mapping.ts @@ -0,0 +1,167 @@ +/** + * Mapping between PostHog feature flag results and OpenFeature resolution + * details for the **server** provider. + * + * `posthog-node`'s `getFeatureFlagResult` returns `{ key, enabled, variant?, + * payload? }`, and this module turns that into the OpenFeature + * `ResolutionDetails` shape (and the reserved-attribute context split). + * + * Everything is imported from `@openfeature/core` (a peer dependency shared by + * the server SDK) so the error classes thrown here are the same identities the + * active SDK catches. + */ +import { + FlagNotFoundError, + StandardResolutionReasons, + TypeMismatchError, + type EvaluationContext, + type JsonValue, + type ResolutionDetails, + type ResolutionReason, +} from '@openfeature/core' + +/** + * The minimal flag-result shape returned by `posthog-node`'s + * `getFeatureFlagResult`. The client's result structurally satisfies this, so + * the SDK does not need to be imported here. + */ +export interface PostHogFlagResult { + readonly key: string + readonly enabled: boolean + readonly variant?: string + readonly payload?: unknown +} + +/** + * Reserved evaluation-context attribute keys. Every other attribute (besides + * the standard `targetingKey`) is forwarded to PostHog as a person property. + */ +export const GROUPS_KEY = 'groups' +export const GROUP_PROPERTIES_KEY = 'groupProperties' + +/** PostHog evaluation inputs derived from an OpenFeature evaluation context. */ +export interface SplitContext { + personProperties: Record + groups: Record + groupProperties: Record> +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Split an OpenFeature evaluation context into PostHog's evaluation inputs: + * - reserved `groups` -> PostHog `groups` + * - reserved `groupProperties` -> PostHog `groupProperties` + * - every other attribute -> PostHog `personProperties` + * + * `targetingKey` is consumed separately (as the distinct id) and never becomes + * a person property. + */ +export function splitContext(context?: EvaluationContext): SplitContext { + if (!context) { + return { personProperties: {}, groups: {}, groupProperties: {} } + } + const { + targetingKey: _targetingKey, + [GROUPS_KEY]: rawGroups, + [GROUP_PROPERTIES_KEY]: rawGroupProperties, + ...rest + } = context + return { + personProperties: rest, + groups: isRecord(rawGroups) ? (rawGroups as Record) : {}, + groupProperties: isRecord(rawGroupProperties) + ? (rawGroupProperties as Record>) + : {}, + } +} + +/** + * Map PostHog's enabled state to an OpenFeature reason. PostHog's JS + * `FeatureFlagResult` carries no free-text reason (unlike the Python client), so + * an enabled flag means a targeting condition matched and a disabled one falls + * back to the default rollout. + */ +function reasonFor(result: PostHogFlagResult): ResolutionReason { + return result.enabled ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.DEFAULT +} + +/** + * A `undefined` result means the flag does not exist (or was archived) — + * `getFeatureFlagResult` returns a populated result with `enabled: false` for a + * flag that exists but did not match. Surface the former as the OpenFeature + * `FLAG_NOT_FOUND` error so callers get their default value. + */ +function ensureResolved(result: PostHogFlagResult | undefined, flagKey: string): PostHogFlagResult { + if (result == null) { + throw new FlagNotFoundError(`Flag '${flagKey}' not found.`) + } + return result +} + +export function resolveBooleanDetails( + result: PostHogFlagResult | undefined, + flagKey: string +): ResolutionDetails { + const resolved = ensureResolved(result, flagKey) + return { value: resolved.enabled, variant: resolved.variant, reason: reasonFor(resolved) } +} + +export function resolveStringDetails( + result: PostHogFlagResult | undefined, + flagKey: string, + defaultValue: string +): ResolutionDetails { + const resolved = ensureResolved(result, flagKey) + if (resolved.variant === undefined) { + if (!resolved.enabled) { + // A disabled or unmatched flag has no variant. Resolve to the caller's + // default (per the OpenFeature spec) rather than throwing — a throw would + // set reason=ERROR and fire every registered error hook on an ordinary + // disabled-flag read. + return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT } + } + // An enabled boolean flag has no string variant: a genuine type mismatch. + throw new TypeMismatchError(`Flag '${flagKey}' has no string variant (boolean flag).`) + } + return { value: resolved.variant, variant: resolved.variant, reason: reasonFor(resolved) } +} + +export function resolveNumberDetails( + result: PostHogFlagResult | undefined, + flagKey: string, + defaultValue: number +): ResolutionDetails { + const resolved = ensureResolved(result, flagKey) + if (resolved.variant === undefined) { + if (!resolved.enabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT } + } + throw new TypeMismatchError(`Flag '${flagKey}' has no variant to parse as a number.`) + } + const value = Number(resolved.variant) + // `Number('')` and `Number(' ')` are 0, not NaN, so guard blank variants + // explicitly rather than silently resolving them to 0. + if (resolved.variant.trim() === '' || !Number.isFinite(value)) { + throw new TypeMismatchError(`Flag '${flagKey}' variant '${resolved.variant}' is not a valid number.`) + } + return { value, variant: resolved.variant, reason: reasonFor(resolved) } +} + +export function resolveObjectDetails( + result: PostHogFlagResult | undefined, + flagKey: string, + defaultValue: T +): ResolutionDetails { + const resolved = ensureResolved(result, flagKey) + const payload = resolved.payload + if (typeof payload !== 'object' || payload === null) { + if (!resolved.enabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT } + } + throw new TypeMismatchError(`Flag '${flagKey}' has no object/JSON payload.`) + } + return { value: payload as T, variant: resolved.variant, reason: reasonFor(resolved) } +} diff --git a/packages/openfeature-node-provider/src/provider.ts b/packages/openfeature-node-provider/src/provider.ts new file mode 100644 index 0000000000..79a7687201 --- /dev/null +++ b/packages/openfeature-node-provider/src/provider.ts @@ -0,0 +1,168 @@ +/** + * Official PostHog provider for the OpenFeature **server** SDK + * (`@openfeature/server-sdk`), backed by a configured `posthog-node` client. + * + * This is the JS counterpart of the Python `PostHogProvider`: the server model + * is stateless and multi-user, so the distinct id arrives per evaluation (from + * the context's `targetingKey`) and resolution is asynchronous. + */ +import { + TargetingKeyMissingError, + type EvaluationContext, + type JsonValue, + type Provider, + type ResolutionDetails, +} from '@openfeature/server-sdk' +import type { PostHog } from 'posthog-node' + +import { + resolveBooleanDetails, + resolveNumberDetails, + resolveObjectDetails, + resolveStringDetails, + splitContext, + type PostHogFlagResult, +} from './mapping' + +export interface PostHogServerProviderOptions { + /** + * Distinct id to use when the evaluation context has no `targetingKey`. When + * omitted, a missing targeting key raises `TargetingKeyMissingError` (the + * OpenFeature-idiomatic behaviour). Set a value (e.g. `'anonymous'`) to opt + * into anonymous evaluation. + */ + defaultDistinctId?: string + /** + * Forwarded to `getFeatureFlagResult` to control `$feature_flag_called` + * capture. Defaults to `true` so PostHog flag analytics (and experiments) + * keep working. + */ + sendFeatureFlagEvents?: boolean +} + +/** + * OpenFeature server provider backed by a configured `posthog-node` client. + * + * The caller owns the PostHog client lifecycle: construct and configure the + * client yourself (project key, `personalApiKey` for local evaluation, `host`, + * ...), then hand it to this provider. + * + * Evaluation-context mapping: + * - `targetingKey` -> PostHog `distinctId` + * - reserved `groups` -> PostHog `groups` + * - reserved `groupProperties` -> PostHog `groupProperties` + * - every other attribute -> PostHog `personProperties` + * + * Flag-type mapping (all via `getFeatureFlagResult`): + * - boolean -> `enabled` + * - string -> the multivariate `variant` key + * - number -> the `variant` parsed as a number + * - object -> the flag's JSON `payload` + */ +export class PostHogServerProvider implements Provider { + public readonly runsOn = 'server' + public readonly metadata = { name: 'PostHogServerProvider' } as const + + private readonly _client: PostHog + private readonly _defaultDistinctId?: string + private readonly _sendFeatureFlagEvents: boolean + + constructor(client: PostHog, options: PostHogServerProviderOptions = {}) { + this._client = client + this._defaultDistinctId = options.defaultDistinctId + this._sendFeatureFlagEvents = options.sendFeatureFlagEvents ?? true + } + + async initialize(): Promise { + // Preload locally-evaluated flag definitions. reloadFeatureFlags() safely + // no-ops without a personalApiKey, and never rejects: posthog-node swallows + // its poller errors internally and surfaces the client/network-error class + // (ClientError: 401/403/429, bad response) via its `error` event. So listen + // on that channel around the reload to surface a genuine misconfiguration + // on a client set up for local evaluation. Remote evaluation still works + // regardless, so this never blocks readiness. + // + // Caveat: posthog-node only routes ClientError through `error`, so a raw + // network/DNS failure on a bad `host` still won't surface here. + let preloadError: Error | undefined + const unsubscribe = this._client.on('error', (err: Error) => { + preloadError = err + }) + try { + await this._client.reloadFeatureFlags() + } finally { + unsubscribe() + } + if (preloadError) { + // eslint-disable-next-line no-console + console.warn( + '[PostHogServerProvider] initialize() flag preload failed; remote evaluation still available.', + preloadError + ) + } + } + + async resolveBooleanEvaluation( + flagKey: string, + _defaultValue: boolean, + context: EvaluationContext + ): Promise> { + return resolveBooleanDetails(await this._evaluate(flagKey, context), flagKey) + } + + async resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext + ): Promise> { + return resolveStringDetails(await this._evaluate(flagKey, context), flagKey, defaultValue) + } + + async resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext + ): Promise> { + return resolveNumberDetails(await this._evaluate(flagKey, context), flagKey, defaultValue) + } + + async resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext + ): Promise> { + return resolveObjectDetails(await this._evaluate(flagKey, context), flagKey, defaultValue) + } + + private async _evaluate(flagKey: string, context: EvaluationContext): Promise { + const distinctId = this._resolveDistinctId(context) + const { personProperties, groups, groupProperties } = splitContext(context) + // posthog-node's public option type narrows person/group properties to + // `Record`, but the flags API accepts any JSON value and + // matches on the native type (a numeric `age >= 21` filter, a boolean, ...). + // OpenFeature contexts legitimately carry non-string values, so forward them + // unchanged — coercing to strings here would break those comparisons. The + // assertions only bridge the too-narrow upstream type; no values are altered. + return this._client.getFeatureFlagResult(flagKey, distinctId, { + groups: nonEmpty(groups), + personProperties: nonEmpty(personProperties) as Record | undefined, + groupProperties: nonEmpty(groupProperties) as Record> | undefined, + sendFeatureFlagEvents: this._sendFeatureFlagEvents, + }) + } + + private _resolveDistinctId(context: EvaluationContext): string { + if (context?.targetingKey) { + return context.targetingKey + } + if (this._defaultDistinctId !== undefined) { + return this._defaultDistinctId + } + throw new TargetingKeyMissingError('No targetingKey in evaluation context and no defaultDistinctId configured.') + } +} + +/** Returns the record if it has any keys, otherwise `undefined`. */ +function nonEmpty(value: T): T | undefined { + return Object.keys(value).length > 0 ? value : undefined +} diff --git a/packages/openfeature-node-provider/tsconfig.build.json b/packages/openfeature-node-provider/tsconfig.build.json new file mode 100644 index 0000000000..fcd4c2a7c6 --- /dev/null +++ b/packages/openfeature-node-provider/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["./src/**/*"], + "exclude": ["./src/__tests__/**/*", "./src/**/*.spec.ts"] +} diff --git a/packages/openfeature-node-provider/tsconfig.json b/packages/openfeature-node-provider/tsconfig.json new file mode 100644 index 0000000000..58fe5a0c64 --- /dev/null +++ b/packages/openfeature-node-provider/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@posthog-tooling/tsconfig-base", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "skipLibCheck": true, + "lib": ["ES2023"] + }, + "include": ["./src/**/*", "rslib.config.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54328ee2c2..01f206a7cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -787,6 +787,36 @@ importers: specifier: ^4.1.2 version: 4.1.3(@parcel/watcher@2.5.6)(@types/node@20.19.9)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.29))(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(eslint@9.37.0(jiti@2.6.1))(ioredis@5.8.1)(lightningcss@1.27.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.53.3)(terser@5.48.0)(typescript@5.8.2)(vite@7.3.1(@types/node@20.19.9)(jiti@2.6.1)(lightningcss@1.27.0)(terser@5.48.0)(yaml@2.8.0))(yaml@2.8.0) + packages/openfeature-node-provider: + devDependencies: + '@openfeature/core': + specifier: ^1.11.0 + version: 1.11.0 + '@openfeature/server-sdk': + specifier: ^1.22.0 + version: 1.22.0(@openfeature/core@1.11.0) + '@posthog-tooling/tsconfig-base': + specifier: workspace:* + version: link:../../tooling/tsconfig-base + '@rslib/core': + specifier: 'catalog:' + version: 0.10.6(@microsoft/api-extractor@7.58.9(@types/node@22.16.5))(typescript@5.8.2) + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/node': + specifier: ^22.15.23 + version: 22.16.5 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.16.5)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@22.16.5)(typescript@5.8.2)) + posthog-node: + specifier: workspace:* + version: link:../node + ts-jest: + specifier: 'catalog:' + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.16.5)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@22.16.5)(typescript@5.8.2)))(typescript@5.8.2) + packages/openfeature-web-provider: devDependencies: '@openfeature/core': @@ -4509,6 +4539,12 @@ packages: '@openfeature/core@1.11.0': resolution: {integrity: sha512-P0u3/ht/oZCQT89fOed+laLk0kZR529a825cS02uPDglxXbE97irWYpDAeRGGVETIzKfuy+H2g8c3Ccv/tXJNQ==} + '@openfeature/server-sdk@1.22.0': + resolution: {integrity: sha512-YBrf6SQkn0FNB/dRAtLEs41dvFMUE8CrQTwI+iLaMFUIqWlqGNJfGnulKSneEKS+2OgKTAC6DdmKcZ6tK7kBcg==} + engines: {node: '>=20'} + peerDependencies: + '@openfeature/core': ^1.11.0 + '@openfeature/web-sdk@1.9.0': resolution: {integrity: sha512-FCrNfqvE/thHVfCNU0KKx1SD7rk+1wE2UaR5B5OPZl917QJv6AsKRwaI3N+SVgwXWI07GgtXP6hNlTVb49PGhg==} peerDependencies: @@ -21639,6 +21675,10 @@ snapshots: '@openfeature/core@1.11.0': {} + '@openfeature/server-sdk@1.22.0(@openfeature/core@1.11.0)': + dependencies: + '@openfeature/core': 1.11.0 + '@openfeature/web-sdk@1.9.0(@openfeature/core@1.11.0)': dependencies: '@openfeature/core': 1.11.0