feat: add official PostHog OpenFeature web provider (browser)#4069
Conversation
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
|
Reviews (1): Last reviewed commit: "feat(openfeature): add web provider (@po..." | Re-trigger Greptile |
| it('resolves on timeout when the flags callback never fires', async () => { | ||
| // onFeatureFlags never invokes its callback and reloadFeatureFlags is a no-op, | ||
| // so only the reloadTimeoutMs safety net can settle initialize(). | ||
| const client = { | ||
| getFeatureFlagResult: jest.fn(), | ||
| reloadFeatureFlags: jest.fn(), | ||
| onFeatureFlags: jest.fn(() => () => {}), | ||
| setPersonPropertiesForFlags: jest.fn(), | ||
| group: jest.fn(), | ||
| } as unknown as PostHog | ||
| const provider = new PostHogWebProvider(client, { reloadTimeoutMs: 20 }) | ||
| await expect(provider.initialize()).resolves.toBeUndefined() | ||
| }) |
There was a problem hiding this comment.
Timeout test relies on real wall-clock time
The test sets reloadTimeoutMs: 20 and awaits the promise, so every run incurs a real 20 ms delay. On a loaded CI machine this could flake if the scheduler doesn't wake the microtask queue in time. Using jest.useFakeTimers() + jest.runAllTimersAsync() (or jest.advanceTimersByTimeAsync) would make the test deterministic and instant.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Fixed in 85f34db — extended the throws on it.each table to cover every branch: number from an enabled boolean flag (no variant), number from a non-numeric variant, object from an enabled flag with no payload, and object from a non-object payload.
| it.each<[string, FlagResult | undefined, Resolve, ErrorCode]>([ | ||
| [ | ||
| 'string from a boolean flag (no variant)', | ||
| { key: 'flag', enabled: true }, | ||
| (p) => p.resolveStringEvaluation('flag', 'x'), | ||
| ErrorCode.TYPE_MISMATCH, | ||
| ], | ||
| [ | ||
| 'missing flag (client returns undefined)', | ||
| undefined, | ||
| (p) => p.resolveBooleanEvaluation('missing', false), | ||
| ErrorCode.FLAG_NOT_FOUND, | ||
| ], | ||
| ])('throws on %s', (_name, result, resolve, code) => { | ||
| const { client } = makeClient(result) | ||
| expect(() => resolve(new PostHogWebProvider(client))).toThrow(expect.objectContaining({ code })) | ||
| }) |
There was a problem hiding this comment.
Parameterised error-path coverage is incomplete
The throws on table only covers 2 of the available error paths. resolveNumberEvaluation and resolveObjectEvaluation each have distinct TypeMismatchError branches that go untested: a boolean flag (no variant) passed to resolveNumberEvaluation, a non-numeric variant string ('abc'), and a flag with no payload passed to resolveObjectEvaluation. Per the team's preference for parameterised tests, these should be added to the same it.each table rather than as separate it blocks.
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
There was a problem hiding this comment.
Fixed in 85f34db — converted this test to jest.useFakeTimers() + await jest.runAllTimersAsync(), so it's deterministic and instant instead of waiting on real wall-clock time.
|
Size Change: 0 B Total Size: 17 MB ℹ️ View Unchanged
|
…oel) 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
dustinbyrne
left a comment
There was a problem hiding this comment.
this won't release automatically given it's a new package - must follow instructions here:
https://posthog.com/handbook/engineering/sdks/releases#new-package-never-published-before
| private async _reconcile(context?: EvaluationContext): Promise<void> { | ||
| const { personProperties, groups, groupProperties } = splitContext(context) | ||
|
|
||
| if (Object.keys(personProperties).length > 0) { | ||
| this._client.setPersonPropertiesForFlags(personProperties, false) | ||
| } | ||
| for (const [groupType, groupKey] of Object.entries(groups)) { | ||
| this._client.group(groupType, groupKey, groupProperties[groupType]) | ||
| } | ||
|
|
||
| await this._reloadFlags() | ||
| } |
There was a problem hiding this comment.
worth noting that this is additive - properties/groups will never be removed from the client once set
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.
Adds
@posthog/openfeature-web-provider— the official PostHog provider for the OpenFeature web SDK (@openfeature/web-sdk), backed byposthog-js.OpenFeature deliberately ships two SDKs with incompatible Provider contracts — the web SDK is synchronous with a static evaluation context reconciled via
onContextChange, versus the server SDK's async, per-call model. So the two providers are separate packages, each pulling in only its own paradigm's SDK.Behaviour
The browser model is single-user and synchronous:
posthog-jsowns the user identity and holds flags in memory, so evaluation is synchronous. The provider reconciles the static evaluation context into the SDK oninitialize/onContextChange(person properties viasetPersonPropertiesForFlags, groups viagroup), then reloads flags. It never callsidentify()— the host app owns identity, sotargetingKeyis not used to switch users.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).Options
sendFeatureFlagEvents(defaulttrue) — control$feature_flag_calledcapture.reloadTimeoutMs(default5000) — max timeinitialize/onContextChangewaits forposthog-jsto (re)load flags before becoming ready anyway, so the OpenFeature client can't get stuckNOT_READYif the SDK never delivers its flags callback.Publishing
Marked for release (changeset + entry in the
release.ymlpublish matrix); the package is 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/web-sdkclient.pnpm install --frozen-lockfileconsistent🤖 Generated with Claude Code