Skip to content

Commit 44bada7

Browse files
authored
feature(adapter-posthog): Add PostHog adapter (#114)
* feature(adapter-posthog): PostHog Adapter * pass flagValue=undefined to getFeatureFlagPayload * Map NEXT_PUBLIC_POSTHOG_HOST to a PostHog app hostname by default, making appHost optional in getProviderData * Do not expose synchronous client in the Adapter interface (make it async later) * Add tests
1 parent 785ba56 commit 44bada7

File tree

12 files changed

+2043
-959
lines changed

12 files changed

+2043
-959
lines changed

.changeset/twenty-cherries-pull.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@flags-sdk/posthog': minor
3+
---
4+
5+
Publish PostHog adapter

packages/adapter-posthog/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# @flags-sdk/posthog
2+
3+
## 0.1.0
4+
5+
### Minor Changes
6+
7+
- postHogAdapter: Default adapter is available
8+
- postHogAdapter.isFeatureEnabled: Check if a feature flag is enabled
9+
- postHogAdapter.featureFlagValue: Get the value of a feature flag
10+
- postHogAdapter.featureFlagPayload: Get the payload of a feature flag
11+
- postHogAdapter.client: Access the PostHog client

packages/adapter-posthog/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Flags SDK — PostHog Adapter
2+
3+
The PostHog adapter for [Flags SDK](https://flags-sdk.dev/) supports dynamic server side feature flags powered by [PostHog](https://posthog.com/).
4+
5+
## Setup
6+
7+
Install the adapter
8+
9+
```bash
10+
pnpm i @flags-sdk/posthog
11+
```
12+
13+
## Example Usage
14+
15+
```ts
16+
import { flag } from 'flags/next';
17+
import { postHogAdapter } from '@flags-sdk/posthog';
18+
19+
export const marketingGate = flag<boolean>({
20+
// The key in PostHog
21+
key: 'my_posthog_flag_key_here',
22+
// The PostHog feature to use (isFeatureEnabled, featureFlagValue, featureFlagPayload)
23+
adapter: postHogAdapter.featureFlagValue(),
24+
});
25+
```
26+
27+
## Runtimes
28+
29+
| Runtime | Supported |
30+
| ------------ | --------- |
31+
| Node ||
32+
| Edge Runtime ||
33+
34+
Note: `posthog-node` does not support the Edge Runtime.
35+
36+
To use with middleware and precompute, read more: [Middleware now supports Node.js](https://vercel.com/changelog/middleware-now-supports-node-js)
37+
38+
## Documentation
39+
40+
View more PostHog documentation at [posthog.com](https://posthog.com?utm_source=github&utm_campaign=flags_sdk).

packages/adapter-posthog/package.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"name": "@flags-sdk/posthog",
3+
"version": "0.0.1",
4+
"description": "PostHog adapter for the Flags SDK",
5+
"keywords": [
6+
"flags-sdk",
7+
"posthog",
8+
"vercel",
9+
"edge config",
10+
"feature flags",
11+
"flags"
12+
],
13+
"homepage": "https://flags-sdk.dev",
14+
"bugs": {
15+
"url": "https://github.com/vercel/flags/issues"
16+
},
17+
"repository": {
18+
"type": "git",
19+
"url": "git+https://github.com/vercel/flags.git"
20+
},
21+
"license": "MIT",
22+
"author": "",
23+
"sideEffects": false,
24+
"type": "module",
25+
"exports": {
26+
".": {
27+
"import": "./dist/index.js",
28+
"require": "./dist/index.cjs"
29+
}
30+
},
31+
"main": "./dist/index.js",
32+
"typesVersions": {
33+
"*": {
34+
".": [
35+
"dist/*.d.ts",
36+
"dist/*.d.cts"
37+
]
38+
}
39+
},
40+
"files": [
41+
"dist",
42+
"CHANGELOG.md"
43+
],
44+
"scripts": {
45+
"build": "rimraf dist && tsup",
46+
"dev": "tsup --watch --clean=false",
47+
"eslint": "eslint-runner",
48+
"eslint:fix": "eslint-runner --fix",
49+
"test": "vitest --run",
50+
"test:watch": "vitest",
51+
"type-check": "tsc --noEmit"
52+
},
53+
"dependencies": {
54+
"@vercel/edge-config": "^1.4.0",
55+
"posthog-node": "4.11.1"
56+
},
57+
"devDependencies": {
58+
"@types/node": "22.14.0",
59+
"eslint-config-custom": "workspace:*",
60+
"flags": "workspace:*",
61+
"msw": "2.6.4",
62+
"rimraf": "6.0.1",
63+
"tsconfig": "workspace:*",
64+
"tsup": "8.0.1",
65+
"typescript": "5.8.2",
66+
"vite": "6.2.5",
67+
"vitest": "1.4.0"
68+
}
69+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { ReadonlyHeaders, ReadonlyRequestCookies } from 'flags';
2+
import { expect, it, describe, vi, beforeAll } from 'vitest';
3+
import { postHogAdapter, type PostHogEntities } from '.';
4+
5+
const postHogClientMock = {
6+
isFeatureEnabled: vi.fn(),
7+
getFeatureFlag: vi.fn(),
8+
getFeatureFlagPayload: vi.fn(),
9+
getRemoteConfigPayload: vi.fn(),
10+
};
11+
12+
vi.mock('posthog-node', () => ({
13+
PostHog: vi.fn(() => postHogClientMock),
14+
}));
15+
16+
describe('postHogAdapter', () => {
17+
it('isFeatureEnabled should be a function', () => {
18+
expect(postHogAdapter.isFeatureEnabled).toBeInstanceOf(Function);
19+
});
20+
21+
describe('with a missing environment', () => {
22+
it('should throw an error', () => {
23+
expect(() => postHogAdapter.isFeatureEnabled()).toThrowError(
24+
'PostHog Adapter: Missing NEXT_PUBLIC_POSTHOG_KEY environment variable',
25+
);
26+
});
27+
});
28+
29+
describe('with an environment', () => {
30+
beforeAll(() => {
31+
process.env.NEXT_PUBLIC_POSTHOG_KEY = 'test-posthog-key';
32+
process.env.NEXT_PUBLIC_POSTHOG_HOST = 'https://us.i.posthog.com';
33+
});
34+
35+
describe('isFeatureEnabled', () => {
36+
it('should decide', async () => {
37+
postHogClientMock.isFeatureEnabled.mockReturnValue(true);
38+
39+
const valuePromise = postHogAdapter.isFeatureEnabled().decide({
40+
key: 'test-flag',
41+
headers: {} as ReadonlyHeaders,
42+
cookies: {} as ReadonlyRequestCookies,
43+
entities: {} as PostHogEntities,
44+
defaultValue: false,
45+
});
46+
47+
await expect(valuePromise).resolves.toEqual(true);
48+
expect(postHogClientMock.isFeatureEnabled).toHaveBeenCalled();
49+
});
50+
});
51+
52+
describe('featureValue', () => {
53+
it('should decide', async () => {
54+
postHogClientMock.getFeatureFlag.mockReturnValue('test_group_1');
55+
56+
const valuePromise = postHogAdapter.featureFlagValue().decide({
57+
key: 'test-flag',
58+
headers: {} as ReadonlyHeaders,
59+
cookies: {} as ReadonlyRequestCookies,
60+
entities: {} as PostHogEntities,
61+
defaultValue: false,
62+
});
63+
64+
await expect(valuePromise).resolves.toEqual('test_group_1');
65+
expect(postHogClientMock.getFeatureFlag).toHaveBeenCalled();
66+
});
67+
});
68+
69+
describe('featurePayload', () => {
70+
it('should decide', async () => {
71+
postHogClientMock.getFeatureFlag.mockReturnValue('test_group_1');
72+
postHogClientMock.getFeatureFlagPayload.mockReturnValue({
73+
text: 'hello world',
74+
});
75+
76+
const valuePromise = postHogAdapter
77+
.featureFlagPayload<string>(
78+
(payload) => (payload as { text: string }).text,
79+
)
80+
.decide({
81+
key: 'test-flag',
82+
headers: {} as ReadonlyHeaders,
83+
cookies: {} as ReadonlyRequestCookies,
84+
entities: {} as PostHogEntities,
85+
defaultValue: 'default',
86+
});
87+
88+
await expect(valuePromise).resolves.toEqual('hello world');
89+
expect(postHogClientMock.getFeatureFlag).toHaveBeenCalled();
90+
expect(postHogClientMock.getFeatureFlagPayload).toHaveBeenCalled();
91+
});
92+
});
93+
});
94+
});

packages/adapter-posthog/src/index.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { PostHog } from 'posthog-node';
2+
import type { PostHogAdapter, PostHogEntities, JsonType } from './types';
3+
4+
export { getProviderData } from './provider';
5+
export type { PostHogEntities, JsonType };
6+
7+
export function createPostHogAdapter({
8+
postHogKey,
9+
postHogOptions,
10+
}: {
11+
postHogKey: ConstructorParameters<typeof PostHog>[0];
12+
postHogOptions: ConstructorParameters<typeof PostHog>[1];
13+
}): PostHogAdapter {
14+
const client = new PostHog(postHogKey, postHogOptions);
15+
16+
const result: PostHogAdapter = {
17+
isFeatureEnabled: (options) => {
18+
return {
19+
async decide({ key, entities, defaultValue }): Promise<boolean> {
20+
const parsedEntities = parseEntities(entities);
21+
const result =
22+
(await client.isFeatureEnabled(
23+
trimKey(key),
24+
parsedEntities.distinctId,
25+
options,
26+
)) ?? defaultValue;
27+
if (result === undefined) {
28+
throw new Error(
29+
`PostHog Adapter isFeatureEnabled returned undefined for ${trimKey(key)} and no default value was provided.`,
30+
);
31+
}
32+
return result;
33+
},
34+
};
35+
},
36+
featureFlagValue: (options) => {
37+
return {
38+
async decide({ key, entities, defaultValue }) {
39+
const parsedEntities = parseEntities(entities);
40+
const flagValue = await client.getFeatureFlag(
41+
trimKey(key),
42+
parsedEntities.distinctId,
43+
options,
44+
);
45+
if (flagValue === undefined) {
46+
if (typeof defaultValue !== 'undefined') {
47+
return defaultValue;
48+
}
49+
throw new Error(
50+
`PostHog Adapter featureFlagValue found undefined for ${trimKey(key)} and no default value was provided.`,
51+
);
52+
}
53+
return flagValue;
54+
},
55+
};
56+
},
57+
featureFlagPayload: (getValue, options) => {
58+
return {
59+
async decide({ key, entities, defaultValue }) {
60+
const parsedEntities = parseEntities(entities);
61+
const payload = await client.getFeatureFlagPayload(
62+
trimKey(key),
63+
parsedEntities.distinctId,
64+
undefined,
65+
options,
66+
);
67+
if (!payload) {
68+
if (typeof defaultValue !== 'undefined') {
69+
return defaultValue;
70+
}
71+
throw new Error(
72+
`PostHog Adapter featureFlagPayload found undefined for ${trimKey(key)} and no default value was provided.`,
73+
);
74+
}
75+
return getValue(payload);
76+
},
77+
};
78+
},
79+
};
80+
81+
return result;
82+
}
83+
84+
function parseEntities(entities?: PostHogEntities): PostHogEntities {
85+
if (!entities) {
86+
throw new Error(
87+
'PostHog Adapter: Missing entities, ' +
88+
'flag must be defined with an identify() function.',
89+
);
90+
}
91+
return entities;
92+
}
93+
94+
function assertEnv(name: string): string {
95+
const value = process.env[name];
96+
if (!value) {
97+
throw new Error(`PostHog Adapter: Missing ${name} environment variable`);
98+
}
99+
return value;
100+
}
101+
102+
// Read until the first `.`
103+
// This supports defining multiple flags with the same key
104+
// Ex. with my-flag.is-enabled, my-flag.variant and my-flag.payload
105+
function trimKey(key: string): string {
106+
return key.split('.')[0] as string;
107+
}
108+
109+
let defaultPostHogAdapter: ReturnType<typeof createPostHogAdapter> | undefined;
110+
function getOrCreateDefaultAdapter() {
111+
if (!defaultPostHogAdapter) {
112+
defaultPostHogAdapter = createPostHogAdapter({
113+
postHogKey: assertEnv('NEXT_PUBLIC_POSTHOG_KEY'),
114+
postHogOptions: {
115+
host: assertEnv('NEXT_PUBLIC_POSTHOG_HOST'),
116+
personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY,
117+
featureFlagsPollingInterval: 10_000,
118+
// Presumption: Server IP is likely not a good proxy for user location
119+
disableGeoip: true,
120+
},
121+
});
122+
}
123+
return defaultPostHogAdapter;
124+
}
125+
126+
export const postHogAdapter: PostHogAdapter = {
127+
isFeatureEnabled: (...args) =>
128+
getOrCreateDefaultAdapter().isFeatureEnabled(...args),
129+
featureFlagValue: (...args) =>
130+
getOrCreateDefaultAdapter().featureFlagValue(...args),
131+
featureFlagPayload: (...args) =>
132+
getOrCreateDefaultAdapter().featureFlagPayload(...args),
133+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { getAppHost } from '.';
3+
4+
describe('getAppHost', () => {
5+
it('maps us.i.posthog.com', () => {
6+
expect(getAppHost('https://us.i.posthog.com')).toBe(
7+
'https://us.posthog.com',
8+
);
9+
});
10+
it('maps eu.i.posthog.com', () => {
11+
expect(getAppHost('https://eu.i.posthog.com')).toBe(
12+
'https://eu.posthog.com',
13+
);
14+
});
15+
});

0 commit comments

Comments
 (0)