feat(flags): add official PostHog OpenFeature provider#695
feat(flags): add official PostHog OpenFeature provider#695gustavohstrassburger wants to merge 5 commits into
Conversation
Adds an official PostHog provider for the OpenFeature Python SDK, shipped as a separate distribution (`openfeature-provider-posthog`) under the OpenFeature namespace package `openfeature.contrib.provider.posthog`, living in this repo alongside the SDK. The provider wraps a configured `posthog.Posthog` client and resolves all five OpenFeature flag types via the modern, non-deprecated `get_feature_flag_result` (one call yields value + variant + payload + reason): - boolean -> `enabled` - string -> the multivariate variant key - int/float -> the variant parsed as a number - object -> the flag's JSON payload (full object/JSON support) Evaluation context maps `targeting_key` -> `distinct_id`, reserved attributes `groups`/`group_properties` -> PostHog groups, and all other attributes -> `person_properties`. A missing targeting key raises `TargetingKeyMissingError` unless `default_distinct_id` is set; type mismatches raise `TypeMismatchError` so the OpenFeature client returns the caller's default per spec. Repo wiring: - New `openfeature-provider` CI job (mirrors the django5 integration job): uv sync, pytest, ruff, mypy, build + twine check in the sub-project's own env. - `mypy.ini`: exclude `openfeature-provider/.*` from the root mypy pass (the namespace tree is type-checked in its own env where openfeature-sdk is present). - README link to the provider. The new top-level `openfeature/` tree is isolated from the `posthog` build (explicit packages list) and from the `posthog.*`-scoped public-API snapshot. Generated-By: PostHog Code Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
|
Reviews (1): Last reviewed commit: "Add official PostHog OpenFeature provide..." | Re-trigger Greptile |
posthog-python Compliance ReportDate: 2026-06-26 21:25:23 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
- _map_reason: a disabled (enabled=False) result now maps to Reason.DEFAULT (the flag is active but no targeting condition matched), reserving Reason.DISABLED for when the reason text says the flag itself is off. PostHog returns None (-> FlagNotFoundError) for archived/missing flags. - initialize: log a WARNING (with exc_info) instead of silently swallowing a load_feature_flags() failure, so a misconfigured personal_api_key / host / permissions is visible while still falling back to remote evaluation. - tests: collapse the boolean reason-mapping and number-parsing cases into @pytest.mark.parametrize, and add coverage for the initialize logging paths. Generated-By: PostHog Code Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
|
Tested this locally end-to-end against a running PostHog instance (not just the mocked unit/e2e tests) — registered the provider with the OpenFeature SDK and evaluated a real boolean flag through Repro script I used: """
Example demonstrating how to use the PostHog OpenFeature provider.
This example shows:
1. Initializing the PostHog client
2. Registering the PostHogProvider with the OpenFeature SDK
3. Evaluating a boolean feature flag via the OpenFeature API
"""
import os
import posthog
from openfeature import api
from openfeature.evaluation_context import EvaluationContext
from openfeature.contrib.provider.posthog import PostHogProvider
# Initialize the PostHog client
posthog_client = posthog.Posthog(
project_api_key=os.getenv("POSTHOG_API_KEY", "phc_PIPWvLdxL4N9RUvpyENExMOFEz2jXuk5ehmyXFG3A2k"),
host=os.getenv("POSTHOG_HOST", "http://localhost:8010"),
)
# Register the PostHog provider with OpenFeature
provider = PostHogProvider(posthog_client)
api.set_provider(provider)
# Get the OpenFeature client
client = api.get_client()
# The flag key to evaluate
flag_key = os.getenv("POSTHOG_FLAG_KEY", "my-flag")
# Evaluation context: targeting_key maps to PostHog's distinct_id
ctx = EvaluationContext(targeting_key="test-user")
# Evaluate the boolean flag
result = client.get_boolean_details(flag_key, False, ctx)
print(f"Flag: {flag_key}")
print(f"Value: {result.value}")
print(f"Reason: {result.reason}")
if result.error_message:
print(f"Error: {result.error_message}")
posthog_client.shutdown()(The |
|
Reviews (2): Last reviewed commit: "Address review: add missing tests and op..." | Re-trigger Greptile |
haacked
left a comment
There was a problem hiding this comment.
Clean, well-tested provider, nothing blocking. A few non-blocking suggestions inline, the correctness one (TYPE_MISMATCH on unmatched experiments) is the one worth a decision.
I'll leave approval to the client libraries team who may want a look.
| evaluation_context: Optional[EvaluationContext] = None, | ||
| ) -> FlagResolutionDetails[str]: | ||
| result = self._resolve(flag_key, evaluation_context) | ||
| if result.variant is None: |
There was a problem hiding this comment.
suggestion: Unenrolled users get TYPE_MISMATCH errors when they shouldn't. When a user matches no condition on a multivariate flag, PostHog returns enabled=False, variant=None, so the result.variant is None check here raises TypeMismatchError. The OpenFeature SDK then returns the correct default value but marks it with error_code=TYPE_MISMATCH and reason=ERROR, so normal non-enrollment gets flagged as type-mismatch errors.
You can tell the two cases apart by enabled: when enabled=True, variant=None, it's a genuine mismatch (boolean flag read as string), while enabled=False, variant=None means the flag is off or nobody matched. Return the default with a non-error reason in that case:
result = self._resolve(flag_key, evaluation_context)
if result.variant is None:
if not result.enabled:
return FlagResolutionDetails(
value=default_value,
reason=self._map_reason(result),
flag_metadata=self._flag_metadata(result),
)
raise TypeMismatchError(
f"Flag '{flag_key}' has no string variant (boolean flag)."
)The same variant is None branch is in _resolve_number; keep the non-numeric-variant case there raising TypeMismatchError. One tradeoff: a disabled boolean flag read as a string would reclassify from TYPE_MISMATCH to DISABLED, since enabled=False, variant=None can't tell the two apart.
There was a problem hiding this comment.
Fixed in 8f24613. enabled=False, variant=None now returns the default with a normal reason (DEFAULT/DISABLED) instead of raising TypeMismatchError; the genuine boolean-read-as-string case (enabled=True, variant=None) still raises. Applied the same in _resolve_number, and (per Manoel) in resolve_object_details. Added detail tests asserting error_code is None on the non-enrollment path.
| groups = attrs.get(GROUPS_KEY) or {} | ||
| group_properties = attrs.get(GROUP_PROPERTIES_KEY) or {} | ||
| person_properties = {k: v for k, v in attrs.items() if k not in _RESERVED_KEYS} | ||
| groups = groups if isinstance(groups, dict) else {} |
There was a problem hiding this comment.
suggestion: Nothing tests the non-dict fallback here. test_context_split only passes well-formed dicts, so when a caller sets groups to a string or list, the isinstance(groups, dict) guard that coerces it to {} is unverified. Drop that guard in a later refactor and a non-dict would flow straight into get_feature_flag_result with no test to catch it.
Add a case mirroring test_context_split that passes groups="acme" and asserts the forwarded kwargs["groups"] is None.
There was a problem hiding this comment.
Fixed in 8f24613. Added test_context_split_non_dict_groups_coerced_to_none, parametrized over "acme", a list, and an int, asserting the forwarded groups/group_properties kwargs are None.
| - name: Set up Python 3.12 | ||
| uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 | ||
| with: | ||
| python-version: 3.12 |
There was a problem hiding this comment.
suggestion: The package declares support for Python 3.10 through 3.14 (requires-python plus classifiers in pyproject.toml), but this job only tests on 3.12. mypy targets 3.10, so type errors are caught, but nothing exercises the floor or ceiling at runtime. A 3.10-only incompatibility or a 3.14 behavior change would ship unverified.
The tests and import-check jobs already matrix across all five versions.
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
...
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: ${{ matrix.python-version }}There was a problem hiding this comment.
Agree the matrix should mirror the tests/import-check jobs (3.10–3.14). I do not edit .github/workflows/** in this automated review-fix turn (guardrail), so I have left this for a maintainer to apply directly.
| evaluation_context: Optional[EvaluationContext] = None, | ||
| ) -> FlagResolutionDetails[bool]: | ||
| result = self._resolve(flag_key, evaluation_context) | ||
| return FlagResolutionDetails( |
There was a problem hiding this comment.
nit: The four typed resolvers end with the same FlagResolutionDetails tail except for value. A small helper keeps the reason/flag_metadata wiring in one place:
def _details(self, value: _T, result: FeatureFlagResult) -> FlagResolutionDetails[_T]:
return FlagResolutionDetails(
value=value,
variant=result.variant,
reason=self._map_reason(result),
flag_metadata=self._flag_metadata(result),
)There was a problem hiding this comment.
Done in 8f24613 — extracted _details(value, result) and routed all four typed resolvers (and the default-return paths) through it.
| @@ -0,0 +1,103 @@ | |||
| # PostHog provider for OpenFeature (Python) | |||
There was a problem hiding this comment.
i'd move all install/snippets to https://posthog.com/docs/feature-flags and just point to the docs
single source of truth for docs, readme gets outdated
There was a problem hiding this comment.
Done in 8f24613 — slimmed the README to a minimal quickstart and pointed to https://posthog.com/docs/feature-flags as the single source of truth. Kept only the OpenFeature-specific registration snippet + context mapping (not yet on the docs site); those can move there once the provider is documented.
There was a problem hiding this comment.
Done earlier in 8f24613 — README slimmed to a minimal quickstart pointing at https://posthog.com/docs/feature-flags as the source of truth.
|
since its a new distribution, you'd need to adapt https://github.com/PostHog/posthog-python/blob/main/.github/workflows/release.yml to publish multiple packages |
|
|
||
| [project] | ||
| name = "openfeature-provider-posthog" | ||
| version = "0.1.0" |
There was a problem hiding this comment.
needs to set up sampo here, see other comment related to the release process
There was a problem hiding this comment.
The release wiring (Sampo for the new package + adapting release.yml to publish a second distribution) is a .github/workflows/** + release-config change I will not make in this automated turn. Flagged for a maintainer in a summary comment on the PR.
There was a problem hiding this comment.
Implemented in 5236b4b. Sampo already auto-discovers the package as pypi/openfeature-provider-posthog (it has its own pyproject with name+version), and release.yml now builds/publishes/tags it — gated so it only fires when a changeset bumps the provider, leaving posthog-only releases untouched. Added openfeature-provider/CHANGELOG.md for Sampo to maintain. First publish still needs a one-time PyPI trusted-publisher registration for the new project.
|
Optional nice-to-have: add a CI smoke test that installs the built wheel into a fresh env and imports openfeature.contrib.provider.posthog, since namespace packaging issues are easy to miss with source-tree tests. |
|
The unmatched-user/default behavior should include object flags as well? |
|
left a bunch of comments, main blocker is the release process and a few other comments/suggestions, Phil added good points as well |
Adds `@posthog/openfeature-provider`, a JS port of the Python OpenFeature provider (PostHog/posthog-python#695). OpenFeature ships two SDKs with incompatible Provider contracts, so this package ships one provider for each, sharing a runtime-agnostic mapping core: - `/server` — `PostHogServerProvider` wraps `posthog-node` against `@openfeature/server-sdk` (async, multi-user, distinct id from `targetingKey`). - `/web` — `PostHogWebProvider` wraps `posthog-js` against `@openfeature/web-sdk` (synchronous, single-user, context reconciled via `onContextChange`). Both resolve through `getFeatureFlagResult`: boolean→enabled, string→variant, number→parsed variant, object→payload; missing flag→FLAG_NOT_FOUND, wrong type→TYPE_MISMATCH. The SDK/client deps are optional peers so a node-only or browser-only app never installs the other paradigm's stack. Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a
Addresses review feedback from @haacked and @marandaneto. - Correctness (haacked + marandaneto): a user who matches no condition or a disabled flag (enabled=False, with no variant/object payload) is no longer reported as a TYPE_MISMATCH error. The string/integer/float/object resolvers now return the caller's default with a normal reason (DEFAULT/DISABLED) in that case, and only raise TypeMismatchError for a genuine mismatch (enabled=True but the value can't be coerced to the requested type). Applied consistently across string, number, and object. - Refactor (haacked): extract a shared `_details()` helper for the repeated reason/flag_metadata wiring across the typed resolvers. - Tests (haacked): add non-dict `groups`/`group_properties` coercion cases asserting they are forwarded as None; add detail tests asserting unmatched string/number/object resolution returns the default with no error_code. - Docs (marandaneto): slim the README to a minimal quickstart and point to https://posthog.com/docs/feature-flags as the single source of truth. Generated-By: PostHog Code Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
Implements the remaining review suggestions from @marandaneto. - Release (release.yml): publish `openfeature-provider-posthog` as a second distribution. Sampo already auto-discovers the package (it has its own pyproject), so the new steps only build/publish/tag the provider when this release actually bumped its version (a changeset targeting `pypi/openfeature-provider-posthog`). posthog-only releases skip them, and they run after the posthog publish/tag/release so they can never block the core release. Uses the same PyPI OIDC trusted-publishing action as posthog/posthoganalytics (a trusted publisher for the new project must be registered before the first provider release). - Sampo: add openfeature-provider/CHANGELOG.md for Sampo to maintain. - CI (ci.yml): add a clean-env smoke test that installs the built wheel and imports openfeature.contrib.provider.posthog, catching namespace-packaging regressions that source-tree tests miss. - pyproject: pin posthog to the tested major (>=7.0.0,<8.0.0); the local-branch build via [tool.uv.sources] was already in place. Generated-By: PostHog Code Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
|
@marandaneto — implemented your remaining suggestions in
One manual prerequisite for the first provider release (can't be done in a PR): register a PyPI trusted publisher for Verified locally: 37 tests, ruff, mypy, |
|
Still not fixed / still worth commenting:
those are still open, the 2. is optional but i think its cool to test across all major versions like we do for the main package |
What
Adds an official PostHog provider for the OpenFeature Python SDK, built in this repo and shipped as a separate distribution (
openfeature-provider-posthog) under the OpenFeature namespace packageopenfeature.contrib.provider.posthog.It wraps a configured
posthog.Posthogclient and resolves all five OpenFeature flag types via the modern, non-deprecatedget_feature_flag_result(one call → value + variant + payload + reason):get_boolean_valueenabledget_string_valueget_integer_value/get_float_valueget_object_valueEvaluation context maps
targeting_key→distinct_id, reserved attributesgroups/group_properties→ PostHog groups, and every other attribute →person_properties.Behavior decisions (recommended defaults, easy to change)
targeting_key→TargetingKeyMissingError(OpenFeature-idiomatic; SDK returns the caller's default). Opt into anonymous eval viadefault_distinct_id="anonymous".get_string_valueon a boolean flag, non-numeric variant for int, non-object payload) →TypeMismatchError→ default returned, per spec.send_feature_flag_events=Trueby default (keeps$feature_flag_called/ experiments working); toggleable.posthog>=6.0.0floor.Layout
Repo wiring
openfeature-providerjob mirroringdjango5-integration—uv sync, pytest, ruff format/check, mypy, thenuv build+twine checkin the sub-project's own env.mypy.ini: excludesopenfeature-provider/.*from the root mypy pass (the namespace tree is type-checked in its own env whereopenfeature-sdkis installed).The new top-level
openfeature/tree is isolated from theposthogwheel (explicit packages list) and from theposthog.*-scoped public-API snapshot — verified.Verification
uv build+twine checkpass; wheel ships only the leaf package +py.typed, no clobberingopenfeature/__init__.py, no testsopenfeature/entries;public_api_checksnapshot up to dateFollow-ups (not in this PR)
openfeature-provider-posthogbefore first publish. A third-partyposthog-openfeature-provider-python(MPL-2.0, deprecated API, no object support) already exists on PyPI; this one is official, MIT, and uses the modern API with full object/JSON support.posthog/posthoganalytics; this dist needs its own version/tag + PyPI publish job. Noposthoganalyticstwin needed.🤖 Generated with Claude Code