Skip to content

feat(flags): add official PostHog OpenFeature provider#695

Draft
gustavohstrassburger wants to merge 11 commits into
mainfrom
posthog-code/openfeature-provider
Draft

feat(flags): add official PostHog OpenFeature provider#695
gustavohstrassburger wants to merge 11 commits into
mainfrom
posthog-code/openfeature-provider

Conversation

@gustavohstrassburger

@gustavohstrassburger gustavohstrassburger commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

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 package openfeature.contrib.provider.posthog.

It wraps a configured posthog.Posthog client and resolves all five OpenFeature flag types via the modern, non-deprecated get_feature_flag_result (one call → value + variant + payload + reason):

OpenFeature method Resolves to
get_boolean_value flag enabled
get_string_value multivariate variant key
get_integer_value / get_float_value variant parsed as a number
get_object_value flag's JSON payload (full object support)

Evaluation context maps targeting_keydistinct_id, reserved attributes groups / group_properties → PostHog groups, and every other attribute → person_properties.

Behavior decisions (recommended defaults, easy to change)

  • Missing targeting_keyTargetingKeyMissingError (OpenFeature-idiomatic; SDK returns the caller's default). Opt into anonymous eval via default_distinct_id="anonymous".
  • Type mismatch (e.g. get_string_value on a boolean flag, non-numeric variant for int, non-object payload) → TypeMismatchError → default returned, per spec.
  • send_feature_flag_events=True by default (keeps $feature_flag_called / experiments working); toggleable.
  • Build backend setuptools (repo-consistent); posthog>=6.0.0 floor.

Layout

openfeature-provider/
├── pyproject.toml                # dist: openfeature-provider-posthog (MIT, py>=3.10)
├── README.md / LICENSE / .gitignore / uv.lock
├── openfeature/contrib/provider/posthog/   # PEP 420 namespace; __init__ only at leaf
│   ├── __init__.py  ├── provider.py  └── py.typed
└── tests/            # 20 unit + end-to-end tests (mocked client)

Repo wiring

  • CI: new openfeature-provider job mirroring django5-integrationuv sync, pytest, ruff format/check, mypy, then uv build + twine check in the sub-project's own env.
  • mypy.ini: excludes openfeature-provider/.* from the root mypy pass (the namespace tree is type-checked in its own env where openfeature-sdk is installed).
  • README: link to the provider.

The new top-level openfeature/ tree is isolated from the posthog wheel (explicit packages list) and from the posthog.*-scoped public-API snapshot — verified.

Verification

  • ✅ 20/20 tests pass
  • ✅ ruff format + lint clean under both root (0.12.2) and sub-project (0.15.17)
  • ✅ mypy clean (sub-project env + full root run, 127 files)
  • uv build + twine check pass; wheel ships only the leaf package + py.typed, no clobbering openfeature/__init__.py, no tests
  • ✅ no repo-wide regression: posthog wheel/sdist contain zero openfeature/ entries; public_api_check snapshot up to date

Follow-ups (not in this PR)

  • Confirm the PyPI distribution name openfeature-provider-posthog before first publish. A third-party posthog-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.
  • Publish workflow: the repo's release flow only ships posthog/posthoganalytics; this dist needs its own version/tag + PyPI publish job. No posthoganalytics twin needed.

Docs

Documentation (install + usage snippets) lives in the PostHog docs, not in the package README (kept minimal so it doesn't drift). Companion docs PR: PostHog/posthog.com#18006 — adds /docs/feature-flags/installation/openfeature.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "Add official PostHog OpenFeature provide..." | Re-trigger Greptile

Comment thread openfeature-provider/openfeature/contrib/provider/posthog/provider.py Outdated
Comment thread openfeature-provider/tests/test_provider_unit.py Outdated
Comment thread openfeature-provider/openfeature/contrib/provider/posthog/provider.py Outdated
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-06-30 14:31:37 UTC
Duration: 176176ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 518ms
Format Validation.Event Has Uuid 1508ms
Format Validation.Event Has Lib Properties 1507ms
Format Validation.Distinct Id Is String 1506ms
Format Validation.Token Is Present 1507ms
Format Validation.Custom Properties Preserved 1507ms
Format Validation.Event Has Timestamp 1508ms
Retry Behavior.Retries On 503 9518ms
Retry Behavior.Does Not Retry On 400 3506ms
Retry Behavior.Does Not Retry On 401 3508ms
Retry Behavior.Respects Retry After Header 9514ms
Retry Behavior.Implements Backoff 23517ms
Retry Behavior.Retries On 500 7518ms
Retry Behavior.Retries On 502 7508ms
Retry Behavior.Retries On 504 7516ms
Retry Behavior.Max Retries Respected 23525ms
Deduplication.Generates Unique Uuids 1502ms
Deduplication.Preserves Uuid On Retry 7515ms
Deduplication.Preserves Uuid And Timestamp On Retry 14511ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7513ms
Deduplication.No Duplicate Events In Batch 1508ms
Deduplication.Different Events Have Different Uuids 1506ms
Compression.Sends Gzip When Enabled 1507ms
Batch Format.Uses Proper Batch Structure 1508ms
Batch Format.Flush With No Events Sends Nothing 1005ms
Batch Format.Multiple Events Batched Together 1505ms
Error Handling.Does Not Retry On 403 3509ms
Error Handling.Does Not Retry On 413 3508ms
Error Handling.Retries On 408 7514ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 1003ms
Request Payload.Flags Request Uses V2 Query Param 1006ms
Request Payload.Flags Request Hits Flags Path Not Decide 1007ms
Request Payload.Flags Request Omits Authorization Header 1007ms
Request Payload.Token In Flags Body Matches Init 1006ms
Request Payload.Groups Round Trip 1007ms
Request Payload.Groups Default To Empty Object 1006ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 1007ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 1006ms
Request Payload.Disable Geoip Omitted Defaults To False 1007ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 1006ms
Request Lifecycle.No Flags Request On Init Alone 503ms
Request Lifecycle.No Flags Request On Normal Capture 1507ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 1010ms
Request Lifecycle.Mock Response Value Is Returned To Caller 1003ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 1509ms

@gustavohstrassburger gustavohstrassburger changed the title Add official PostHog OpenFeature provider feat: add official PostHog OpenFeature provider Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

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 client.get_boolean_details(...), and it worked. 🎉

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 phc_ key above is a local-dev project key pointing at localhost:8010.) Confirms the full path works as installed: namespace import, provider registration, targeting_keydistinct_id mapping, and the boolean resolution returning the expected value/reason.

@gustavohstrassburger gustavohstrassburger requested a review from a team June 25, 2026 20:30
@posthog-project-board-bot posthog-project-board-bot Bot moved this to In Progress in Feature Flags Jun 25, 2026
@gustavohstrassburger gustavohstrassburger marked this pull request as ready for review June 25, 2026 20:33
@gustavohstrassburger gustavohstrassburger requested a review from a team as a code owner June 25, 2026 20:33
@posthog-project-board-bot posthog-project-board-bot Bot moved this from In Progress to In Review in Feature Flags Jun 25, 2026
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Reviews (2): Last reviewed commit: "Address review: add missing tests and op..." | Re-trigger Greptile

@haacked haacked left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread .github/workflows/ci.yml Outdated
- name: Set up Python 3.12
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),
        )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8f24613 — extracted _details(value, result) and routed all four typed resolvers (and the default-return paths) through it.

@haacked haacked changed the title feat: add official PostHog OpenFeature provider feat(flags): add official PostHog OpenFeature provider Jun 26, 2026
Comment thread openfeature-provider/README.md Outdated
@@ -0,0 +1,103 @@
# PostHog provider for OpenFeature (Python)

@marandaneto marandaneto Jun 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done earlier in 8f24613 — README slimmed to a minimal quickstart pointing at https://posthog.com/docs/feature-flags as the source of truth.

@marandaneto

Copy link
Copy Markdown
Member

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
you can check ruby/rails, the js monorepo, etc, since they also publish multiple packages within the release process

Comment thread openfeature-provider/pyproject.toml Outdated

[project]
name = "openfeature-provider-posthog"
version = "0.1.0"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to set up sampo here, see other comment related to the release process

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my earlier reply — you were right, Sampo does not auto-discover this package. I checked sampo-core's pip adapter: it only discovers packages from the root pyproject's [tool.uv.workspace] members (plus the root). Fixed properly in b40251e by declaring a uv workspace (members = ["openfeature-provider"]) so sampo add -p pypi/openfeature-provider-posthog works and sampo release bumps the provider. Details in the PR comment.

@marandaneto

Copy link
Copy Markdown
Member

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.

@marandaneto

Copy link
Copy Markdown
Member

The unmatched-user/default behavior should include object flags as well?
- Existing comment covers string/number variant is None.
- resolve_object_details has the same issue when enabled=False and payload=None: it raises TypeMismatchError, so
OpenFeature reports Reason.ERROR / TYPE_MISMATCH instead of a normal default.
- See openfeature-provider/openfeature/contrib/provider/posthog/provider.py:196.
- If they fix non-enrollment handling, apply it consistently to string/number/object and add detail tests checking
no error_code.

@marandaneto

Copy link
Copy Markdown
Member

left a bunch of comments, main blocker is the release process and a few other comments/suggestions, Phil added good points as well

gustavohstrassburger added a commit to PostHog/posthog-js that referenced this pull request Jun 26, 2026
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

Copy link
Copy Markdown
Contributor Author

@marandaneto — implemented your remaining suggestions in 5236b4b:

  • Release (release.yml) now publishes openfeature-provider-posthog as a second distribution. Sampo already auto-discovers it as pypi/openfeature-provider-posthog (own pyproject with name + version), and short_tags = "posthog" means it tags as openfeature-provider-posthog-v{version}. The new build/publish/tag steps are gated — they only run when this release actually bumped the provider (a changeset targeting it), so posthog-only releases skip them, and they run after the posthog publish/tag/release so a provider issue can never block the core release.
  • Sampo: added openfeature-provider/CHANGELOG.md for it to maintain.
  • CI smoke test: the provider job now installs the built wheel into a clean env and imports openfeature.contrib.provider.posthog — catches namespace-packaging regressions source-tree tests miss.
  • Pin: posthog>=7.0.0,<8.0.0 (tested major); local-branch build via [tool.uv.sources] was already in place.

One manual prerequisite for the first provider release (can't be done in a PR): register a PyPI trusted publisher for openfeature-provider-posthog pointing at release.yml + the Release environment, exactly like posthog/posthoganalytics. After that, adding a changeset that bumps the provider triggers its first publish.

Verified locally: 37 tests, ruff, mypy, uv build + clean-env wheel install/import all pass; both workflow YAMLs parse. The release flow itself can only be fully exercised on a real release run.

@marandaneto

Copy link
Copy Markdown
Member

@gustavohstrassburger

Still not fixed / still worth commenting:

  1. Provider release/versioning is still broken

    • Release workflow assumes Sampo can process changesets for pypi/openfeature-provider-posthog.
    • But Sampo does not know that package. I verified in a temp worktree:
      sampo add -p pypi/openfeature-provider-posthog -b patch -m test
      # Failed: Package 'pypi/openfeature-provider-posthog' not found in the workspace.

    So openfeature-provider/pyproject.toml won’t be bumped by Sampo, and the release workflow’s detection at
    .github/workflows/release.yml:245 likely never triggers for this package.

  2. Provider CI is still only Python 3.12

    • openfeature-provider/pyproject.toml claims Python 3.10–3.14 support.
    • .github/workflows/ci.yml:268-271 still only sets up 3.12.

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

@gustavohstrassburger gustavohstrassburger moved this from In Review to In Progress in Feature Flags Jun 27, 2026
Comment thread .github/workflows/release.yml Outdated
Comment on lines +230 to +275
# Publish the `openfeature-provider-posthog` distribution, but only when
# this release actually bumped it (a changeset targeting
# `pypi/openfeature-provider-posthog` was processed by Sampo, changing its
# version in openfeature-provider/pyproject.toml). Releases that only touch
# `posthog` skip these steps entirely, so the core release is unaffected.
# These run after the posthog publish/tag/release above so a provider issue
# can never block the main release.
#
# NOTE: the first provider release requires a PyPI trusted publisher to be
# registered for `openfeature-provider-posthog` (this workflow, environment
# "Release"), exactly like posthog/posthoganalytics.
- name: Detect openfeature-provider release
id: of-provider
if: steps.commit-release.outputs.commit-hash != ''
run: |
if git diff --name-only HEAD~1 HEAD -- openfeature-provider/pyproject.toml | grep -q .; then
version=$(python3 -c "import tomllib; print(tomllib.load(open('openfeature-provider/pyproject.toml','rb'))['project']['version'])")
echo "released=true" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "openfeature-provider-posthog bumped to $version; will publish."
else
echo "released=false" >> "$GITHUB_OUTPUT"
echo "openfeature-provider-posthog not changed in this release; skipping."
fi

- name: Build openfeature-provider-posthog
if: steps.of-provider.outputs.released == 'true'
working-directory: openfeature-provider
run: uv build --package openfeature-provider-posthog --out-dir dist

- name: Publish openfeature-provider-posthog to PyPI
if: steps.of-provider.outputs.released == 'true'
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
packages-dir: openfeature-provider/dist

- name: Tag openfeature-provider release
if: steps.of-provider.outputs.released == 'true'
env:
GH_TOKEN: ${{ steps.releaser.outputs.token }}
PROVIDER_VERSION: ${{ steps.of-provider.outputs.version }}
COMMIT_HASH: ${{ steps.commit-release.outputs.commit-hash }}
run: |
gh api "repos/${{ github.repository }}/git/refs" \
-f "ref=refs/tags/openfeature-provider-posthog-v${PROVIDER_VERSION}" \
-f "sha=${COMMIT_HASH}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think a build matrix is better as suggested before
for example https://github.com/PostHog/posthog-ruby/blob/4579a7f2c1bb253bf67cb560f14a72190118ff4f/.github/workflows/release.yml#L222-L233
those are also N packages

Comment thread openfeature-provider/README.md Outdated
Comment on lines +43 to +51
## Development

```bash
cd openfeature-provider
uv sync --extra dev
uv run pytest
uv run ruff check .
uv run mypy .
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread openfeature-provider/README.md Outdated
Comment on lines +16 to +41
## Install

```bash
pip install openfeature-provider-posthog
```

## Quickstart

```python
import posthog
from openfeature import api
from openfeature.evaluation_context import EvaluationContext
from openfeature.contrib.provider.posthog import PostHogProvider

client = posthog.Posthog("phc_project_api_key", host="https://us.i.posthog.com")
api.set_provider(PostHogProvider(client, default_distinct_id="anonymous"))

of_client = api.get_client()
ctx = EvaluationContext(targeting_key="user-123", attributes={"plan": "pro"})
enabled = of_client.get_boolean_value("my-flag", False, ctx)
```

The OpenFeature `targeting_key` maps to PostHog's `distinct_id`; other context
attributes become person properties, with reserved keys `groups` and
`group_properties` mapping to PostHog groups. You own the `Posthog` client
lifecycle — call `client.shutdown()` when done.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this gets outdated super fast, lets point to our docs only, you can link to the specific open feature section instead

@gustavohstrassburger gustavohstrassburger marked this pull request as draft June 29, 2026 13:26
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
- _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
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
Fixes the release/versioning gap @marandaneto found: `sampo add -p
pypi/openfeature-provider-posthog` failed with "not found in the workspace".
Sampo's PyPI adapter only discovers packages listed in the root pyproject's
[tool.uv.workspace] members (plus the root) — there is no auto-discovery — so the
provider was invisible to Sampo and the release detection never triggered.

- Root pyproject: declare a uv workspace with `members = ["openfeature-provider"]`.
  Sampo now discovers `pypi/openfeature-provider-posthog`, so `sampo add` works and
  `sampo release` bumps openfeature-provider/pyproject.toml from a changeset. The
  root uv.lock change is purely additive (only the member + openfeature-sdk; no
  churn to existing posthog pins), and the main `posthog` build is unaffected
  (explicit packages list).
- Provider pyproject: resolve posthog via `{ workspace = true }` instead of a
  path source; drop the now-redundant standalone uv.lock (the workspace shares the
  root lock).
- CI: the provider job now matrixes Python 3.10–3.14 (matching the main package,
  per @marandaneto/@haacked) and uses workspace-aware commands
  (`--package openfeature-provider-posthog`, `uv build --out-dir dist`).
- release.yml: build the provider with `--package ... --out-dir dist` so its
  artifacts stay isolated from the posthog dist.

Verified locally: root posthog sync + import, provider sync/tests (37)/ruff/mypy/
build/twine/clean-env-import across the workspace, and the django5 integration
project (not a member) still syncs standalone. Sampo itself couldn't be run here
(no cargo), but the config matches exactly what its pip adapter parses.

Generated-By: PostHog Code
Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
Addresses further review from @marandaneto.

- release.yml: restructure into the canonical PostHog multi-package shape (cf.
  posthog-ruby). A single approval-gated `version-bump` job (environment
  "Release") bumps versions, commits, and regenerates references; a separate
  `publish` job with no environment runs a `package` matrix
  (fail-fast, max-parallel 1) over posthog, posthoganalytics, and
  openfeature-provider-posthog. Each entry detects whether its version changed
  in the release commit and only then builds/publishes/tags. Keeping the
  approval on version-bump (not the matrix) preserves a single approval per
  release. NOTE: the no-environment publish job means each package's PyPI
  trusted publisher must point at the `publish` job (not an environment) — a
  one-time PyPI-side config matraneto/maintainers control.
- README: drop the install/quickstart snippets that drift; point to
  https://posthog.com/docs/feature-flags (OpenFeature section) as the single
  source of truth.
- Move the development instructions out of the README into a dedicated
  openfeature-provider/CONTRIBUTING.md with the workspace-aware commands.

Generated-By: PostHog Code
Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
Per review: don't keep a separate provider README that drifts — point to the
PostHog docs (single source of truth) instead. Companion docs page is added in
PostHog/posthog.com#18006.

- Delete openfeature-provider/README.md.
- pyproject: drop the `readme` field (it referenced the deleted file) and add a
  Documentation URL so the PyPI page links straight to the docs.
- Root README: the OpenFeature entry now links to
  https://posthog.com/docs/feature-flags/installation/openfeature instead of the
  in-repo provider README.

uv build + twine check still pass (only a non-fatal missing-long_description
warning, expected without a README).

Generated-By: PostHog Code
Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
Follow-up to dropping the provider README (these were left unstaged in the
prior commit, which deleted the README but not its references):

- openfeature-provider/pyproject.toml: drop the `readme = "README.md"` field
  (it pointed at the now-deleted file and would break the build) and add a
  Documentation URL to [project.urls] so PyPI links straight to the docs.
- README.md: the OpenFeature entry links to
  https://posthog.com/docs/feature-flags/installation/openfeature.

Generated-By: PostHog Code
Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32

Copy link
Copy Markdown
Contributor Author

Review feedback — all addressed ✅

Thanks @marandaneto and @haacked (and Greptile) for the thorough review. Summary of what changed and how.

Greptile

  • Reason mapping (DISABLED returned for every disabled flag) → now maps a not-enabled/unmatched result to Reason.DEFAULT, reserving DISABLED for when the reason text actually says the flag is off.
  • Silent initialize swallow → a failed load_feature_flags() now logs a WARNING (with exc_info) instead of pass, so a misconfigured personal_api_key/host is visible while still falling back to remote eval.
  • Repeated tests → collapsed into @pytest.mark.parametrize.

@haacked

  • Unenrolled users got TYPE_MISMATCH → string/integer/float/object resolvers now return the caller's default with a normal reason when enabled=False (no condition matched / flag off); only a genuine mismatch (enabled=True but uncoercible) raises. Added detail tests asserting error_code is None on the non-enrollment path.
  • _details nit → extracted a shared helper for the reason/flag_metadata wiring across the typed resolvers.
  • Non-dict groups untested → added a coercion test (non-dict groups/group_properties forwarded as None).
  • CI only on 3.12 → the provider CI job now matrixes Python 3.10–3.14, mirroring the main package.

@marandaneto

  • "Set up Sampo" / sampo add ... not found → root pyproject.toml now declares a uv workspace ([tool.uv.workspace] members = ["openfeature-provider"]), so Sampo discovers pypi/openfeature-provider-posthog. The root uv.lock delta is purely additive (member + openfeature-sdk); the main posthog build is unaffected.
  • Build from the local branch → the provider resolves posthog via { workspace = true }; added openfeature-provider/CHANGELOG.md for Sampo.
  • Publish N packages / use a build matrixrelease.yml restructured into an approval-gated version-bump job + a no-environment publish matrix over posthog, posthoganalytics, and openfeature-provider-posthog (fail-fast, max-parallel: 1), each gated on whether its version changed. Single approval preserved.
  • Wheel-install smoke test → CI installs the built wheel into a clean env and imports openfeature.contrib.provider.posthog.
  • README drifts / point to docs only → the separate provider README is removed entirely; the package ([project.urls] Documentation) and the root README point straight to the docs. Docs content lives in Add OpenFeature (Python) feature flags provider docs posthog.com#18006 (/docs/feature-flags/installation/openfeature).
  • Dev instructions → moved to openfeature-provider/CONTRIBUTING.md.
  • Pin posthogposthog>=7.0.0,<8.0.0.

Status

  • 37 provider tests pass; ruff/mypy clean; provider CI matrix (3.10–3.14), build + wheel smoke test green.
  • The branch was rebased onto current main (7.21.1 + posthog.mcp), which also resolved a merge conflict that had been blocking CI.

One follow-up for maintainers (not blocking this PR)

The publish matrix job runs without a deployment environment (so the matrix doesn't multiply the approval prompt — approval stays on version-bump). For OIDC to work, each package's PyPI trusted publisher should point at the publish job rather than the Release environment — a one-time PyPI-side config.

Comment thread .github/workflows/release.yml Outdated
version_file: pyproject.toml
build: uv run make build_release
packages_dir: dist
tag_prefix: "v"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
tag_prefix: "v"
tag_prefix: "posthog-v"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it makes sense like this now

build: uv run make build_release
packages_dir: dist
tag_prefix: "v"
changelog: CHANGELOG.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should move this to posthog/CHANGELOG.md now
and the root changelog points to the inner changelogs
similar to https://github.com/PostHog/posthog-ruby/blob/main/CHANGELOG.md

Comment thread .github/workflows/release.yml Outdated
Comment on lines +331 to +339
- name: Create ${{ matrix.package.name }} GitHub Release
if: steps.detect.outputs.has-new-version == 'true' && matrix.package.github_release
env:
GH_TOKEN: ${{ steps.releaser.outputs.token }}
TAG: ${{ matrix.package.tag_prefix }}${{ steps.detect.outputs.version }}
CHANGELOG_FILE: ${{ matrix.package.changelog }}
run: |
CHANGELOG_ENTRY=$(awk -v defText="see ${CHANGELOG_FILE}" '/^## /{if (flag) exit; flag=1; next} flag; END{if (!flag) print defText}' "${CHANGELOG_FILE}" | sed '/[^[:space:]]/,$!d' | tac | sed '/[^[:space:]]/,$!d' | tac)
gh release create "$TAG" --notes "$CHANGELOG_ENTRY"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here

Comment thread openfeature-provider/CHANGELOG.md Outdated
@@ -0,0 +1,5 @@
# Changelog

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Changelog
# openfeature-provider-posthog

make sure the https://github.com/PostHog/posthog-python/pull/695/changes#r3498456802 works here since its fragile

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a few comments still, but approving to unblock

@github-project-automation github-project-automation Bot moved this from In Progress to Approved in Feature Flags Jun 30, 2026
@marandaneto

Copy link
Copy Markdown
Member

probably requires setting up pypi for the new package (trusted publisher)

Addresses @marandaneto's 2026-06-30 review comments:

- openfeature-provider/CHANGELOG.md: title is now `# openfeature-provider-posthog`
  (was `# Changelog`), so Sampo's `## <version>` sections sit under a
  package-named heading.
- release.yml: make the GitHub release-notes extraction robust (the previous
  awk | sed | tac | sed | tac pipeline was fragile). It now falls back to a
  "See <CHANGELOG>" pointer when the changelog is missing or has no section yet
  (e.g. a package's first release), so a release is never blocked on notes.
  Verified against the provider changelog (no section -> fallback), the root
  posthog changelog (extracts the latest section), and a missing file.

Not changed, with rationale:
- posthog tag prefix kept as `v` (not `posthog-v`): the Sampo config sets
  `short_tags = "posthog"` which intentionally tags the posthog package as
  `v{version}`, matching existing `vX.Y.Z` tags. Switching to `posthog-v` would
  diverge from both.
- Did not move the root CHANGELOG.md to posthog/CHANGELOG.md: Sampo writes a
  package's changelog in its manifest directory, and the posthog package's
  manifest is the repo root, so root CHANGELOG.md *is* the posthog changelog.
  The posthog-ruby layout works because its gem lives in a posthog-ruby/ subdir;
  replicating it here would require relocating the entire posthog package.

Generated-By: PostHog Code
Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
…age repos

Per @marandaneto's review and matching posthog-ruby's multi-package convention.

posthog-ruby tags every package with its name prefix once it went multi-package
(`posthog-ruby-v3.15.1`, `posthog-rails-v3.15.0`) and carries no `short_tags` in
its Sampo config. This mirrors that:

- release.yml: posthog package now tags as `posthog-v{version}` (was `v{version}`),
  so all published packages are consistently name-prefixed
  (`posthog-v…`, `openfeature-provider-posthog-v…`). posthoganalytics stays
  untagged (it is a same-version mirror of posthog).
- .sampo/config.toml: drop `short_tags = "posthog"`. Tags are created manually in
  release.yml, so this was inert, but it implied `v{version}` for posthog and was
  inconsistent with the new prefix; removing it aligns Sampo's default tag format
  with the workflow and with posthog-ruby.

Generated-By: PostHog Code
Task-Id: 392fb0da-49bb-4c96-96c7-1b39b0348d32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Approved

Development

Successfully merging this pull request may close these issues.

3 participants