Skip to content

feat: add OpenFeature provider - #214

Merged
marandaneto merged 6 commits into
mainfrom
posthog/openfeature-provider
Sep 25, 2026
Merged

marandaneto merged 6 commits into
mainfrom
posthog/openfeature-provider

Conversation

@seanpem

@seanpem seanpem commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

posthog-js has OpenFeature providers (@posthog/openfeature-node-provider and @posthog/openfeature-web-provider). posthog-elixir has none. This PR adds PostHog.OpenFeature.Provider for the Elixir OpenFeature SDK. Its behavior is as close as possible to the node provider.

  • open_feature is an optional dependency. The provider module compiles only when open_feature is available, so users who do not use OpenFeature see no change.
  • The provider uses PostHog.FeatureFlags.evaluate_flags/2 with flag_keys: [key]. It does not use the deprecated single-flag functions.

Behavior that is the same as the node provider

  • targeting_key becomes the PostHog distinct_id. The default_distinct_id option is the fallback. If neither is there, the result is :targeting_key_missing.
  • The groups and group_properties keys go to groups. All other context keys go to person properties, with no change to their types. Empty maps are not sent.
  • The value rules for each type (boolean, string, number, map) follow mapping.ts, including hexadecimal numeric variants and JSON object or array payloads. A disabled flag that still carries a variant or payload returns that value. Reasons are :targeting_match for enabled flags and :default for off results, except an explicit PostHog flag_disabled reason code produces :disabled. An enabled flag without a usable value is :type_mismatch. Unknown flags are :flag_not_found.
  • send_feature_flag_events is true by default.

Differences

Topic Elixir Node
Context keys snake_case (targeting_key, group_properties). Atom and string keys both work. targetingKey, groupProperties
PostHog instance supervisor_name option for named instances The client is passed in
:type_mismatch, :targeting_key_missing The Elixir SDK has no error tuple for these codes. They are returned as resolution details with reason: :error and the error code, so after hooks run, not error hooks. Thrown errors
Request failure :general FLAG_NOT_FOUND
initialize No preload, because the SDK already polls flag definitions Calls reloadFeatureFlags()
JSON payloads Objects and arrays are accepted. The upstream Elixir OpenFeature SDK requires a map default (%{}), even for arrays; get_map_value/4 can return a list. Objects and arrays are accepted
Numeric parsing Integers, decimals, .5, 1., exponents such as 1e3, and unsigned hexadecimal integers such as 0x10. Number(variant), which also accepts hex
Resolution reason :targeting_match for enabled flags; :disabled for off results with the explicit flag_disabled reason code, otherwise :default. flag_metadata["posthog_reason"] preserves the PostHog reason description, falling back to its code, or a string reason from local evaluation. Metadata is also preserved on type mismatches. TARGETING_MATCH or DEFAULT; no PostHog-specific reason metadata

There is no sdk-specs capability for OpenFeature.

💚 How did you test it?

Original provider validation in Docker with Elixir 1.18.3 / OTP 27 (the CI versions), before the follow-up in #216:

  • mix format --check-formatted, mix credo --strict, and MIX_ENV=test mix compile --warnings-as-errors pass.
  • mix test: 581 tests, 0 failures. This includes 42 new provider tests. They cover each flag type with the flag on and off, the error cases, the distinct ID and context mapping, $feature_flag_called events, and use through the real OpenFeature client.
  • In a copy of the repo without open_feature, mix compile --warnings-as-errors passes, and no OpenFeature module is compiled.

The follow-up in #216 adds regression coverage for hexadecimal variants, JSON arrays (including through the real OpenFeature client), explicit disabled reasons, and PostHog reason metadata, including on type mismatches.

The changeset cleanup does not change runtime code; git diff --cached --check passes. Tests were not rerun for this cleanup.

I did not try the provider against a real PostHog project.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. (The posthog.com OpenFeature docs need an Elixir section. This is a follow-up.)
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written with Claude Code in PostHog Desktop, with the posthog-js OpenFeature providers and the Elixir OpenFeature SDK source as the reference.
  • The maintainer selected two options: a module in :posthog with an optional dependency (not a separate Hex package), and snake_case context keys.

Created with PostHog Desktop

🤖 Generated with Claude Code

Add PostHog.OpenFeature.Provider for the Elixir OpenFeature SDK, with
open_feature as an optional dependency. The provider mirrors the
posthog-node OpenFeature provider: targeting_key maps to distinct_id,
groups and group_properties map to groups, and other context keys map
to person properties.

Generated-By: PostHog Desktop
Task-Id: 45365cd7-19ef-46e1-8d2a-46d2f484d229
@seanpem seanpem self-assigned this Sep 23, 2026
@greptile-apps

greptile-apps Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Retrigger

The PR appears safe to merge; the prior disabled-flag resolution defect is fully fixed and no new actionable issue remains.

Reviews (2) · Last reviewed commit: "docs: describe disabled-flag resolution ..."

Comment thread lib/posthog/open_feature.ex
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

posthog-elixir Compliance Report

Date: 2026-09-25T12:52:28.246518+00:00
Duration: 108720ms

⚠️ Some Tests Failed

40/47 tests passed, 7 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 610ms
Format Validation.Event Has Uuid ✅ 611ms
Format Validation.Event Has Lib Properties ✅ 612ms
Format Validation.Distinct Id Is String ✅ 611ms
Format Validation.Token Is Present ✅ 611ms
Format Validation.Custom Properties Preserved ✅ 610ms
Format Validation.Event Has Timestamp ✅ 611ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ❌ 610ms
Retry Behavior.Retries On 503 ✅ 5616ms
Retry Behavior.Does Not Retry On 400 ✅ 2614ms
Retry Behavior.Does Not Retry On 401 ✅ 2612ms
Retry Behavior.Respects Retry After Header ✅ 5613ms
Retry Behavior.Implements Backoff ✅ 15627ms
Retry Behavior.Retries On 500 ✅ 5617ms
Retry Behavior.Retries On 502 ✅ 5616ms
Retry Behavior.Retries On 504 ✅ 5617ms
Retry Behavior.Max Retries Respected ✅ 15623ms
Deduplication.Generates Unique Uuids ✅ 624ms
Deduplication.Preserves Uuid On Retry ✅ 5616ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10622ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5618ms
Deduplication.No Duplicate Events In Batch ✅ 617ms
Deduplication.Different Events Have Different Uuids ✅ 614ms
Compression.Sends Gzip When Enabled ✅ 611ms
Batch Format.Uses Proper Batch Structure ✅ 611ms
Batch Format.Flush With No Events Sends Nothing ✅ 608ms
Batch Format.Multiple Events Batched Together ✅ 617ms
Error Handling.Does Not Retry On 403 ✅ 2613ms
Error Handling.Does Not Retry On 413 ✅ 2613ms
Error Handling.Retries On 408 ✅ 5615ms

Failures

format_validation.non_utc_event_timestamp_is_converted_to_utc

Event 0 field 'timestamp' instant '2026-09-25T12:50:44.133245Z' != expected '2025-01-02T03:04:05Z'

Feature_Flags Tests

⚠️ 11/17 tests passed, 6 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 9ms
Request Payload.Flags Request Uses V2 Query Param ✅ 9ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 8ms
Request Payload.Flags Request Omits Authorization Header ✅ 9ms
Request Payload.Token In Flags Body Matches Init ✅ 8ms
Request Payload.Groups Round Trip ✅ 9ms
Request Payload.Groups Default To Empty Object ❌ 8ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 8ms
Request Payload.Disable Geoip Omitted Defaults To False ❌ 9ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 8ms
Request Lifecycle.No Flags Request On Init Alone ✅ 4ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 611ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 12ms
Request Lifecycle.Mock Response Value Is Returned To Caller ❌ 7ms
Retry Behavior.Retries Flags On 502 ❌ 311ms
Retry Behavior.Retries Flags On 504 ❌ 312ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ❌ 611ms

Failures

request_payload.groups_default_to_empty_object

Field 'groups' not found in /flags request body at path 'groups'. Available keys: ['distinct_id', 'api_key', 'flag_keys_to_evaluate']

request_payload.disable_geoip_omitted_defaults_to_false

Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'api_key', 'flag_keys_to_evaluate']

request_lifecycle.mock_response_value_is_returned_to_caller

Last action result missing field 'value'. Keys: ['error', 'success']

retry_behavior.retries_flags_on_502

Last action result missing field 'value'. Keys: ['error', 'success']

retry_behavior.retries_flags_on_504

Last action result missing field 'value'. Keys: ['error', 'success']

side_effect_events.get_feature_flag_captures_feature_flag_called_event

Expected 1 events with name '$feature_flag_called', got 0

@marandaneto
marandaneto requested a review from a team September 24, 2026 08:48
@posthog-project-board-bot posthog-project-board-bot Bot moved this to In Progress in Feature Flags Sep 24, 2026
…shot

The string, number, and map resolvers now check `enabled: false` before inspecting the variant or payload, so a disabled flag that still carries a remote value resolves to the caller-supplied default. Adds a test for each type.

Regenerates public_api.snapshot to include PostHog.OpenFeature.Provider, which the "Compile with warnings as errors" CI job checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 8167c421-7e51-4669-be55-fc7b39e39d26
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 8167c421-7e51-4669-be55-fc7b39e39d26
@patricio-posthog
patricio-posthog marked this pull request as ready for review September 24, 2026 15:14
@patricio-posthog
patricio-posthog requested a review from a team as a code owner September 24, 2026 15:14
@greptile-apps

greptile-apps Bot commented Sep 24, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

@marandaneto

Copy link
Copy Markdown
Member

Cross-provider comparison

Compared this PR at 08d188891329ba4ab5a895c134dc1f5e2a2663a5 with the Python provider (f5cbdef81536a749191fd91b8ed44b151d0a874c) and Node provider (bb884eba4edd27633dbf1faeb4b326fb1041c63e). Please check these differences and adjust the implementation or document the intentional differences as needed. These are non-blocking parity observations, not a request to copy behavior that conflicts with the Elixir SDK's API.

Area Difference / gap
Disabled flags Elixir deliberately returns defaults for string/number/map reads even when a variant/payload exists. Python and Node preserve those populated values. The PR description's “same rules” claim is therefore not exact.
JSON arrays Python and Node support arrays; Elixir rejects them. This matches the upstream Elixir SDK's map-only API.
Numeric parsing Elixir rejects .5 and 1., which Node and Python's float resolver accept; Node also accepts 0x10.
Reason metadata Python exposes posthog_reason and distinguishes DISABLED; Elixir follows Node's simpler reason mapping.
Error hooks The documented Elixir SDK limitation means type/targeting errors run after rather than error hooks.
Core behavior Identity fallback, context forwarding, event control, missing flags, and normal typed resolution align.

In particular, please confirm the intended disabled-flag behavior and update the differences section so users do not expect exact Node parity.

…ariants

A disabled flag that still carries a variant or payload now resolves to that value with reason `:default`, as the Node and Python providers do. Number resolution accepts `.5`, `-.5`, and `1.`, which JavaScript's Number() and Python's float() accept but Float.parse/1 does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 8167c421-7e51-4669-be55-fc7b39e39d26

Copy link
Copy Markdown
Contributor

Thanks for the comparison. Addressed in 1fe7e5c:

  • Disabled flags now match Node and Python: a disabled flag that still carries a variant or payload returns that value with reason :default. The earlier reorder from the Greptile thread is reverted, and the three tests for that case now assert the populated value.
  • Numeric parsing now accepts .5, -.5, and 1., which Number() and float() accept but Float.parse/1 does not. Hex like 0x10 is still :type_mismatch, since only Node accepts it and only as a side effect of Number().

Documented as intentional differences in the PR description's table rather than changed:

  • JSON arrays: the Elixir OpenFeature SDK only has a map getter, so there is no way to return an array.
  • Reason metadata: Elixir follows Node's :targeting_match / :default mapping. Python's posthog_reason and DISABLED have no Node equivalent either, so matching one would mean diverging from the other.
  • Error hooks: already documented in the module; the Elixir SDK has no error tuple for :type_mismatch or :targeting_key_missing.

The "same rules as mapping.ts" line in the description is reworded to spell out the disabled-flag behavior.

@marandaneto

Copy link
Copy Markdown
Member

Thanks for the comparison. Addressed in 1fe7e5c:

  • Disabled flags now match Node and Python: a disabled flag that still carries a variant or payload returns that value with reason :default. The earlier reorder from the Greptile thread is reverted, and the three tests for that case now assert the populated value.
  • Numeric parsing now accepts .5, -.5, and 1., which Number() and float() accept but Float.parse/1 does not. Hex like 0x10 is still :type_mismatch, since only Node accepts it and only as a side effect of Number().

Documented as intentional differences in the PR description's table rather than changed:

  • JSON arrays: the Elixir OpenFeature SDK only has a map getter, so there is no way to return an array.
  • Reason metadata: Elixir follows Node's :targeting_match / :default mapping. Python's posthog_reason and DISABLED have no Node equivalent either, so matching one would mean diverging from the other.
  • Error hooks: already documented in the module; the Elixir SDK has no error tuple for :type_mismatch or :targeting_key_missing.

The "same rules as mapping.ts" line in the description is reworded to spell out the disabled-flag behavior.

approved to unblock
i opened prs on node, python and elixir to fix the pending mismatches

marandaneto and others added 2 commits September 25, 2026 14:47
* feat: extend OpenFeature numeric, payload, and reason support

* docs: explain upstream OpenFeature array-default limitation

* fix: preserve OpenFeature reason metadata on type mismatches
@marandaneto
marandaneto enabled auto-merge (squash) September 25, 2026 12:50
{:ok, distinct_id} ->
body = flags_body(distinct_id, key, context)

case FeatureFlags.evaluate_flags(provider.supervisor_name, body) do

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low: Evaluation snapshots leak Agent processes

evaluate_flags/2 creates a snapshot whose accessed_pid is a newly linked Agent, but this provider discards the snapshot without stopping that Agent. An attacker who can repeatedly trigger flag checks handled by the same long-lived process can grow its linked process set until BEAM resources are exhausted; this also occurs when send_feature_flag_events is false. Stop the snapshot's Agent in an after block once the result and optional event have been processed, or use a single-flag evaluation path that does not allocate an access tracker.

@veria-ai

veria-ai Bot commented Sep 25, 2026

Copy link
Copy Markdown

PR overview

This pull request adds an OpenFeature provider backed by PostHog, including feature flag evaluation and optional evaluation event handling.

One resource-management issue remains open: each evaluation snapshot can leave behind a linked Agent process. If an attacker can repeatedly trigger flag checks in the same long-lived process, these leaked processes could accumulate and eventually exhaust BEAM resources; no issues have yet been addressed.

Open issues (1)

Fixed/addressed: 0 · PR risk: 4/10

@marandaneto
marandaneto merged commit e4dbef7 into main Sep 25, 2026
29 of 30 checks passed
@marandaneto
marandaneto deleted the posthog/openfeature-provider branch September 25, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants