From 7e56e05dcf361fd03c1f313879e66593974224e6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 21 Jul 2026 07:22:46 +0300 Subject: [PATCH 01/13] @flags-sdk/posthog: upgrade posthog-node --- .changeset/spicy-donkeys-refuse.md | 5 + packages/adapter-posthog/package.json | 2 +- pnpm-lock.yaml | 171 ++++++-------------------- 3 files changed, 41 insertions(+), 137 deletions(-) create mode 100644 .changeset/spicy-donkeys-refuse.md diff --git a/.changeset/spicy-donkeys-refuse.md b/.changeset/spicy-donkeys-refuse.md new file mode 100644 index 00000000..7e5c8e38 --- /dev/null +++ b/.changeset/spicy-donkeys-refuse.md @@ -0,0 +1,5 @@ +--- +"@flags-sdk/posthog": major +--- + +Upgrade posthog-node from v4.11.1 to v5.45.0 diff --git a/packages/adapter-posthog/package.json b/packages/adapter-posthog/package.json index 86ee3e23..0c990c26 100644 --- a/packages/adapter-posthog/package.json +++ b/packages/adapter-posthog/package.json @@ -51,7 +51,7 @@ }, "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/pnpm-lock.yaml b/pnpm-lock.yaml index 897d3384..845023c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -661,8 +661,8 @@ importers: 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 +1116,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 +1132,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 +1149,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'} @@ -2076,6 +2059,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 +4847,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 +4969,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 +4977,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 +5196,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 +5505,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 +5966,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 +6282,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 +7204,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 +7717,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 +7741,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 +9093,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 +9104,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 +9128,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 @@ -10073,6 +10016,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 +13100,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 +13242,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 +13457,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 +13792,6 @@ snapshots: dependencies: robust-predicates: 3.0.2 - delayed-stream@1.0.0: {} - dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -13974,7 +13899,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 +13975,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 +14108,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 +14136,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 +14147,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 +14165,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 +14406,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 +14544,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 +14567,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 +14836,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 +14896,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 +15016,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 +15950,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 +16569,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 +16585,6 @@ snapshots: property-information@7.1.0: {} - proxy-from-env@2.1.0: {} - psl@1.15.0: dependencies: punycode: 2.3.1 From 62235767c7cdcfc18031d2c1e91e27a7f8710404 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 21 Jul 2026 08:02:07 +0300 Subject: [PATCH 02/13] @flags-sdk/posthog: make local vs remote evaluation explicit The default adapter passed POSTHOG_PERSONAL_API_KEY into the runtime posthog-node client, which enabled local evaluation and started a feature-flag poller in every warm process. On serverless this produced large, traffic-independent PostHog feature flag request volume. Local evaluation is now opt-in via POSTHOG_SECRET_KEY; without it the adapter evaluates remotely. POSTHOG_PERSONAL_API_KEY is used only by getProviderData (Flags Explorer) and no longer affects runtime evaluation. Drops the forced 10s poll interval in favor of the v5 default. Updates docs, README, and tests. --- .../posthog-explicit-local-evaluation.md | 19 ++++++ apps/docs/content/docs/providers/posthog.mdx | 48 +++++++++++++++ packages/adapter-posthog/README.md | 15 +++++ packages/adapter-posthog/src/index.test.ts | 60 ++++++++++++++++++- packages/adapter-posthog/src/index.ts | 14 ++++- 5 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 .changeset/posthog-explicit-local-evaluation.md diff --git a/.changeset/posthog-explicit-local-evaluation.md b/.changeset/posthog-explicit-local-evaluation.md new file mode 100644 index 00000000..1c07d09a --- /dev/null +++ b/.changeset/posthog-explicit-local-evaluation.md @@ -0,0 +1,19 @@ +--- +"@flags-sdk/posthog": major +--- + +Make local vs. remote flag evaluation an explicit choice. + +Previously the default `postHogAdapter` passed `POSTHOG_PERSONAL_API_KEY` into the +runtime `posthog-node` client, which enabled local evaluation and started a +feature-flag poller in every warm server process. On serverless this could generate +a large, traffic-independent volume of PostHog feature flag requests. + +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. diff --git a/apps/docs/content/docs/providers/posthog.mdx b/apps/docs/content/docs/providers/posthog.mdx index 3324e5c1..5111cb7e 100644 --- a/apps/docs/content/docs/providers/posthog.mdx +++ b/apps/docs/content/docs/providers/posthog.mdx @@ -107,6 +107,54 @@ export default async function Page() { } ``` +## Evaluation modes + +The adapter can evaluate flags in one of two modes. The mode is an explicit choice, +not a side effect of any other credential. + +### Remote evaluation (default) + +With only `NEXT_PUBLIC_POSTHOG_KEY` and `NEXT_PUBLIC_POSTHOG_HOST` set, each flag +evaluation calls PostHog to resolve the value. There is no background polling, so +PostHog request volume scales with your traffic. This is the recommended default for +serverless deployments. + +### Local evaluation + +Set a PostHog project secret key to opt in to local evaluation: + +```bash title=".env.local" +# Settings > Project > Feature flags secret key +POSTHOG_SECRET_KEY=phs_... +``` + +In this mode `posthog-node` periodically polls flag definitions (roughly every 30s) +and evaluates flags in-process, which lowers per-evaluation latency. Be aware that +this polling runs **per warm server process** and counts against your PostHog feature +flag request quota independently of how many users you have — on serverless platforms +many warm instances can each run their own poller. + +You can also enable it explicitly with `createPostHogAdapter`: + +```ts title="flags.ts" +import { createPostHogAdapter } from '@flags-sdk/posthog' + +const postHogAdapter = createPostHogAdapter({ + postHogKey: process.env.NEXT_PUBLIC_POSTHOG_KEY, + postHogOptions: { + host: process.env.NEXT_PUBLIC_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. + + ## Flags Explorer ### How to inform the Flags Explorer about flags diff --git a/packages/adapter-posthog/README.md b/packages/adapter-posthog/README.md index 3e458fd7..89ffeff2 100644 --- a/packages/adapter-posthog/README.md +++ b/packages/adapter-posthog/README.md @@ -24,6 +24,21 @@ export const marketingGate = flag({ }); ``` +## Evaluation modes + +The mode is an explicit choice, not a side effect of any other credential. + +- **Remote (default):** with only `NEXT_PUBLIC_POSTHOG_KEY` and `NEXT_PUBLIC_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/src/index.test.ts b/packages/adapter-posthog/src/index.test.ts index cd4f521d..361fa0d4 100644 --- a/packages/adapter-posthog/src/index.test.ts +++ b/packages/adapter-posthog/src/index.test.ts @@ -1,5 +1,5 @@ import type { ReadonlyHeaders, ReadonlyRequestCookies } from 'flags'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { type PostHogEntities, postHogAdapter } from '.'; const postHogClientMock = { @@ -96,3 +96,61 @@ describe('postHogAdapter', () => { }); }); }); + +describe('default adapter evaluation mode', () => { + const OLD_ENV = process.env; + + beforeAll(() => { + process.env.NEXT_PUBLIC_POSTHOG_KEY = 'test-posthog-key'; + process.env.NEXT_PUBLIC_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('.'); + // Accessing a method constructs the underlying PostHog client synchronously. + freshAdapter.isFeatureEnabled(); + 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..f54fc5d8 100644 --- a/packages/adapter-posthog/src/index.ts +++ b/packages/adapter-posthog/src/index.ts @@ -112,12 +112,22 @@ function trimKey(key: string): string { let defaultPostHogAdapter: ReturnType | undefined; function getOrCreateDefaultAdapter() { 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'), postHogOptions: { host: assertEnv('NEXT_PUBLIC_POSTHOG_HOST'), - personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY, - featureFlagsPollingInterval: 10_000, + secretKey, + enableLocalEvaluation: Boolean(secretKey), // Presumption: Server IP is likely not a good proxy for user location disableGeoip: true, }, From 0b42dcb47297a799fc43e8cdb9c698e67bec95d7 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 21 Jul 2026 08:04:29 +0300 Subject: [PATCH 03/13] remove unused @vercel/edge-config dependency --- packages/adapter-posthog/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/adapter-posthog/package.json b/packages/adapter-posthog/package.json index 0c990c26..77b9a701 100644 --- a/packages/adapter-posthog/package.json +++ b/packages/adapter-posthog/package.json @@ -50,7 +50,6 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@vercel/edge-config": "^1.4.3", "posthog-node": "5.45.0" }, "devDependencies": { From 6838df5fa95870046958e5541e6cd25c6ff4a817 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 21 Jul 2026 09:21:48 +0300 Subject: [PATCH 04/13] add remote vs local tradeoffs --- apps/docs/content/docs/providers/posthog.mdx | 37 ++++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/apps/docs/content/docs/providers/posthog.mdx b/apps/docs/content/docs/providers/posthog.mdx index 5111cb7e..6055808d 100644 --- a/apps/docs/content/docs/providers/posthog.mdx +++ b/apps/docs/content/docs/providers/posthog.mdx @@ -109,15 +109,13 @@ export default async function Page() { ## Evaluation modes -The adapter can evaluate flags in one of two modes. The mode is an explicit choice, -not a side effect of any other credential. +PostHog can evaluate flags remotely or locally. ### Remote evaluation (default) -With only `NEXT_PUBLIC_POSTHOG_KEY` and `NEXT_PUBLIC_POSTHOG_HOST` set, each flag -evaluation calls PostHog to resolve the value. There is no background polling, so -PostHog request volume scales with your traffic. This is the recommended default for -serverless deployments. +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 @@ -128,11 +126,22 @@ Set a PostHog project secret key to opt in to local evaluation: POSTHOG_SECRET_KEY=phs_... ``` -In this mode `posthog-node` periodically polls flag definitions (roughly every 30s) -and evaluates flags in-process, which lowers per-evaluation latency. Be aware that -this polling runs **per warm server process** and counts against your PostHog feature -flag request quota independently of how many users you have — on serverless platforms -many warm instances can each run their own poller. +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`: @@ -188,3 +197,9 @@ export const GET = createFlagsDiscoveryEndpoint(() => 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) From cf5186bb7ac5c2d46a6e13aaef478a09916971f2 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 21 Jul 2026 11:49:51 +0300 Subject: [PATCH 05/13] rm edge config tag --- packages/adapter-posthog/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/adapter-posthog/package.json b/packages/adapter-posthog/package.json index 77b9a701..04c397f4 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" ], From 273a7ed3b49ed3cec75fac74f6d8e3fc1af4c123 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 21 Jul 2026 12:36:05 +0300 Subject: [PATCH 06/13] modernize --- .../posthog-explicit-local-evaluation.md | 43 ++++ apps/docs/content/docs/providers/posthog.mdx | 42 ++-- packages/adapter-posthog/package.json | 3 + packages/adapter-posthog/src/index.test.ts | 230 ++++++++++++++---- packages/adapter-posthog/src/index.ts | 178 ++++++++------ packages/adapter-posthog/src/types.ts | 22 +- 6 files changed, 363 insertions(+), 155 deletions(-) diff --git a/.changeset/posthog-explicit-local-evaluation.md b/.changeset/posthog-explicit-local-evaluation.md index 1c07d09a..49e2220b 100644 --- a/.changeset/posthog-explicit-local-evaluation.md +++ b/.changeset/posthog-explicit-local-evaluation.md @@ -17,3 +17,46 @@ evaluation by setting `POSTHOG_SECRET_KEY` (a `phs_...` project secret key). Whe `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: it returns +the flag's evaluated value, typed per flag (`flag` yields a boolean). The old +`isFeatureEnabled` boolean coercion of multivariate flags is gone — declare the flag as +`boolean` to keep boolean semantics. + +## Migrated to `evaluateFlags` and added bulk evaluation + +Internally the adapter now uses `posthog-node`'s `evaluateFlags` instead of the +deprecated `isFeatureEnabled` / `getFeatureFlag` / `getFeatureFlagPayload` methods, +removing their runtime deprecation warnings. 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 `/flags` request. + +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. diff --git a/apps/docs/content/docs/providers/posthog.mdx b/apps/docs/content/docs/providers/posthog.mdx index 6055808d..a263fed6 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, }); @@ -76,25 +79,36 @@ const postHogAdapter = createPostHogAdapter({ }) ``` -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. + +One PostHog flag can back multiple SDK flags by using a suffix after a `.` in the +key (for example `my-flag.enabled` and `my-flag.payload` both read the PostHog flag +`my-flag`). ```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" diff --git a/packages/adapter-posthog/package.json b/packages/adapter-posthog/package.json index 04c397f4..e59982e7 100644 --- a/packages/adapter-posthog/package.json +++ b/packages/adapter-posthog/package.json @@ -21,6 +21,9 @@ "author": "Aaron Morris ", "sideEffects": false, "type": "module", + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, "exports": { ".": { "import": "./dist/index.js", diff --git a/packages/adapter-posthog/src/index.test.ts b/packages/adapter-posthog/src/index.test.ts index 361fa0d4..bab065f5 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 { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { type PostHogEntities, postHogAdapter } from '.'; +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,14 +24,31 @@ 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( + delete process.env.NEXT_PUBLIC_POSTHOG_KEY; + resetDefaultPostHogAdapter(); + expect(() => postHogAdapter()).toThrowError( 'PostHog Adapter: Missing NEXT_PUBLIC_POSTHOG_KEY environment variable', ); }); @@ -34,64 +58,170 @@ describe('postHogAdapter', () => { beforeAll(() => { process.env.NEXT_PUBLIC_POSTHOG_KEY = 'test-posthog-key'; process.env.NEXT_PUBLIC_POSTHOG_HOST = 'https://us.i.posthog.com'; + resetDefaultPostHogAdapter(); + }); + + afterEach(() => { + vi.clearAllMocks(); }); - describe('isFeatureEnabled', () => { - it('should decide', async () => { - postHogClientMock.isFeatureEnabled.mockReturnValue(true); + 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 valuePromise = postHogAdapter.isFeatureEnabled().decide({ + 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.variant' }], + entities, + headers, + cookies, + }); + + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledTimes(1); + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledWith('user_1', { + flagKeys: ['a', 'b'], + }); + expect(result).toEqual({ a: true, 'b.variant': '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.payload' }, { key: 'b.payload' }], + entities, + headers, + cookies, + }); + + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledTimes(1); + expect(postHogClientMock.evaluateFlags).toHaveBeenCalledWith('user_1', { + flagKeys: ['a', 'b'], + }); + expect(result).toEqual({ 'a.payload': { a: 1 } }); }); }); }); @@ -116,8 +246,8 @@ describe('default adapter evaluation mode', () => { vi.resetModules(); const { PostHog } = await import('posthog-node'); const { postHogAdapter: freshAdapter } = await import('.'); - // Accessing a method constructs the underlying PostHog client synchronously. - freshAdapter.isFeatureEnabled(); + // Invoking the adapter constructs the underlying PostHog client. + freshAdapter(); return vi.mocked(PostHog).mock.calls.at(-1)?.[1] ?? {}; } diff --git a/packages/adapter-posthog/src/index.ts b/packages/adapter-posthog/src/index.ts index f54fc5d8..00b7f891 100644 --- a/packages/adapter-posthog/src/index.ts +++ b/packages/adapter-posthog/src/index.ts @@ -1,9 +1,58 @@ +import type { Adapter } from 'flags'; import { PostHog } 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 flagKey = trimKey(key); + const snapshot = await client.evaluateFlags(distinctId, { + flagKeys: [flagKey], + }); + const value = read(snapshot, flagKey); + if (!isPresent(value)) { + if (typeof defaultValue !== 'undefined') return defaultValue; + throw new Error( + `PostHog Adapter found no ${noun} for ${flagKey} and no default value was provided.`, + ); + } + return value; + }, + async bulkDecide({ flags, entities }) { + const { distinctId } = parseEntities(entities); + // One PostHog flag can back multiple SDK flags (see `trimKey`), so + // dedupe the underlying keys before scoping the request. + const flagKeys = Array.from( + new Set(flags.map(({ key }) => trimKey(key))), + ); + const snapshot = await client.evaluateFlags(distinctId, { flagKeys }); + const out: Record = {}; + for (const { key } of flags) { + const value = read(snapshot, trimKey(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, @@ -13,75 +62,36 @@ export function createPostHogAdapter({ }): 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', + ); + + const adapter = (() => + valueAdapter as Adapter) as PostHogAdapter; + adapter.payload = () => + payloadAdapter as Adapter; - return result; + return adapter; } function parseEntities(entities?: PostHogEntities): PostHogEntities { @@ -109,8 +119,16 @@ function trimKey(key: string): string { return key.split('.')[0] as string; } -let defaultPostHogAdapter: ReturnType | undefined; -function getOrCreateDefaultAdapter() { +let defaultPostHogAdapter: PostHogAdapter | undefined; + +/** + * Internal function for testing purposes + */ +export function resetDefaultPostHogAdapter() { + defaultPostHogAdapter = undefined; +} + +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_` @@ -136,11 +154,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/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 }; From 7f26df855f6e4b5fd1a79ffe8969e20134d28cf6 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Tue, 21 Jul 2026 12:40:04 +0300 Subject: [PATCH 07/13] rm trimKey --- .../posthog-explicit-local-evaluation.md | 5 ++++ apps/docs/content/docs/providers/posthog.mdx | 4 +-- packages/adapter-posthog/src/index.test.ts | 8 +++--- packages/adapter-posthog/src/index.ts | 25 ++++++------------- 4 files changed, 17 insertions(+), 25 deletions(-) diff --git a/.changeset/posthog-explicit-local-evaluation.md b/.changeset/posthog-explicit-local-evaluation.md index 49e2220b..85224f44 100644 --- a/.changeset/posthog-explicit-local-evaluation.md +++ b/.changeset/posthog-explicit-local-evaluation.md @@ -44,6 +44,11 @@ the flag's evaluated value, typed per flag (`flag` yields a boolean). T `isFeatureEnabled` boolean coercion of multivariate flags is gone — declare the flag as `boolean` to keep boolean semantics. +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`. + ## Migrated to `evaluateFlags` and added bulk evaluation Internally the adapter now uses `posthog-node`'s `evaluateFlags` instead of the diff --git a/apps/docs/content/docs/providers/posthog.mdx b/apps/docs/content/docs/providers/posthog.mdx index a263fed6..476ac800 100644 --- a/apps/docs/content/docs/providers/posthog.mdx +++ b/apps/docs/content/docs/providers/posthog.mdx @@ -88,9 +88,7 @@ invoke it — both are equivalent: - `postHogAdapter.payload` (or `postHogAdapter.payload()`): resolves the flag's attached payload. -One PostHog flag can back multiple SDK flags by using a suffix after a `.` in the -key (for example `my-flag.enabled` and `my-flag.payload` both read the PostHog flag -`my-flag`). +The flag's `key` is used as the PostHog feature flag key as-is. ```ts title="app/flags.ts" import { flag } from 'flags/next' diff --git a/packages/adapter-posthog/src/index.test.ts b/packages/adapter-posthog/src/index.test.ts index bab065f5..e9d12f4b 100644 --- a/packages/adapter-posthog/src/index.test.ts +++ b/packages/adapter-posthog/src/index.test.ts @@ -144,7 +144,7 @@ describe('postHogAdapter', () => { ); const result = await postHogAdapter().bulkDecide!({ - flags: [{ key: 'a' }, { key: 'b.variant' }], + flags: [{ key: 'a' }, { key: 'b' }], entities, headers, cookies, @@ -154,7 +154,7 @@ describe('postHogAdapter', () => { expect(postHogClientMock.evaluateFlags).toHaveBeenCalledWith('user_1', { flagKeys: ['a', 'b'], }); - expect(result).toEqual({ a: true, 'b.variant': 'variant' }); + expect(result).toEqual({ a: true, b: 'variant' }); }); it('bulkDecide omits flags with no value', async () => { @@ -211,7 +211,7 @@ describe('postHogAdapter', () => { ); const result = await postHogAdapter.payload().bulkDecide!({ - flags: [{ key: 'a.payload' }, { key: 'b.payload' }], + flags: [{ key: 'a' }, { key: 'b' }], entities, headers, cookies, @@ -221,7 +221,7 @@ describe('postHogAdapter', () => { expect(postHogClientMock.evaluateFlags).toHaveBeenCalledWith('user_1', { flagKeys: ['a', 'b'], }); - expect(result).toEqual({ 'a.payload': { a: 1 } }); + expect(result).toEqual({ a: { a: 1 } }); }); }); }); diff --git a/packages/adapter-posthog/src/index.ts b/packages/adapter-posthog/src/index.ts index 00b7f891..53f6a26e 100644 --- a/packages/adapter-posthog/src/index.ts +++ b/packages/adapter-posthog/src/index.ts @@ -21,30 +21,26 @@ function createFlagAdapter( adapterId, async decide({ key, entities, defaultValue }) { const { distinctId } = parseEntities(entities); - const flagKey = trimKey(key); const snapshot = await client.evaluateFlags(distinctId, { - flagKeys: [flagKey], + flagKeys: [key], }); - const value = read(snapshot, flagKey); + const value = read(snapshot, key); if (!isPresent(value)) { if (typeof defaultValue !== 'undefined') return defaultValue; throw new Error( - `PostHog Adapter found no ${noun} for ${flagKey} and no default value was provided.`, + `PostHog Adapter found no ${noun} for ${key} and no default value was provided.`, ); } return value; }, async bulkDecide({ flags, entities }) { const { distinctId } = parseEntities(entities); - // One PostHog flag can back multiple SDK flags (see `trimKey`), so - // dedupe the underlying keys before scoping the request. - const flagKeys = Array.from( - new Set(flags.map(({ key }) => trimKey(key))), - ); - const snapshot = await client.evaluateFlags(distinctId, { flagKeys }); + const snapshot = await client.evaluateFlags(distinctId, { + flagKeys: flags.map(({ key }) => key), + }); const out: Record = {}; for (const { key } of flags) { - const value = read(snapshot, trimKey(key)); + const value = read(snapshot, key); // Omit absent values so the SDK applies each flag's `defaultValue`. if (isPresent(value)) out[key] = value; } @@ -112,13 +108,6 @@ 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; /** From 5e6ff26f7118a35925136b114ab840ad54dba5e9 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 26 Jul 2026 15:38:53 -0400 Subject: [PATCH 08/13] lockfile --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 845023c2..4d9300ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -657,9 +657,6 @@ 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: 5.45.0 version: 5.45.0 From 589ad120906299fb2a8f462248cd86314cb4d77b Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 26 Jul 2026 17:26:13 -0400 Subject: [PATCH 09/13] update --- .gitignore | 1 + apps/docs/content/docs/providers/posthog.mdx | 73 ++++++++++++++----- packages/adapter-posthog/README.md | 5 +- packages/adapter-posthog/src/index.ts | 10 +-- .../adapter-posthog/src/provider/index.ts | 6 +- pnpm-lock.yaml | 10 +-- 6 files changed, 66 insertions(+), 39 deletions(-) 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 476ac800..fac5798c 100644 --- a/apps/docs/content/docs/providers/posthog.mdx +++ b/apps/docs/content/docs/providers/posthog.mdx @@ -50,16 +50,27 @@ Install the required dependencies: pnpm i @flags-sdk/posthog ``` -Set up required environment variables: +Set up the required environment variables: ```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 > Project API Key +POSTHOG_PROJECT_API_KEY=phc_... +# Determines the region your data lives in: +# https://us.i.posthog.com or https://eu.i.posthog.com +POSTHOG_HOST=https://us.i.posthog.com ``` -Import the PostHog adapter for Flags SDK, which uses the environment variables above as defaults: +### Environment variables + +| Variable | Required | Used by | Description | +| -------------------------- | ----------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `POSTHOG_PROJECT_API_KEY` | Yes | `postHogAdapter` | Your project API key (`phc_...`). Settings > Project > Project API Key. | +| `POSTHOG_HOST` | Yes | `postHogAdapter`, Explorer | The regional API host, e.g. `https://us.i.posthog.com` or `https://eu.i.posthog.com`. | +| `POSTHOG_SECRET_KEY` | No | `postHogAdapter` | Feature flags secret key (`phs_...`). Setting it opts the default adapter into [local evaluation](#local-evaluation). | +| `POSTHOG_PERSONAL_API_KEY` | For the Flags Explorer | `getProviderData` | Personal API key (`phx_...`). Settings > User > Personal API keys. | +| `POSTHOG_PROJECT_ID` | For the Flags Explorer | `getProviderData` | Your numeric project id, e.g. `521742`. Settings > Project > Project ID. | + +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' @@ -71,9 +82,9 @@ 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, // ... }, }) @@ -88,7 +99,20 @@ invoke it — both are equivalent: - `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. +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' @@ -125,19 +149,23 @@ PostHog can evaluate flags remotely or locally. ### Remote evaluation (default) -PostHog makes a network request for every feature flag evaluation, and each request +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 -Set a PostHog project secret key to opt in to local evaluation: +Set a PostHog project secret key to opt the default adapter into local evaluation: ```bash title=".env.local" # Settings > Project > Feature flags secret key POSTHOG_SECRET_KEY=phs_... ``` +This is the only thing that switches evaluation modes — the default adapter enables +local evaluation exactly when `POSTHOG_SECRET_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 @@ -161,9 +189,9 @@ You can also enable it explicitly with `createPostHogAdapter`: 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, secretKey: process.env.POSTHOG_SECRET_KEY, enableLocalEvaluation: true, }, @@ -176,6 +204,10 @@ const postHogAdapter = createPostHogAdapter({ 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 @@ -186,22 +218,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. + ({ // The key in PostHog key: "my_posthog_flag_key_here", - // The PostHog feature to use (isFeatureEnabled, featureFlagValue, featureFlagPayload) - adapter: postHogAdapter.featureFlagValue(), + adapter: postHogAdapter, }); ``` ## Evaluation modes -The mode is an explicit choice, not a side effect of any other credential. - - **Remote (default):** with only `NEXT_PUBLIC_POSTHOG_KEY` and `NEXT_PUBLIC_POSTHOG_HOST` set, each evaluation calls PostHog. No background polling; request volume scales with traffic. Recommended for serverless. diff --git a/packages/adapter-posthog/src/index.ts b/packages/adapter-posthog/src/index.ts index 53f6a26e..3860fa2b 100644 --- a/packages/adapter-posthog/src/index.ts +++ b/packages/adapter-posthog/src/index.ts @@ -1,5 +1,5 @@ import type { Adapter } from 'flags'; -import { PostHog } from 'posthog-node'; +import { PostHog, type PostHogOptions } from 'posthog-node'; import type { JsonType, PostHogAdapter, PostHogEntities } from './types'; export { getProviderData } from './provider'; @@ -53,8 +53,8 @@ export function createPostHogAdapter({ postHogKey, postHogOptions, }: { - postHogKey: ConstructorParameters[0]; - postHogOptions: ConstructorParameters[1]; + postHogKey: string; + postHogOptions: PostHogOptions; }): PostHogAdapter { const client = new PostHog(postHogKey, postHogOptions); @@ -130,9 +130,9 @@ function getOrCreateDefaultAdapter(): PostHogAdapter { 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'), + host: assertEnv('POSTHOG_HOST'), secretKey, enableLocalEvaluation: Boolean(secretKey), // Presumption: Server IP is likely not a good proxy for user location diff --git a/packages/adapter-posthog/src/provider/index.ts b/packages/adapter-posthog/src/provider/index.ts index 82447832..8b33a2f7 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'); } if (host.includes('us.i.posthog.com')) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d9300ab..09e19044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1306,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==} @@ -9369,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 @@ -9674,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': From 058447afc9ae66fd6af75e44705123923570f105 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 26 Jul 2026 17:34:00 -0400 Subject: [PATCH 10/13] fixes --- apps/docs/content/docs/providers/posthog.mdx | 47 +++++++++++--------- packages/adapter-posthog/README.md | 29 +++++++++++- packages/adapter-posthog/src/index.test.ts | 12 ++--- skills/flags-sdk/references/providers.md | 33 +++++++++----- 4 files changed, 81 insertions(+), 40 deletions(-) diff --git a/apps/docs/content/docs/providers/posthog.mdx b/apps/docs/content/docs/providers/posthog.mdx index fac5798c..8439d69d 100644 --- a/apps/docs/content/docs/providers/posthog.mdx +++ b/apps/docs/content/docs/providers/posthog.mdx @@ -50,25 +50,37 @@ Install the required dependencies: pnpm i @flags-sdk/posthog ``` -Set up the 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_PROJECT_API_KEY=phc_... -# Determines the region your data lives in: -# https://us.i.posthog.com or https://eu.i.posthog.com 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_... ``` -### Environment variables +**Optional**, opts `postHogAdapter` into [local evaluation](#local-evaluation), where +flag definitions are polled in the background instead of evaluating each flag remotely: -| Variable | Required | Used by | Description | -| -------------------------- | ----------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `POSTHOG_PROJECT_API_KEY` | Yes | `postHogAdapter` | Your project API key (`phc_...`). Settings > Project > Project API Key. | -| `POSTHOG_HOST` | Yes | `postHogAdapter`, Explorer | The regional API host, e.g. `https://us.i.posthog.com` or `https://eu.i.posthog.com`. | -| `POSTHOG_SECRET_KEY` | No | `postHogAdapter` | Feature flags secret key (`phs_...`). Setting it opts the default adapter into [local evaluation](#local-evaluation). | -| `POSTHOG_PERSONAL_API_KEY` | For the Flags Explorer | `getProviderData` | Personal API key (`phx_...`). Settings > User > Personal API keys. | -| `POSTHOG_PROJECT_ID` | For the Flags Explorer | `getProviderData` | Your numeric project id, e.g. `521742`. Settings > Project > Project ID. | +```bash title=".env.local" +# 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 reads `POSTHOG_PROJECT_API_KEY`, `POSTHOG_HOST` and `POSTHOG_SECRET_KEY` when it is first used: @@ -156,15 +168,8 @@ also adds latency. If you provide user ids, PostHog will look up additional prop ### Local evaluation -Set a PostHog project secret key to opt the default adapter into local evaluation: - -```bash title=".env.local" -# Settings > Project > Feature flags secret key -POSTHOG_SECRET_KEY=phs_... -``` - -This is the only thing that switches evaluation modes — the default adapter enables -local evaluation exactly when `POSTHOG_SECRET_KEY` is present. +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 diff --git a/packages/adapter-posthog/README.md b/packages/adapter-posthog/README.md index 505b399d..6b1ba418 100644 --- a/packages/adapter-posthog/README.md +++ b/packages/adapter-posthog/README.md @@ -23,9 +23,36 @@ export const marketingGate = flag({ }); ``` +## 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 `NEXT_PUBLIC_POSTHOG_KEY` and `NEXT_PUBLIC_POSTHOG_HOST` +- **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 diff --git a/packages/adapter-posthog/src/index.test.ts b/packages/adapter-posthog/src/index.test.ts index e9d12f4b..045c51cc 100644 --- a/packages/adapter-posthog/src/index.test.ts +++ b/packages/adapter-posthog/src/index.test.ts @@ -46,18 +46,18 @@ describe('postHogAdapter', () => { describe('with a missing environment', () => { it('should throw an error', () => { - delete process.env.NEXT_PUBLIC_POSTHOG_KEY; + delete process.env.POSTHOG_PROJECT_API_KEY; resetDefaultPostHogAdapter(); expect(() => postHogAdapter()).toThrowError( - 'PostHog Adapter: Missing NEXT_PUBLIC_POSTHOG_KEY environment variable', + '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(); }); @@ -231,8 +231,8 @@ describe('default adapter evaluation mode', () => { const OLD_ENV = process.env; 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'; }); afterEach(() => { 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!, }), ); ``` From 7f5d580fe392cf42a9c2b6cd9422e60531bac363 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 26 Jul 2026 17:39:15 -0400 Subject: [PATCH 11/13] reword changeset --- .../posthog-explicit-local-evaluation.md | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.changeset/posthog-explicit-local-evaluation.md b/.changeset/posthog-explicit-local-evaluation.md index 85224f44..4e38bc31 100644 --- a/.changeset/posthog-explicit-local-evaluation.md +++ b/.changeset/posthog-explicit-local-evaluation.md @@ -2,7 +2,32 @@ "@flags-sdk/posthog": major --- -Make local vs. remote flag evaluation an explicit choice. +Modernize the PostHog adapter. This release is breaking in four 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**, and Node.js `^20.20.0 || >=22.22.0` is now required. + +It also adds bulk evaluation support and drops `posthog-node`'s deprecation warnings. + +## 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, which enabled local evaluation and started a From d7d597e83cf1aa86759c786cfe4c393a7931b683 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Sun, 26 Jul 2026 17:40:17 -0400 Subject: [PATCH 12/13] merge changesets --- .changeset/posthog-explicit-local-evaluation.md | 8 +++++--- .changeset/spicy-donkeys-refuse.md | 5 ----- 2 files changed, 5 insertions(+), 8 deletions(-) delete mode 100644 .changeset/spicy-donkeys-refuse.md diff --git a/.changeset/posthog-explicit-local-evaluation.md b/.changeset/posthog-explicit-local-evaluation.md index 4e38bc31..8efa3083 100644 --- a/.changeset/posthog-explicit-local-evaluation.md +++ b/.changeset/posthog-explicit-local-evaluation.md @@ -9,7 +9,8 @@ Modernize the PostHog adapter. This release is breaking in four ways: - **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**, and Node.js `^20.20.0 || >=22.22.0` is now required. -It also adds bulk evaluation support and drops `posthog-node`'s deprecation warnings. +It also upgrades `posthog-node` from v4.11.1 to v5.45.0, adds bulk evaluation support, +and drops `posthog-node`'s deprecation warnings. ## Environment variables @@ -74,9 +75,10 @@ convention of trimming everything after the first `.` (so `my-flag.variant` read PostHog flag `my-flag`) has been removed; use the exact PostHog flag key as your flag `key`. -## Migrated to `evaluateFlags` and added bulk evaluation +## Upgraded `posthog-node`, migrated to `evaluateFlags`, added bulk evaluation -Internally the adapter now uses `posthog-node`'s `evaluateFlags` instead of the +`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 their runtime deprecation warnings. The adapter also implements `bulkDecide`, so [`evaluate()`](https://flags-sdk.dev/frameworks/next/bulk-evaluation) resolves flags diff --git a/.changeset/spicy-donkeys-refuse.md b/.changeset/spicy-donkeys-refuse.md deleted file mode 100644 index 7e5c8e38..00000000 --- a/.changeset/spicy-donkeys-refuse.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@flags-sdk/posthog": major ---- - -Upgrade posthog-node from v4.11.1 to v5.45.0 From 20ce306216d2682d2a83b5d468196c3282eb0241 Mon Sep 17 00:00:00 2001 From: Dominik Ferber Date: Wed, 29 Jul 2026 10:50:05 -0400 Subject: [PATCH 13/13] reword changelog --- .../posthog-explicit-local-evaluation.md | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/.changeset/posthog-explicit-local-evaluation.md b/.changeset/posthog-explicit-local-evaluation.md index 8efa3083..a7d8713a 100644 --- a/.changeset/posthog-explicit-local-evaluation.md +++ b/.changeset/posthog-explicit-local-evaluation.md @@ -2,15 +2,20 @@ "@flags-sdk/posthog": major --- -Modernize the PostHog adapter. This release is breaking in four ways: +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**, and Node.js `^20.20.0 || >=22.22.0` is now required. +- **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, adds bulk evaluation support, -and drops `posthog-node`'s deprecation warnings. +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 @@ -31,9 +36,11 @@ project API key variable now says which key it wants: ## Explicit local vs. remote evaluation Previously the default `postHogAdapter` passed `POSTHOG_PERSONAL_API_KEY` into the -runtime `posthog-node` client, which enabled local evaluation and started a -feature-flag poller in every warm server process. On serverless this could generate -a large, traffic-independent volume of PostHog feature flag requests. +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, @@ -65,10 +72,15 @@ 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: it returns -the flag's evaluated value, typed per flag (`flag` yields a boolean). The old -`isFeatureEnabled` boolean coercion of multivariate flags is gone — declare the flag as -`boolean` to keep boolean semantics. +`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 @@ -78,11 +90,15 @@ PostHog flag `my-flag`) has been removed; use the exact PostHog flag key as your ## 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 their runtime deprecation warnings. 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 `/flags` request. +`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 @@ -92,3 +108,8 @@ own flag code instead. `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.