feat: add official PostHog OpenFeature server provider (Node)#3994
feat: add official PostHog OpenFeature server provider (Node)#3994gustavohstrassburger wants to merge 1 commit into
Conversation
|
Reviews (1): Last reviewed commit: "feat: add official PostHog OpenFeature p..." | Re-trigger Greptile |
|
Size Change: 0 B Total Size: 17.1 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
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