feat: add official PostHog OpenFeature server provider (Node) - #3994
Conversation
|
Reviews (1): Last reviewed commit: "feat: add official PostHog OpenFeature p..." | Re-trigger Greptile |
|
Size Change: 0 B Total Size: 17.3 MB ℹ️ View Unchanged
|
Documents the two official PostHog OpenFeature providers for JavaScript (@posthog/openfeature-node and @posthog/openfeature-web): installation, usage, evaluation-context mapping, supported flag types, and options. Companion to the posthog-js implementation PR (PostHog/posthog-js#3994), mirroring the existing OpenFeature (Python) page. Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a
|
Reviews (2): Last reviewed commit: "refactor(openfeature): rename packages t..." | Re-trigger Greptile |
haacked
left a comment
There was a problem hiding this comment.
Nice addition! Two blocking issues inline: disabled flags throw instead of resolving to the default, and the node package's server-sdk peer range can't actually be satisfied. A few non-blocking suggestions too.
| flagKey: string | ||
| ): ResolutionDetails<string> { | ||
| const resolved = ensureResolved(result, flagKey) | ||
| if (resolved.variant === undefined) { |
There was a problem hiding this comment.
blocking: A disabled or unmatched flag ({ enabled: false, variant: undefined }) throws TypeMismatchError instead of resolving to the caller's default. OpenFeature's SDK catches the throw, sets errorCode: TYPE_MISMATCH and reason: ERROR, and fires every registered error hook. So a consumer with error hooks wired to their error tracker gets paged on every ordinary disabled-flag read.
The Python provider that this PR mirrors returns the caller's default with Reason.DEFAULT and no error in exactly this case, only raising TypeMismatchError when enabled is true. The identical bug exists in packages/openfeature-web-provider/src/mapping.ts. Neither provider.spec.ts has a test for disabled flags through these resolvers.
Add the !resolved.enabled guard before the throw in all three resolvers in both packages, and thread the caller's default value through:
export function resolveStringDetails(
result: PostHogFlagResult | undefined,
- flagKey: string
+ flagKey: string,
+ defaultValue: string
): ResolutionDetails<string> {
const resolved = ensureResolved(result, flagKey)
if (resolved.variant === undefined) {
- // A boolean flag has no string variant. Surface a type mismatch so the
- // caller gets its default value (per the OpenFeature spec) rather than a
- // surprising "true"/"false" string.
+ if (!resolved.enabled) {
+ return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
+ }
+ // An enabled boolean flag has no string variant: this is a genuine mismatch.
throw new TypeMismatchError(`Flag '${flagKey}' has no string variant (boolean flag).`)
}
return { value: resolved.variant, variant: resolved.variant, reason: reasonFor(resolved) }
}Apply the same guard in resolveNumberDetails (before the variant === undefined throw) and resolveObjectDetails (before the payload-type throw). Pass _defaultValue through at the call sites in both provider.ts files. Add disabled-flag cases to each it.each table asserting reason: DEFAULT and no errorCode.
There was a problem hiding this comment.
Fixed in 7fffb7c (node) and 85f34db (web) — added the !resolved.enabled guard to all three resolvers, so a disabled/unmatched flag resolves to the caller's default with reason: DEFAULT instead of throwing. Threaded the default value through resolveString/Number/Object and their provider.ts call sites. Added disabled-flag test cases asserting reason: DEFAULT and no errorCode.
| "@openfeature/core": "^1.11.0", | ||
| "@openfeature/server-sdk": "^1.7.0", | ||
| "posthog-node": ">=5.21.2" | ||
| }, |
There was a problem hiding this comment.
blocking: The @openfeature/server-sdk@^1.7.0 lower bound conflicts with the @openfeature/core@^1.11.0 peer declared above. Every server-sdk version from 1.7.0 through 1.16.x requires @openfeature/core below 1.11.0, so no single installed version satisfies both constraints. A consumer on any server-sdk in that range hits an ERESOLVE failure on npm or yarn install; pnpm only warns unless strict-peer-dependencies is set.
server-sdk@1.17.0 is the first version whose own core peer (^1.6.0) overlaps with 1.11.0:
"peerDependencies": {
"@openfeature/core": "^1.11.0",
- "@openfeature/server-sdk": "^1.7.0",
+ "@openfeature/server-sdk": "^1.17.0",
"posthog-node": ">=5.21.2"
},There was a problem hiding this comment.
Fixed in 7fffb7c — bumped the @openfeature/server-sdk peer floor to ^1.17.0, the first version whose own @openfeature/core peer overlaps ^1.11.0.
| } | ||
|
|
||
| async onContextChange(_oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> { | ||
| await this._reconcile(newContext) |
There was a problem hiding this comment.
suggestion: onContextChange reconciles on every call even when the context is unchanged. @openfeature/web-sdk's setContext runs the provider's handler on every call with no equality check, and _reconcile doesn't diff against the previous context either. So a host that re-sets an equivalent context (a React integration passing a freshly-constructed object on every render, say) triggers a $groupidentify event and a reloadFeatureFlags() request on every call, since consecutive calls are typically more than 5ms apart and don't fold into the SDK's debounce.
Diff newContext against the previous context and skip reconciliation when nothing changed:
async onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise<void> {
if (contextsEqual(oldContext, newContext)) {
return
}
await this._reconcile(newContext)
}groupProperties is nested, so this needs a deep compare, not a shallow one. initialize() should keep calling _reconcile unconditionally since first load always needs it.
| try { | ||
| await this._client.reloadFeatureFlags() | ||
| } catch { | ||
| // Intentionally ignored: remote evaluation remains available. |
There was a problem hiding this comment.
suggestion: This swallows every preload error with no log, not just the documented no-personalApiKey no-op case. A genuine misconfiguration during reloadFeatureFlags() (bad host, network failure) disappears silently, so a user whose local evaluation never warms up has no visibility. The Python reference that this PR mirrors logs a warning with the exception in the equivalent spot.
try {
await this._client.reloadFeatureFlags()
- } catch {
- // Intentionally ignored: remote evaluation remains available.
+ } catch (err) {
+ // Remote evaluation still works, so don't block readiness, but surface
+ // the failure so a real misconfiguration isn't invisible.
+ console.warn('[PostHogServerProvider] initialize() flag preload failed; remote evaluation still available.', err)
}no-console is already disabled for this package in .eslintrc.cjs.
There was a problem hiding this comment.
Fixed in 7fffb7c — initialize() now console.warns on a preload failure (with the error) instead of swallowing it, so a genuine misconfiguration on a client set up for local evaluation is visible. Remote evaluation still works, so readiness isn't blocked.
| }) | ||
|
|
||
| it.each<[string, FlagResult | undefined, Resolve, ErrorCode]>([ | ||
| [ |
There was a problem hiding this comment.
suggestion: This table only covers a string read on a boolean flag and a missing flag. The node package's equivalent table also has a "number from a non-numeric variant" case and an "object from a non-object payload" case, exercising resolveNumberDetails's !Number.isFinite throw and resolveObjectDetails's non-object-payload throw. Neither branch is exercised here, so a regression in either check on the web side ships silently.
Add the two missing rows, mirroring the node spec:
[
'number from a non-numeric variant',
{ key: 'flag', enabled: true, variant: 'not-a-number' },
(p) => p.resolveNumberEvaluation('flag', 0),
ErrorCode.TYPE_MISMATCH,
],
[
'object from a non-object payload',
{ key: 'flag', enabled: true, variant: 'x', payload: 'not-an-object' },
(p) => p.resolveObjectEvaluation('flag', {}),
ErrorCode.TYPE_MISMATCH,
],| @@ -0,0 +1,150 @@ | |||
| /** | |||
There was a problem hiding this comment.
question: This file is identical to openfeature-node-provider/src/mapping.ts apart from two words in the header comment, and imports only from @openfeature/core, not from posthog-js, posthog-node, or any runtime SDK. So the dependency-isolation argument that justifies splitting the providers into two packages doesn't apply to the mapping layer: a shared internal module would peer on @openfeature/core, which both already require, adding no extra peer constraints. The concrete cost of duplication is drift: the disabled-flag type-mismatch bug exists identically in both files, and any fix lands twice and stays in sync.
Was a shared internal module considered and set aside deliberately? The likely counter-argument is that with bundle: false in both rslib.config.ts, extracting means either publishing a third @posthog/openfeature-* package or setting bundle: true here, which would deviate from the @posthog/mcp convention these packages otherwise follow. For 150 lines with two consumers that's a defensible tradeoff, just want to confirm it was weighed rather than copy-paste.
There was a problem hiding this comment.
Considered and set aside deliberately. With bundle: false (the @posthog/mcp convention both packages follow), sharing the mapping means either publishing a third @posthog/openfeature-* package or switching to bundle: true — both deviate from convention for ~150 lines. We accept the duplication; the drift risk you flagged (the disabled-flag bug) is now fixed in both packages in lockstep (7fffb7c / 85f34db). Happy to revisit if a third consumer appears.
| "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz" | ||
| }, | ||
| "files": [ | ||
| "dist/" |
There was a problem hiding this comment.
nit: No engines block here, while the node provider and every other sibling package (@posthog/mcp, @posthog/nextjs-config, @posthog/nuxt) declare one despite targeting the web. Without it, consumers on unsupported Node versions get no install-time warning when building or testing this package.
"package": "pnpm pack --out $PACKAGE_DEST/%s.tgz"
},
+ "engines": {
+ "node": "^20.20.0 || >=22.22.0"
+ },
"files": [
"dist/"
],| let subscribed = false | ||
|
|
||
| const finish = (): void => { | ||
| if (settled) { |
There was a problem hiding this comment.
nit: finish references timer and unsubscribe before either const below it is declared. It works today only because finish never actually runs until after both are assigned, so a future edit that lets it run earlier would hit a temporal-dead-zone throw instead of a type error. Declaring unsubscribe and timer above finish would let the closure read top-to-bottom.
| '@posthog/openfeature-node-provider': minor | ||
| '@posthog/openfeature-web-provider': minor |
|
worth adding new packages to https://github.com/PostHog/posthog-js/blob/main/.github/pull_request_template.md |
|
also append to https://github.com/PostHog/posthog-js#packages |
|
can we add examples to https://github.com/PostHog/posthog-js/tree/main/examples so we know/can test this outside unit tests? or extend one of our examples for node/web |
Official PostHog provider for the OpenFeature web SDK (@openfeature/web-sdk), backed by posthog-js. Split out from the combined providers PR per review (marandaneto) — the server provider (@posthog/openfeature-node-provider) ships in #3994. The browser model is single-user and synchronous: posthog-js owns the user identity and holds flags in memory, so evaluation is synchronous and the static evaluation context is reconciled into the SDK on initialize()/onContextChange(). Resolves booleans from `enabled`, strings from the multivariate variant, numbers from the parsed variant, and objects from the JSON payload; missing flag -> FLAG_NOT_FOUND, wrong type -> TYPE_MISMATCH. Not published yet at the workflow level beyond being wired into the release matrix and carrying a changeset; publishing still requires the standard merge + NPM Release approval flow. Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a
|
@marandaneto addressed your follow-ups:
|
* feat(openfeature): add web provider (@posthog/openfeature-web-provider) Official PostHog provider for the OpenFeature web SDK (@openfeature/web-sdk), backed by posthog-js. Split out from the combined providers PR per review (marandaneto) — the server provider (@posthog/openfeature-node-provider) ships in #3994. The browser model is single-user and synchronous: posthog-js owns the user identity and holds flags in memory, so evaluation is synchronous and the static evaluation context is reconciled into the SDK on initialize()/onContextChange(). Resolves booleans from `enabled`, strings from the multivariate variant, numbers from the parsed variant, and objects from the JSON payload; missing flag -> FLAG_NOT_FOUND, wrong type -> TYPE_MISMATCH. Not published yet at the workflow level beyond being wired into the release matrix and carrying a changeset; publishing still requires the standard merge + NPM Release approval flow. Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a * fix(openfeature-web): address review feedback (haacked, Greptile, manoel) Provider/mapping fixes (haacked): - Disabled or unmatched flags now resolve to the caller's default with reason=DEFAULT instead of throwing TypeMismatchError (which set reason=ERROR and fired error hooks on every ordinary disabled-flag read). Threaded the default value through resolveString/Number/Object. - onContextChange now deep-compares old vs new context and skips reconciliation (group()/reloadFeatureFlags()) when nothing changed. - Added an engines block. - Restructured _reloadFlags so the cleanup closure no longer references timer/unsubscribe before their declarations (temporal-dead-zone fragility). Tests: - Added disabled-flag resolution cases (reason=DEFAULT, no errorCode) and the missing number/object TypeMismatchError branches to the parameterised tables. - Converted the reload-timeout test to fake timers (Greptile) so it's deterministic and instant instead of waiting on real wall-clock time. Repo wiring (manoel): - Listed the package in the root README and CHANGELOG package lists and the PR template's "Libraries affected" checklist. - Added examples/example-openfeature-web (Vite) demonstrating the provider. Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a
Rebuilt on top of current main after the web provider (#4069) merged, resolving conflicts in the shared registration files by adding the node entries alongside the now-merged web entries. Adds @posthog/openfeature-node-provider (OpenFeature server SDK, backed by posthog-node), an example (examples/example-openfeature-node), a changeset, and registration entries. Includes review fixes: disabled/unmatched flags resolve to the caller's default with reason=DEFAULT instead of throwing; @openfeature/server-sdk peer floor raised to ^1.17.0; initialize() logs a warning on preload failure.
7fffb7c to
92704a5
Compare
haacked
left a comment
There was a problem hiding this comment.
Both blocking issues from before are fixed. One follow-up suggestion inline on initialize()'s error handling, plus a couple of nits.
| try { | ||
| await this._client.reloadFeatureFlags() | ||
| } catch (err) { | ||
| // Remote evaluation still works, so don't block readiness — but surface |
There was a problem hiding this comment.
suggestion: This catch can't fire for the misconfigurations the comment names. reloadFeatureFlags() awaits featureFlagsPoller.loadFeatureFlags(true), which wraps its internal _loadFeatureFlags() in its own .catch() and only debug-logs. Every real failure (bad host, invalid key, network error, 401/403/429) routes through posthog-node's onError callback instead of a rejection, but that callback's event emitter drops the event when nothing's listening. So the promise here never rejects in production, and console.warn never runs for the cases this comment describes.
To actually surface those failures, listen on the channel posthog-node uses instead:
async initialize(): Promise<void> {
- // Preload locally-evaluated flag definitions. reloadFeatureFlags() safely
- // no-ops without a personalApiKey, and posthog-node catches its own poller
- // errors, so a preload failure must not make the OpenFeature client
- // un-ready — remote evaluation still works.
- try {
- await this._client.reloadFeatureFlags()
- } catch (err) {
- // Remote evaluation still works, so don't block readiness — but surface
- // the failure so a genuine misconfiguration (bad host, network error) on
- // a client set up for local evaluation isn't invisible.
- // eslint-disable-next-line no-console
- console.warn('[PostHogServerProvider] initialize() flag preload failed; remote evaluation still available.', err)
- }
+ // posthog-node swallows poller errors internally and surfaces the
+ // client/network-error class via its `error` event, so listen on that
+ // channel around the reload. Remote evaluation still works either way,
+ // so this never blocks readiness.
+ let preloadError: Error | undefined
+ const unsubscribe = this._client.on('error', (err: Error) => {
+ preloadError = err
+ })
+ try {
+ await this._client.reloadFeatureFlags()
+ } finally {
+ unsubscribe()
+ }
+ if (preloadError) {
+ // eslint-disable-next-line no-console
+ console.warn('[PostHogServerProvider] initialize() flag preload failed; remote evaluation still available.', preloadError)
+ }
}One caveat: posthog-node only routes ClientError (401/403/429, bad response) through that event, so a raw network/DNS failure on a bad host still won't surface either way.
There was a problem hiding this comment.
Good catch — confirmed reloadFeatureFlags() never rejects (poller .catch() at feature-flags.ts:748 only debug-logs), so the try/catch was dead code. Fixed in c2ad670 by listening on the client's error event around the reload, per your suggestion. Kept your caveat in the comment: only ClientError (401/403/429, bad response) routes through that event, so a raw network/DNS failure on a bad host still won't surface.
| expect(reloadFeatureFlags).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('does not reject when preloading fails', async () => { |
There was a problem hiding this comment.
suggestion: This test proves initialize() doesn't throw when reloadFeatureFlags rejects, but the real posthog-node client never rejects here (see provider.ts:84), so it doesn't cover the actual failure path.
If you switch to listening on the client's error event there, test that instead: emit an error on 'error' and assert console.warn fires with it.
There was a problem hiding this comment.
Fixed in c2ad670 — the test now emits the client's error event while the reload is in flight and asserts console.warn fires with it, plus a companion case asserting no warning on a clean preload.
| } | ||
| throw new TypeMismatchError(`Flag '${flagKey}' has no variant to parse as a number.`) | ||
| } | ||
| const value = Number(resolved.variant) |
There was a problem hiding this comment.
nit: Number('') evaluates to 0, not NaN, so a multivariate flag with an empty-string variant resolves to 0 instead of throwing TypeMismatchError. The 'not-a-number' test case only covers the NaN branch; worth an added case if you want this edge nailed down, though real variant keys are unlikely to be empty strings.
There was a problem hiding this comment.
Fixed in c2ad670 — resolveNumberDetails now rejects a blank variant (variant.trim() === '') before the Number.isFinite check, so an empty/whitespace variant throws TypeMismatchError instead of resolving to 0. Added an empty-string variant case to the throws table.
(The web package's mapping has the same line; since it's already merged I left it for a follow-up rather than reintroduce cross-package churn here.)
| // id comes from the evaluation context's targetingKey; other attributes | ||
| // map to person/group properties. Swap these keys for real flags. | ||
| const context = { targetingKey: 'user_distinct_id', plan: 'enterprise' } | ||
| const result = { |
There was a problem hiding this comment.
nit: These three flag reads run sequentially even though they're independent. Promise.all would model the better pattern here since example code tends to get copy-pasted, though at 3 flags in a one-shot demo the actual cost is negligible.
There was a problem hiding this comment.
Done in c2ad670 — the three independent reads now run via Promise.all.
- initialize(): reloadFeatureFlags() never rejects — posthog-node swallows
its poller errors and surfaces the client/network-error class via its
`error` event. Listen on that event around the reload so a genuine
misconfiguration is surfaced via console.warn; the previous try/catch was
dead code. Remote evaluation still works, so this never blocks readiness.
- mapping: guard blank number variants. Number('') and Number(' ') are 0
(not NaN), so an empty/whitespace variant previously resolved to 0 instead
of throwing TypeMismatchError.
- tests: replace the unrealistic reject-based preload test with one that
emits the client's `error` event and asserts console.warn fires (plus a
no-warn-on-success case); add an empty-string number-variant case.
- example: evaluate the three independent flag reads with Promise.all.
Generated-By: PostHog Code
Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a
| return this._client.getFeatureFlagResult(flagKey, distinctId, { | ||
| groups: Object.keys(groups).length > 0 ? groups : undefined, | ||
| personProperties: | ||
| Object.keys(personProperties).length > 0 ? (personProperties as Record<string, string>) : undefined, | ||
| groupProperties: | ||
| Object.keys(groupProperties).length > 0 | ||
| ? (groupProperties as Record<string, Record<string, string>>) | ||
| : undefined, | ||
| sendFeatureFlagEvents: this._sendFeatureFlagEvents, | ||
| }) |
There was a problem hiding this comment.
Incorrect type casts will cause runtime type mismatches. The personProperties is Record<string, unknown> but is cast to Record<string, string>, and groupProperties is Record<string, Record<string, unknown>> but is cast to Record<string, Record<string, string>>. OpenFeature contexts can contain any JSON value (numbers, booleans, objects, arrays, etc.), not just strings.
If getFeatureFlagResult truly requires string values, these should be converted/serialized, not just cast. If it accepts any values, remove the incorrect casts:
personProperties:
Object.keys(personProperties).length > 0 ? personProperties : undefined,
groupProperties:
Object.keys(groupProperties).length > 0 ? groupProperties : undefined,The current casts only hide the type mismatch at compile time but will pass invalid types at runtime when non-string values are present in the evaluation context.
| return this._client.getFeatureFlagResult(flagKey, distinctId, { | |
| groups: Object.keys(groups).length > 0 ? groups : undefined, | |
| personProperties: | |
| Object.keys(personProperties).length > 0 ? (personProperties as Record<string, string>) : undefined, | |
| groupProperties: | |
| Object.keys(groupProperties).length > 0 | |
| ? (groupProperties as Record<string, Record<string, string>>) | |
| : undefined, | |
| sendFeatureFlagEvents: this._sendFeatureFlagEvents, | |
| }) | |
| return this._client.getFeatureFlagResult(flagKey, distinctId, { | |
| groups: Object.keys(groups).length > 0 ? groups : undefined, | |
| personProperties: | |
| Object.keys(personProperties).length > 0 ? personProperties : undefined, | |
| groupProperties: | |
| Object.keys(groupProperties).length > 0 ? groupProperties : undefined, | |
| sendFeatureFlagEvents: this._sendFeatureFlagEvents, | |
| }) | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Good catch on the intent — you're right that the values must reach getFeatureFlagResult unchanged, since the flags API matches on the native JSON type (e.g. a numeric age >= 21 filter), so serializing to strings would silently break comparisons.
The one wrinkle is that removing the casts outright doesn't compile: posthog-node's public option type narrows personProperties to Record<string, string> (and groupProperties to Record<string, Record<string, string>>), even though the internal type and the flags API accept any JSON value. So the assertion is needed purely to bridge that too-narrow upstream type — it changes nothing at runtime; the values are forwarded exactly as received.
Fixed in 9f8a7fe: values are passed through unchanged with a documented comment, empty records collapse to undefined via a nonEmpty() helper, and a new test (forwards non-string property values unchanged) asserts that age: 42, beta: true, and seats: 25 reach the client without coercion.
The flags API matches person/group properties on their native JSON type (e.g. a numeric `age >= 21` filter), but posthog-node's public option type narrows them to `Record<string, string>`. Forward context values through unchanged with a documented assertion that only bridges the too-narrow upstream type — coercing to strings here would break native-type matching. Add a `nonEmpty` helper and a test proving no coercion happens. Also repair the pnpm-lock.yaml entry for the openfeature-node-provider importer, which the main merge left pointing at stale peer resolutions (api-extractor 7.55.1 -> 7.58.9, babel/core 7.28.5 -> 7.29.7). Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a
* Add OpenFeature (JavaScript) feature flags provider docs Documents the two official PostHog OpenFeature providers for JavaScript (@posthog/openfeature-node and @posthog/openfeature-web): installation, usage, evaluation-context mapping, supported flag types, and options. Companion to the posthog-js implementation PR (PostHog/posthog-js#3994), mirroring the existing OpenFeature (Python) page. Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a * Update package names to add -provider suffix Match the renamed packages: @posthog/openfeature-node-provider and @posthog/openfeature-web-provider (install commands and import snippets). Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Based on PostHog/posthog-python#695, adds
@posthog/openfeature-node-provider— the official PostHog provider for the OpenFeature server SDK (@openfeature/server-sdk), backed byposthog-node.OpenFeature ships two SDKs with incompatible Provider contracts — the server SDK is async with a per-call evaluation context (distinct id from
targetingKey), versus the web SDK's synchronous, static-context model. So the two providers are separate packages, each pulling in only its own paradigm's SDK. The Python parent PR is server-only (no browser runtime); this pair covers both.Behaviour
The server model is stateless and multi-user: the distinct id arrives per evaluation from the context's
targetingKey, and resolution is async. Context mapping:targetingKey→distinctId, reservedgroups/groupProperties→ PostHog equivalents, every other attribute →personProperties.Flag-type mapping (via
getFeatureFlagResult): boolean →enabled, string → multivariatevariant, number → parsedvariant, object → JSONpayload. A missing flag resolves toFLAG_NOT_FOUND; asking for a type the flag can't provide resolves toTYPE_MISMATCH(caller gets its default, per spec).One deliberate divergence from Python: posthog-node's
FeatureFlagResulthas noreasonfield, so reason mapping simplifies toenabled ? TARGETING_MATCH : DEFAULTand there's noposthog_reasonmetadata.Options
sendFeatureFlagEvents(defaulttrue) — control$feature_flag_calledcapture.defaultDistinctId— when set, evaluations without atargetingKeyuse it instead of raisingTARGETING_KEY_MISSING.Publishing
Marked for release (changeset + entry in the
release.ymlpublish matrix); created on npm under the@posthogscope on first publish, gated on the standard merge +NPM Releaseapproval flow.Docs
Usage lives in the PostHog docs (kept out of the package README so it doesn't drift): PostHog/posthog.com#18105 —
/docs/feature-flags/installation/openfeature-js(covers both the node and web providers).Testing
@openfeature/server-sdkclient.pnpm install --frozen-lockfileconsistent🤖 Generated with Claude Code