From b6554b262109e361580fd55fd51ed5d5a3dc243d Mon Sep 17 00:00:00 2001 From: Sean Mallia Date: Wed, 23 Sep 2026 18:01:42 -0400 Subject: [PATCH 1/6] feat: add OpenFeature provider 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 --- .../posthog-openfeature-provider.md | 5 + lib/posthog/open_feature.ex | 250 ++++++++++++++ mix.exs | 1 + mix.lock | 1 + test/posthog/open_feature_test.exs | 313 ++++++++++++++++++ 5 files changed, 570 insertions(+) create mode 100644 .sampo/changesets/posthog-openfeature-provider.md create mode 100644 lib/posthog/open_feature.ex create mode 100644 test/posthog/open_feature_test.exs diff --git a/.sampo/changesets/posthog-openfeature-provider.md b/.sampo/changesets/posthog-openfeature-provider.md new file mode 100644 index 0000000..6988f49 --- /dev/null +++ b/.sampo/changesets/posthog-openfeature-provider.md @@ -0,0 +1,5 @@ +--- +hex/posthog: minor +--- + +Add `PostHog.OpenFeature.Provider`, an OpenFeature provider for the Elixir OpenFeature SDK. Add `{:open_feature, "~> 0.1"}` to your dependencies to use it. diff --git a/lib/posthog/open_feature.ex b/lib/posthog/open_feature.ex new file mode 100644 index 0000000..845f6f1 --- /dev/null +++ b/lib/posthog/open_feature.ex @@ -0,0 +1,250 @@ +if Code.ensure_loaded?(OpenFeature.Provider) do + defmodule PostHog.OpenFeature.Provider do + @moduledoc """ + [OpenFeature](https://openfeature.dev) provider backed by PostHog feature flags. + + Requires the optional `:open_feature` dependency: + + {:open_feature, "~> 0.1"} + + ## Usage + + {:ok, _provider} = OpenFeature.set_provider(%PostHog.OpenFeature.Provider{}) + client = OpenFeature.get_client() + + OpenFeature.Client.get_boolean_value(client, "new-dashboard", false, + context: %{ + "targeting_key" => "user-123", + "plan" => "enterprise", + "groups" => %{"company" => "acme"}, + "group_properties" => %{"company" => %{"size" => 250}} + } + ) + + ## Options + + - `:supervisor_name` - PostHog instance to evaluate flags with. Defaults to + `PostHog`. + - `:default_distinct_id` - distinct ID used when the evaluation context has + no `targeting_key`. When unset, such evaluations fail with + `:targeting_key_missing`. + - `:send_feature_flag_events` - whether evaluations fire + `$feature_flag_called` events. Defaults to `true`. + + ## Evaluation context + + Keys can be strings or atoms. + + - `targeting_key` - used as the PostHog `distinct_id`. + - `groups` - map of group type to group key. + - `group_properties` - map of group type to group properties. + - Every other key is sent as a person property. + + ## Resolution + + - Boolean flags resolve to whether the flag is enabled. + - String flags resolve to the variant key. + - Number flags resolve to the variant key parsed as a number. + - Map flags resolve to the flag's JSON payload. + + Reading a disabled flag without a variant (or payload, for maps) returns the + default value with reason `:default`. Reading an enabled flag whose value + doesn't fit the requested type returns the default value with + `error_code: :type_mismatch`. Unknown flags return `:flag_not_found`. + + The OpenFeature Elixir SDK has no error tuple for `:type_mismatch` or + `:targeting_key_missing`, so these are returned as resolution details with + `reason: :error` and run `after` hooks rather than `error` hooks. + """ + + @behaviour OpenFeature.Provider + + alias OpenFeature.ResolutionDetails + alias PostHog.FeatureFlags + alias PostHog.FeatureFlags.{Evaluations, Result} + + @typedoc "PostHog OpenFeature provider." + @type t :: %__MODULE__{ + name: String.t(), + domain: String.t() | nil, + state: atom(), + hooks: list(), + supervisor_name: PostHog.supervisor_name(), + default_distinct_id: PostHog.distinct_id() | nil, + send_feature_flag_events: boolean() + } + + defstruct name: "PostHogProvider", + domain: nil, + state: :not_ready, + hooks: [], + supervisor_name: PostHog, + default_distinct_id: nil, + send_feature_flag_events: true + + @reserved_keys ["targeting_key", "groups", "group_properties"] + + @impl true + def initialize(%__MODULE__{} = provider, domain, _context) do + {:ok, %{provider | domain: domain, state: :ready}} + end + + @impl true + def shutdown(_provider), do: :ok + + @impl true + def resolve_boolean_value(provider, key, default, context) do + with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do + {:ok, details(result, result.enabled)} + end + end + + @impl true + def resolve_string_value(provider, key, default, context) do + with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do + case result do + %Result{variant: nil, enabled: false} -> + {:ok, default_details(default)} + + %Result{variant: nil} -> + {:ok, type_mismatch(default, "Flag '#{key}' has no string variant.")} + + %Result{variant: variant} -> + {:ok, details(result, variant)} + end + end + end + + @impl true + def resolve_number_value(provider, key, default, context) do + with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do + case result do + %Result{variant: nil, enabled: false} -> + {:ok, default_details(default)} + + %Result{variant: nil} -> + {:ok, type_mismatch(default, "Flag '#{key}' has no numeric variant.")} + + %Result{variant: variant} -> + {:ok, number_details(result, key, variant, default)} + end + end + end + + @impl true + def resolve_map_value(provider, key, default, context) do + with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do + case result do + %Result{payload: payload} when is_map(payload) -> + {:ok, details(result, payload)} + + %Result{enabled: false} -> + {:ok, default_details(default)} + + %Result{} -> + {:ok, type_mismatch(default, "Flag '#{key}' has no object/JSON payload.")} + end + end + end + + defp evaluate(%__MODULE__{} = provider, key, default, context) do + case resolve_distinct_id(provider, context) do + {:ok, distinct_id} -> + body = flags_body(distinct_id, key, context) + + case FeatureFlags.evaluate_flags(provider.supervisor_name, body) do + {:ok, snapshot} -> fetch_result(provider, snapshot, key) + {:error, reason} -> {:error, :unexpected_error, to_exception(reason)} + end + + :error -> + {:ok, + error_details( + default, + :targeting_key_missing, + "No targeting_key in evaluation context and no default_distinct_id configured." + )} + end + end + + defp fetch_result(provider, %Evaluations{flags: flags} = snapshot, key) do + if provider.send_feature_flag_events, do: Evaluations.get_flag(snapshot, key) + + case Map.fetch(flags, key) do + {:ok, result} -> {:ok, result} + :error -> {:error, :flag_not_found} + end + end + + defp resolve_distinct_id(provider, context) do + case get_key(context, :targeting_key) do + targeting_key when targeting_key not in [nil, ""] -> {:ok, targeting_key} + _ when not is_nil(provider.default_distinct_id) -> {:ok, provider.default_distinct_id} + _ -> :error + end + end + + defp flags_body(distinct_id, key, context) do + person_properties = + context + |> Enum.map(fn {k, v} -> {to_string(k), v} end) + |> Enum.reject(fn {k, _v} -> k in @reserved_keys end) + |> Map.new() + + %{distinct_id: distinct_id, flag_keys: [key]} + |> put_non_empty(:person_properties, person_properties) + |> put_non_empty(:groups, get_key(context, :groups)) + |> put_non_empty(:group_properties, get_key(context, :group_properties)) + end + + defp get_key(context, key) do + Map.get(context, Atom.to_string(key), Map.get(context, key)) + end + + defp put_non_empty(body, key, value) when is_map(value) and map_size(value) > 0, + do: Map.put(body, key, value) + + defp put_non_empty(body, _key, _value), do: body + + defp number_details(result, key, variant, default) do + case parse_number(variant) do + {:ok, number} -> details(result, number) + :error -> type_mismatch(default, "Flag '#{key}' variant '#{variant}' is not a number.") + end + end + + defp parse_number(variant) do + trimmed = String.trim(variant) + + case Integer.parse(trimmed) do + {integer, ""} -> + {:ok, integer} + + _ -> + case Float.parse(trimmed) do + {float, ""} -> {:ok, float} + _ -> :error + end + end + end + + defp details(%Result{} = result, value) do + %ResolutionDetails{ + value: value, + variant: result.variant, + reason: if(result.enabled, do: :targeting_match, else: :default) + } + end + + defp default_details(default), do: %ResolutionDetails{value: default, reason: :default} + + defp type_mismatch(default, message), do: error_details(default, :type_mismatch, message) + + defp error_details(default, code, message) do + %ResolutionDetails{value: default, reason: :error, error_code: code, error_message: message} + end + + defp to_exception(%{__exception__: true} = exception), do: exception + defp to_exception(reason), do: %PostHog.Error{message: inspect(reason)} + end +end diff --git a/mix.exs b/mix.exs index a6d346e..46b1dc5 100644 --- a/mix.exs +++ b/mix.exs @@ -69,6 +69,7 @@ defmodule PostHog.MixProject do {:logger_json, "~> 7.0"}, {:nimble_ownership, "~> 1.0"}, {:uuid_v7, "~> 0.6"}, + {:open_feature, "~> 0.1", optional: true}, # Development tools {:ex_doc, "~> 0.37", only: :dev, runtime: false}, {:logger_handler_kit, "~> 0.4", only: :test}, diff --git a/mix.lock b/mix.lock index 1723e27..5c53b87 100644 --- a/mix.lock +++ b/mix.lock @@ -23,6 +23,7 @@ "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "open_feature": {:hex, :open_feature, "0.1.3", "b03ea38818010dd04c3e690059a8baf14a797c9322af06eb40ac3f81c733474e", [:mix], [], "hexpm", "2dafd79e94384328469c655ef2ddc66647c2531a53b57634e012f952b6afcaf9"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_cowboy": {:hex, :plug_cowboy, "2.9.0", "87e21e0d9054ced99c36d128f49e3ea2cd8b745fffb97de50bff99706087af4f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2002bafba4f3a45b55a58e68d70211b153a7ed18d37edb1ceb6e96e7a92c422e"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, diff --git a/test/posthog/open_feature_test.exs b/test/posthog/open_feature_test.exs new file mode 100644 index 0000000..d4d653d --- /dev/null +++ b/test/posthog/open_feature_test.exs @@ -0,0 +1,313 @@ +defmodule PostHog.OpenFeature.ProviderTest do + use PostHog.Case, async: false, group: PostHog + + @moduletag config: [supervisor_name: PostHog] + + import Mox + + alias OpenFeature.ResolutionDetails + alias PostHog.API + alias PostHog.OpenFeature.Provider + + setup :setup_supervisor + setup :verify_on_exit! + + @context %{"targeting_key" => "user-1"} + + defp flag(attrs) do + Map.merge(%{"enabled" => true, "variant" => nil, "metadata" => %{"payload" => nil}}, attrs) + end + + defp expect_flags(flags) do + expect(API.Mock, :request, fn _client, :post, "/flags", _opts -> + {:ok, %{status: 200, body: %{"flags" => flags}}} + end) + end + + defp expect_flag(key, attrs), do: expect_flags(%{key => flag(Map.put(attrs, "key", key))}) + + defp provider(opts \\ []) do + {:ok, provider} = Provider.initialize(struct(Provider, opts), "default", %{}) + provider + end + + describe "initialize/3" do + test "marks the provider ready and keeps the domain" do + assert {:ok, %Provider{state: :ready, domain: "my-domain", name: "PostHogProvider"}} = + Provider.initialize(%Provider{}, "my-domain", %{}) + end + end + + describe "resolve_boolean_value/4" do + test "resolves an enabled flag" do + expect_flag("flag", %{"enabled" => true}) + + assert {:ok, %ResolutionDetails{value: true, reason: :targeting_match, variant: nil}} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + + test "resolves a disabled flag" do + expect_flag("flag", %{"enabled" => false}) + + assert {:ok, %ResolutionDetails{value: false, reason: :default}} = + Provider.resolve_boolean_value(provider(), "flag", true, @context) + end + + test "includes the variant for multivariate flags" do + expect_flag("flag", %{"variant" => "test"}) + + assert {:ok, %ResolutionDetails{value: true, variant: "test"}} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + end + + describe "resolve_string_value/4" do + test "resolves the variant" do + expect_flag("flag", %{"variant" => "test"}) + + assert {:ok, %ResolutionDetails{value: "test", variant: "test", reason: :targeting_match}} = + Provider.resolve_string_value(provider(), "flag", "control", @context) + end + + test "returns the default for a disabled flag without a variant" do + expect_flag("flag", %{"enabled" => false}) + + assert {:ok, %ResolutionDetails{value: "control", reason: :default, error_code: nil}} = + Provider.resolve_string_value(provider(), "flag", "control", @context) + end + + test "returns type_mismatch for an enabled flag without a variant" do + expect_flag("flag", %{"enabled" => true}) + + assert {:ok, + %ResolutionDetails{value: "control", reason: :error, error_code: :type_mismatch}} = + Provider.resolve_string_value(provider(), "flag", "control", @context) + end + end + + describe "resolve_number_value/4" do + for {variant, expected} <- [{"42", 42}, {"3.5", 3.5}, {" 7 ", 7}, {"-1", -1}] do + test "parses variant #{inspect(variant)}" do + expect_flag("flag", %{"variant" => unquote(variant)}) + + assert {:ok, %ResolutionDetails{value: value, reason: :targeting_match}} = + Provider.resolve_number_value(provider(), "flag", 0, @context) + + assert value === unquote(expected) + end + end + + for variant <- ["abc", "", " ", "12abc"] do + test "returns type_mismatch for variant #{inspect(variant)}" do + expect_flag("flag", %{"variant" => unquote(variant)}) + + assert {:ok, %ResolutionDetails{value: 0, reason: :error, error_code: :type_mismatch}} = + Provider.resolve_number_value(provider(), "flag", 0, @context) + end + end + + test "returns the default for a disabled flag without a variant" do + expect_flag("flag", %{"enabled" => false}) + + assert {:ok, %ResolutionDetails{value: 5, reason: :default}} = + Provider.resolve_number_value(provider(), "flag", 5, @context) + end + + test "returns type_mismatch for an enabled flag without a variant" do + expect_flag("flag", %{"enabled" => true}) + + assert {:ok, %ResolutionDetails{value: 5, error_code: :type_mismatch}} = + Provider.resolve_number_value(provider(), "flag", 5, @context) + end + end + + describe "resolve_map_value/4" do + test "resolves the payload" do + expect_flag("flag", %{"variant" => "test", "metadata" => %{"payload" => ~s({"a": 1})}}) + + assert {:ok, %ResolutionDetails{value: %{"a" => 1}, variant: "test"}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + + test "returns the default for a disabled flag without a payload" do + expect_flag("flag", %{"enabled" => false}) + + assert {:ok, %ResolutionDetails{value: %{"d" => 1}, reason: :default}} = + Provider.resolve_map_value(provider(), "flag", %{"d" => 1}, @context) + end + + test "returns type_mismatch for an enabled flag without a payload" do + expect_flag("flag", %{"enabled" => true}) + + assert {:ok, %ResolutionDetails{value: %{}, error_code: :type_mismatch}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + + test "returns type_mismatch for a non-object payload" do + expect_flag("flag", %{"metadata" => %{"payload" => "[1, 2]"}}) + + assert {:ok, %ResolutionDetails{value: %{}, error_code: :type_mismatch}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + end + + describe "errors" do + test "returns flag_not_found when the flag is not in the response" do + expect_flags(%{}) + + assert {:error, :flag_not_found} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + + test "returns unexpected_error when the request fails" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:error, :transport_error} + end) + + assert {:error, :unexpected_error, %PostHog.Error{}} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + end + + describe "distinct ID" do + for context <- [%{"targeting_key" => "user-1"}, %{targeting_key: "user-1"}] do + test "uses targeting_key from #{inspect(context)}" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json].distinct_id == "user-1" + {:ok, %{status: 200, body: %{"flags" => %{"flag" => flag(%{})}}}} + end) + + assert {:ok, _} = + Provider.resolve_boolean_value( + provider(), + "flag", + false, + unquote(Macro.escape(context)) + ) + end + end + + test "falls back to default_distinct_id" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json].distinct_id == "fallback" + {:ok, %{status: 200, body: %{"flags" => %{"flag" => flag(%{})}}}} + end) + + assert {:ok, %ResolutionDetails{value: true}} = + Provider.resolve_boolean_value( + provider(default_distinct_id: "fallback"), + "flag", + false, + %{} + ) + end + + test "returns targeting_key_missing without targeting_key or default_distinct_id" do + expect(API.Mock, :request, 0, fn _client, _method, _url, _opts -> :unreachable end) + + assert {:ok, + %ResolutionDetails{ + value: false, + reason: :error, + error_code: :targeting_key_missing + }} = Provider.resolve_boolean_value(provider(), "flag", false, %{}) + end + end + + describe "context mapping" do + test "forwards person properties, groups, and group properties without coercion" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{ + distinct_id: "user-1", + flag_keys_to_evaluate: ["flag"], + person_properties: %{"plan" => "pro", "age" => 30, "beta" => true}, + groups: %{"company" => "acme"}, + group_properties: %{"company" => %{"size" => 250}} + } + + {:ok, %{status: 200, body: %{"flags" => %{"flag" => flag(%{})}}}} + end) + + context = %{ + "targeting_key" => "user-1", + "plan" => "pro", + :age => 30, + "beta" => true, + "groups" => %{"company" => "acme"}, + :group_properties => %{"company" => %{"size" => 250}} + } + + assert {:ok, _} = Provider.resolve_boolean_value(provider(), "flag", false, context) + end + + test "omits empty person properties, groups, and group properties" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{distinct_id: "user-1", flag_keys_to_evaluate: ["flag"]} + {:ok, %{status: 200, body: %{"flags" => %{"flag" => flag(%{})}}}} + end) + + context = %{"targeting_key" => "user-1", "groups" => %{}, "group_properties" => %{}} + assert {:ok, _} = Provider.resolve_boolean_value(provider(), "flag", false, context) + end + end + + describe "send_feature_flag_events" do + test "fires $feature_flag_called by default" do + expect_flag("flag", %{}) + + assert {:ok, _} = Provider.resolve_boolean_value(provider(), "flag", false, @context) + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "user-1", + properties: %{"$feature_flag": "flag", "$feature_flag_response": true} + } + ] = all_captured() + end + + test "does not fire events when disabled" do + expect_flag("flag", %{}) + + assert {:ok, _} = + Provider.resolve_boolean_value( + provider(send_feature_flag_events: false), + "flag", + false, + @context + ) + + assert all_captured() == [] + end + end + + describe "with the OpenFeature client" do + setup do + OpenFeature.clear_providers() + {:ok, _} = OpenFeature.set_provider(%Provider{}) + on_exit(&OpenFeature.clear_providers/0) + %{client: OpenFeature.get_client()} + end + + test "resolves values", %{client: client} do + expect_flag("flag", %{"variant" => "test"}) + + assert OpenFeature.Client.get_string_value(client, "flag", "control", context: @context) == + "test" + end + + test "surfaces type_mismatch", %{client: client} do + expect_flag("flag", %{"enabled" => true}) + + assert %{value: "control", reason: :error, error_code: :type_mismatch} = + OpenFeature.Client.get_string_details(client, "flag", "control", context: @context) + end + + test "surfaces flag_not_found", %{client: client} do + expect_flags(%{}) + + assert %{value: false, reason: :error, error_code: :flag_not_found} = + OpenFeature.Client.get_boolean_details(client, "flag", false, context: @context) + end + end +end From bf7b38dd30bee008bf051d95f2c6873277b20e64 Mon Sep 17 00:00:00 2001 From: Patricio Date: Thu, 24 Sep 2026 11:58:56 -0300 Subject: [PATCH 2/6] fix: return the default for disabled flags and update public API snapshot 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 Generated-By: PostHog Desktop Task-Id: 8167c421-7e51-4669-be55-fc7b39e39d26 --- lib/posthog/open_feature.ex | 10 +++++----- public_api.snapshot | 7 +++++++ test/posthog/open_feature_test.exs | 21 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/posthog/open_feature.ex b/lib/posthog/open_feature.ex index 845f6f1..f746e39 100644 --- a/lib/posthog/open_feature.ex +++ b/lib/posthog/open_feature.ex @@ -103,7 +103,7 @@ if Code.ensure_loaded?(OpenFeature.Provider) do def resolve_string_value(provider, key, default, context) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do - %Result{variant: nil, enabled: false} -> + %Result{enabled: false} -> {:ok, default_details(default)} %Result{variant: nil} -> @@ -119,7 +119,7 @@ if Code.ensure_loaded?(OpenFeature.Provider) do def resolve_number_value(provider, key, default, context) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do - %Result{variant: nil, enabled: false} -> + %Result{enabled: false} -> {:ok, default_details(default)} %Result{variant: nil} -> @@ -135,12 +135,12 @@ if Code.ensure_loaded?(OpenFeature.Provider) do def resolve_map_value(provider, key, default, context) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do - %Result{payload: payload} when is_map(payload) -> - {:ok, details(result, payload)} - %Result{enabled: false} -> {:ok, default_details(default)} + %Result{payload: payload} when is_map(payload) -> + {:ok, details(result, payload)} + %Result{} -> {:ok, type_mismatch(default, "Flag '#{key}' has no object/JSON payload.")} end diff --git a/public_api.snapshot b/public_api.snapshot index 4243197..aff3f9e 100644 --- a/public_api.snapshot +++ b/public_api.snapshot @@ -210,6 +210,13 @@ PostHog.LLMAnalytics macros: (none) +PostHog.OpenFeature.Provider + module_doc: documented + functions: + (none) + macros: + (none) + PostHog.Supervisor module_doc: documented functions: diff --git a/test/posthog/open_feature_test.exs b/test/posthog/open_feature_test.exs index d4d653d..87e6080 100644 --- a/test/posthog/open_feature_test.exs +++ b/test/posthog/open_feature_test.exs @@ -76,6 +76,13 @@ defmodule PostHog.OpenFeature.ProviderTest do Provider.resolve_string_value(provider(), "flag", "control", @context) end + test "returns the default for a disabled flag that still has a variant" do + expect_flag("flag", %{"enabled" => false, "variant" => "test"}) + + assert {:ok, %ResolutionDetails{value: "control", reason: :default, variant: nil}} = + Provider.resolve_string_value(provider(), "flag", "control", @context) + end + test "returns type_mismatch for an enabled flag without a variant" do expect_flag("flag", %{"enabled" => true}) @@ -113,6 +120,13 @@ defmodule PostHog.OpenFeature.ProviderTest do Provider.resolve_number_value(provider(), "flag", 5, @context) end + test "returns the default for a disabled flag that still has a variant" do + expect_flag("flag", %{"enabled" => false, "variant" => "42"}) + + assert {:ok, %ResolutionDetails{value: 5, reason: :default, variant: nil}} = + Provider.resolve_number_value(provider(), "flag", 5, @context) + end + test "returns type_mismatch for an enabled flag without a variant" do expect_flag("flag", %{"enabled" => true}) @@ -136,6 +150,13 @@ defmodule PostHog.OpenFeature.ProviderTest do Provider.resolve_map_value(provider(), "flag", %{"d" => 1}, @context) end + test "returns the default for a disabled flag that still has a payload" do + expect_flag("flag", %{"enabled" => false, "metadata" => %{"payload" => ~s({"a": 1})}}) + + assert {:ok, %ResolutionDetails{value: %{"d" => 1}, reason: :default, variant: nil}} = + Provider.resolve_map_value(provider(), "flag", %{"d" => 1}, @context) + end + test "returns type_mismatch for an enabled flag without a payload" do expect_flag("flag", %{"enabled" => true}) From 08d188891329ba4ab5a895c134dc1f5e2a2663a5 Mon Sep 17 00:00:00 2001 From: Patricio Date: Thu, 24 Sep 2026 12:05:08 -0300 Subject: [PATCH 3/6] docs: describe disabled-flag resolution after resolver reorder Co-Authored-By: Claude Fable 5.1 Generated-By: PostHog Desktop Task-Id: 8167c421-7e51-4669-be55-fc7b39e39d26 --- lib/posthog/open_feature.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/posthog/open_feature.ex b/lib/posthog/open_feature.ex index f746e39..9348b93 100644 --- a/lib/posthog/open_feature.ex +++ b/lib/posthog/open_feature.ex @@ -47,10 +47,10 @@ if Code.ensure_loaded?(OpenFeature.Provider) do - Number flags resolve to the variant key parsed as a number. - Map flags resolve to the flag's JSON payload. - Reading a disabled flag without a variant (or payload, for maps) returns the - default value with reason `:default`. Reading an enabled flag whose value - doesn't fit the requested type returns the default value with - `error_code: :type_mismatch`. Unknown flags return `:flag_not_found`. + Reading a disabled flag returns the default value with reason `:default`. + Reading an enabled flag whose value doesn't fit the requested type returns + the default value with `error_code: :type_mismatch`. Unknown flags return + `:flag_not_found`. The OpenFeature Elixir SDK has no error tuple for `:type_mismatch` or `:targeting_key_missing`, so these are returned as resolution details with From 1fe7e5c12ca3f95bb24c20dcd1b1e49c3d8a8f44 Mon Sep 17 00:00:00 2001 From: Patricio Date: Thu, 24 Sep 2026 14:44:58 -0300 Subject: [PATCH 4/6] fix: match Node and Python providers for disabled flags and numeric variants 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 Generated-By: PostHog Desktop Task-Id: 8167c421-7e51-4669-be55-fc7b39e39d26 --- lib/posthog/open_feature.ex | 48 +++++++++++++++++++----------- test/posthog/open_feature_test.exs | 24 ++++++++++----- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/lib/posthog/open_feature.ex b/lib/posthog/open_feature.ex index 9348b93..dd399d9 100644 --- a/lib/posthog/open_feature.ex +++ b/lib/posthog/open_feature.ex @@ -47,10 +47,12 @@ if Code.ensure_loaded?(OpenFeature.Provider) do - Number flags resolve to the variant key parsed as a number. - Map flags resolve to the flag's JSON payload. - Reading a disabled flag returns the default value with reason `:default`. - Reading an enabled flag whose value doesn't fit the requested type returns - the default value with `error_code: :type_mismatch`. Unknown flags return - `:flag_not_found`. + Reading a disabled flag returns the default value with reason `:default`, + unless the flag still carries a variant (or payload, for maps). That value + is then returned with reason `:default`, as in the Node and Python + providers. Reading an enabled flag whose value doesn't fit the requested + type returns the default value with `error_code: :type_mismatch`. Unknown + flags return `:flag_not_found`. The OpenFeature Elixir SDK has no error tuple for `:type_mismatch` or `:targeting_key_missing`, so these are returned as resolution details with @@ -103,7 +105,7 @@ if Code.ensure_loaded?(OpenFeature.Provider) do def resolve_string_value(provider, key, default, context) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do - %Result{enabled: false} -> + %Result{variant: nil, enabled: false} -> {:ok, default_details(default)} %Result{variant: nil} -> @@ -119,7 +121,7 @@ if Code.ensure_loaded?(OpenFeature.Provider) do def resolve_number_value(provider, key, default, context) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do - %Result{enabled: false} -> + %Result{variant: nil, enabled: false} -> {:ok, default_details(default)} %Result{variant: nil} -> @@ -135,12 +137,12 @@ if Code.ensure_loaded?(OpenFeature.Provider) do def resolve_map_value(provider, key, default, context) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do - %Result{enabled: false} -> - {:ok, default_details(default)} - %Result{payload: payload} when is_map(payload) -> {:ok, details(result, payload)} + %Result{enabled: false} -> + {:ok, default_details(default)} + %Result{} -> {:ok, type_mismatch(default, "Flag '#{key}' has no object/JSON payload.")} end @@ -216,15 +218,27 @@ if Code.ensure_loaded?(OpenFeature.Provider) do defp parse_number(variant) do trimmed = String.trim(variant) - case Integer.parse(trimmed) do - {integer, ""} -> - {:ok, integer} + if trimmed =~ ~r/\d/ do + case Integer.parse(trimmed) do + {integer, ""} -> {:ok, integer} + _ -> parse_float(trimmed) + end + else + :error + end + end - _ -> - case Float.parse(trimmed) do - {float, ""} -> {:ok, float} - _ -> :error - end + # Float.parse/1 needs digits on both sides of the decimal point, unlike + # JavaScript's Number() and Python's float(), so `.5` and `1.` are padded. + defp parse_float(string) do + padded = + string + |> String.replace(~r/^([+-]?)\./, "\\g{1}0.") + |> String.replace(~r/\.$/, ".0") + + case Float.parse(padded) do + {float, ""} -> {:ok, float} + _ -> :error end end diff --git a/test/posthog/open_feature_test.exs b/test/posthog/open_feature_test.exs index 87e6080..39c694a 100644 --- a/test/posthog/open_feature_test.exs +++ b/test/posthog/open_feature_test.exs @@ -76,10 +76,10 @@ defmodule PostHog.OpenFeature.ProviderTest do Provider.resolve_string_value(provider(), "flag", "control", @context) end - test "returns the default for a disabled flag that still has a variant" do + test "returns the variant for a disabled flag that still has one" do expect_flag("flag", %{"enabled" => false, "variant" => "test"}) - assert {:ok, %ResolutionDetails{value: "control", reason: :default, variant: nil}} = + assert {:ok, %ResolutionDetails{value: "test", variant: "test", reason: :default}} = Provider.resolve_string_value(provider(), "flag", "control", @context) end @@ -93,7 +93,15 @@ defmodule PostHog.OpenFeature.ProviderTest do end describe "resolve_number_value/4" do - for {variant, expected} <- [{"42", 42}, {"3.5", 3.5}, {" 7 ", 7}, {"-1", -1}] do + for {variant, expected} <- [ + {"42", 42}, + {"3.5", 3.5}, + {" 7 ", 7}, + {"-1", -1}, + {".5", 0.5}, + {"-.5", -0.5}, + {"1.", 1.0} + ] do test "parses variant #{inspect(variant)}" do expect_flag("flag", %{"variant" => unquote(variant)}) @@ -104,7 +112,7 @@ defmodule PostHog.OpenFeature.ProviderTest do end end - for variant <- ["abc", "", " ", "12abc"] do + for variant <- ["abc", "", " ", "12abc", ".", "-."] do test "returns type_mismatch for variant #{inspect(variant)}" do expect_flag("flag", %{"variant" => unquote(variant)}) @@ -120,10 +128,10 @@ defmodule PostHog.OpenFeature.ProviderTest do Provider.resolve_number_value(provider(), "flag", 5, @context) end - test "returns the default for a disabled flag that still has a variant" do + test "returns the parsed variant for a disabled flag that still has one" do expect_flag("flag", %{"enabled" => false, "variant" => "42"}) - assert {:ok, %ResolutionDetails{value: 5, reason: :default, variant: nil}} = + assert {:ok, %ResolutionDetails{value: 42, variant: "42", reason: :default}} = Provider.resolve_number_value(provider(), "flag", 5, @context) end @@ -150,10 +158,10 @@ defmodule PostHog.OpenFeature.ProviderTest do Provider.resolve_map_value(provider(), "flag", %{"d" => 1}, @context) end - test "returns the default for a disabled flag that still has a payload" do + test "returns the payload for a disabled flag that still has one" do expect_flag("flag", %{"enabled" => false, "metadata" => %{"payload" => ~s({"a": 1})}}) - assert {:ok, %ResolutionDetails{value: %{"d" => 1}, reason: :default, variant: nil}} = + assert {:ok, %ResolutionDetails{value: %{"a" => 1}, reason: :default, variant: nil}} = Provider.resolve_map_value(provider(), "flag", %{"d" => 1}, @context) end From 829dbe2c37e179bee49f1b343bdbf2a95b4ca0b2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:47:59 +0700 Subject: [PATCH 5/6] feat: extend OpenFeature numeric, payload, and reason support (#216) * feat: extend OpenFeature numeric, payload, and reason support * docs: explain upstream OpenFeature array-default limitation * fix: preserve OpenFeature reason metadata on type mismatches --- .sampo/changesets/roguish-knight-louhi.md | 5 + lib/posthog/open_feature.ex | 95 ++++++++---- test/posthog/open_feature_test.exs | 181 +++++++++++++++++++++- 3 files changed, 249 insertions(+), 32 deletions(-) create mode 100644 .sampo/changesets/roguish-knight-louhi.md diff --git a/.sampo/changesets/roguish-knight-louhi.md b/.sampo/changesets/roguish-knight-louhi.md new file mode 100644 index 0000000..8652271 --- /dev/null +++ b/.sampo/changesets/roguish-knight-louhi.md @@ -0,0 +1,5 @@ +--- +hex/posthog: patch +--- + +Support hexadecimal variants, JSON arrays, and reason metadata in the OpenFeature provider. diff --git a/lib/posthog/open_feature.ex b/lib/posthog/open_feature.ex index dd399d9..26c2a8a 100644 --- a/lib/posthog/open_feature.ex +++ b/lib/posthog/open_feature.ex @@ -44,15 +44,23 @@ if Code.ensure_loaded?(OpenFeature.Provider) do - Boolean flags resolve to whether the flag is enabled. - String flags resolve to the variant key. - - Number flags resolve to the variant key parsed as a number. - - Map flags resolve to the flag's JSON payload. - - Reading a disabled flag returns the default value with reason `:default`, - unless the flag still carries a variant (or payload, for maps). That value - is then returned with reason `:default`, as in the Node and Python - providers. Reading an enabled flag whose value doesn't fit the requested - type returns the default value with `error_code: :type_mismatch`. Unknown - flags return `:flag_not_found`. + - Number flags resolve to the variant key parsed as a number, including + unsigned hexadecimal integers such as `0x10`. + - Map flags resolve to the flag's JSON object or array payload. The upstream + OpenFeature SDK requires a map default (`%{}`), even for array payloads; + `get_map_value/4` can return a list when the payload is an array. + + For string, number, and map reads, a disabled flag returns the default value, + unless it still carries a variant (or object/array payload, for maps). That + value is then returned, as in the Node and Python providers. Boolean reads + return the flag's enabled state, including `false` regardless of the default. + Reading an enabled flag whose value doesn't fit the requested type returns + the default value with `error_code: :type_mismatch`. Unknown flags return + `:flag_not_found`. + + Resolution details include `flag_metadata["posthog_reason"]` when PostHog + supplies an evaluation reason. Off results use `:disabled` when the server + explicitly reports the `flag_disabled` reason code, otherwise `:default`. The OpenFeature Elixir SDK has no error tuple for `:type_mismatch` or `:targeting_key_missing`, so these are returned as resolution details with @@ -106,10 +114,10 @@ if Code.ensure_loaded?(OpenFeature.Provider) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do %Result{variant: nil, enabled: false} -> - {:ok, default_details(default)} + {:ok, default_details(result, default)} %Result{variant: nil} -> - {:ok, type_mismatch(default, "Flag '#{key}' has no string variant.")} + {:ok, type_mismatch(result, default, "Flag '#{key}' has no string variant.")} %Result{variant: variant} -> {:ok, details(result, variant)} @@ -122,10 +130,10 @@ if Code.ensure_loaded?(OpenFeature.Provider) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do %Result{variant: nil, enabled: false} -> - {:ok, default_details(default)} + {:ok, default_details(result, default)} %Result{variant: nil} -> - {:ok, type_mismatch(default, "Flag '#{key}' has no numeric variant.")} + {:ok, type_mismatch(result, default, "Flag '#{key}' has no numeric variant.")} %Result{variant: variant} -> {:ok, number_details(result, key, variant, default)} @@ -133,18 +141,21 @@ if Code.ensure_loaded?(OpenFeature.Provider) do end end + # open-feature/elixir-sdk 0.1.3 guards get_map_value/get_map_details defaults + # with is_map/1 before calling the provider, but does not restrict returned + # values. Array payloads work with a map default; list defaults need an upstream fix. @impl true def resolve_map_value(provider, key, default, context) do with {:ok, %Result{} = result} <- evaluate(provider, key, default, context) do case result do - %Result{payload: payload} when is_map(payload) -> + %Result{payload: payload} when is_map(payload) or is_list(payload) -> {:ok, details(result, payload)} %Result{enabled: false} -> - {:ok, default_details(default)} + {:ok, default_details(result, default)} %Result{} -> - {:ok, type_mismatch(default, "Flag '#{key}' has no object/JSON payload.")} + {:ok, type_mismatch(result, default, "Flag '#{key}' has no object/JSON payload.")} end end end @@ -210,21 +221,30 @@ if Code.ensure_loaded?(OpenFeature.Provider) do defp number_details(result, key, variant, default) do case parse_number(variant) do - {:ok, number} -> details(result, number) - :error -> type_mismatch(default, "Flag '#{key}' variant '#{variant}' is not a number.") + {:ok, number} -> + details(result, number) + + :error -> + type_mismatch(result, default, "Flag '#{key}' variant '#{variant}' is not a number.") end end defp parse_number(variant) do trimmed = String.trim(variant) - if trimmed =~ ~r/\d/ do - case Integer.parse(trimmed) do - {integer, ""} -> {:ok, integer} - _ -> parse_float(trimmed) - end - else - :error + cond do + trimmed =~ ~r/\A0[xX][0-9a-fA-F]+\z/ -> + <<_prefix::binary-size(2), digits::binary>> = trimmed + {:ok, String.to_integer(digits, 16)} + + trimmed =~ ~r/\d/ -> + case Integer.parse(trimmed) do + {integer, ""} -> {:ok, integer} + _ -> parse_float(trimmed) + end + + true -> + :error end end @@ -246,13 +266,32 @@ if Code.ensure_loaded?(OpenFeature.Provider) do %ResolutionDetails{ value: value, variant: result.variant, - reason: if(result.enabled, do: :targeting_match, else: :default) + reason: resolution_reason(result), + flag_metadata: reason_metadata(result.reason) } end - defp default_details(default), do: %ResolutionDetails{value: default, reason: :default} + defp resolution_reason(%Result{enabled: true}), do: :targeting_match + defp resolution_reason(%Result{reason: %{"code" => "flag_disabled"}}), do: :disabled + defp resolution_reason(_result), do: :default - defp type_mismatch(default, message), do: error_details(default, :type_mismatch, message) + defp reason_metadata(%{"description" => description}) when is_binary(description), + do: %{"posthog_reason" => description} + + defp reason_metadata(%{"code" => code}) when is_binary(code), + do: %{"posthog_reason" => code} + + defp reason_metadata(reason) when is_binary(reason), do: %{"posthog_reason" => reason} + defp reason_metadata(_reason), do: %{} + + defp default_details(result, default), do: %{details(result, default) | variant: nil} + + defp type_mismatch(result, default, message) do + %{ + error_details(default, :type_mismatch, message) + | flag_metadata: reason_metadata(result.reason) + } + end defp error_details(default, code, message) do %ResolutionDetails{value: default, reason: :error, error_code: code, error_message: message} diff --git a/test/posthog/open_feature_test.exs b/test/posthog/open_feature_test.exs index 39c694a..0e79809 100644 --- a/test/posthog/open_feature_test.exs +++ b/test/posthog/open_feature_test.exs @@ -100,7 +100,11 @@ defmodule PostHog.OpenFeature.ProviderTest do {"-1", -1}, {".5", 0.5}, {"-.5", -0.5}, - {"1.", 1.0} + {"1.", 1.0}, + {"0x10", 16}, + {"0Xff", 255}, + {" 0xA0 ", 160}, + {"0x0", 0} ] do test "parses variant #{inspect(variant)}" do expect_flag("flag", %{"variant" => unquote(variant)}) @@ -112,7 +116,20 @@ defmodule PostHog.OpenFeature.ProviderTest do end end - for variant <- ["abc", "", " ", "12abc", ".", "-."] do + for variant <- [ + "abc", + "", + " ", + "12abc", + ".", + "-.", + "0x", + "0xgg", + "0x1.5", + "0x_10", + "-0x10", + "+0x10" + ] do test "returns type_mismatch for variant #{inspect(variant)}" do expect_flag("flag", %{"variant" => unquote(variant)}) @@ -172,14 +189,158 @@ defmodule PostHog.OpenFeature.ProviderTest do Provider.resolve_map_value(provider(), "flag", %{}, @context) end - test "returns type_mismatch for a non-object payload" do - expect_flag("flag", %{"metadata" => %{"payload" => "[1, 2]"}}) + for payload <- [[], [1, 2], [%{"nested" => [true, nil]}]] do + test "resolves array payload #{inspect(payload)}" do + payload = unquote(Macro.escape(payload)) + expect_flag("flag", %{"metadata" => %{"payload" => Jason.encode!(payload)}}) + + assert {:ok, %ResolutionDetails{value: ^payload, reason: :targeting_match}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + end + + test "preserves array payloads on disabled flags" do + expect_flag("flag", %{"enabled" => false, "metadata" => %{"payload" => "[]"}}) + + assert {:ok, %ResolutionDetails{value: [], reason: :default}} = + Provider.resolve_map_value(provider(), "flag", %{}, @context) + end + + test "returns type_mismatch for a scalar payload" do + expect_flag("flag", %{"metadata" => %{"payload" => "123"}}) assert {:ok, %ResolutionDetails{value: %{}, error_code: :type_mismatch}} = Provider.resolve_map_value(provider(), "flag", %{}, @context) end end + describe "reason metadata" do + for {resolver, default, attrs} <- [ + {:resolve_boolean_value, false, %{}}, + {:resolve_string_value, "fallback", %{"variant" => "test"}}, + {:resolve_number_value, 0, %{"variant" => "42"}}, + {:resolve_map_value, %{}, %{"metadata" => %{"payload" => "{}"}}} + ] do + test "preserves PostHog reason for #{resolver}" do + expect_flag( + "flag", + Map.put(unquote(Macro.escape(attrs)), "reason", %{ + "code" => "condition_match", + "description" => "Matched condition set 1" + }) + ) + + assert {:ok, + %ResolutionDetails{ + reason: :targeting_match, + flag_metadata: %{"posthog_reason" => "Matched condition set 1"} + }} = + apply(Provider, unquote(resolver), [ + provider(), + "flag", + unquote(Macro.escape(default)), + @context + ]) + end + + test "reports explicit disabled reason for #{resolver}" do + expect_flag("flag", %{ + "enabled" => false, + "reason" => %{ + "code" => "flag_disabled", + "description" => "Flag switched off" + } + }) + + assert {:ok, + %ResolutionDetails{ + reason: :disabled, + flag_metadata: %{"posthog_reason" => "Flag switched off"} + }} = + apply(Provider, unquote(resolver), [ + provider(), + "flag", + unquote(Macro.escape(default)), + @context + ]) + end + end + + for {label, resolver, default, attrs} <- [ + {"missing string variant", :resolve_string_value, "fallback", %{}}, + {"missing numeric variant", :resolve_number_value, 0, %{}}, + {"invalid numeric variant", :resolve_number_value, 0, %{"variant" => "abc"}}, + {"missing payload", :resolve_map_value, %{}, %{}}, + {"scalar payload", :resolve_map_value, %{}, %{"metadata" => %{"payload" => "123"}}} + ] do + test "preserves reason metadata on type mismatch for #{label}" do + expect_flag( + "flag", + Map.put(unquote(Macro.escape(attrs)), "reason", %{ + "code" => "condition_match", + "description" => "Matched condition set 1" + }) + ) + + assert {:ok, + %ResolutionDetails{ + value: unquote(Macro.escape(default)), + reason: :error, + error_code: :type_mismatch, + flag_metadata: %{"posthog_reason" => "Matched condition set 1"} + }} = + apply(Provider, unquote(resolver), [ + provider(), + "flag", + unquote(Macro.escape(default)), + @context + ]) + end + end + + test "does not infer disabled from the description" do + expect_flag("flag", %{ + "enabled" => false, + "reason" => %{ + "code" => "no_condition_match", + "description" => "User property disabled is true" + } + }) + + assert {:ok, %ResolutionDetails{reason: :default}} = + Provider.resolve_boolean_value(provider(), "flag", true, @context) + end + + test "falls back to the reason code for metadata" do + expect_flag("flag", %{"enabled" => false, "reason" => %{"code" => "flag_disabled"}}) + + assert {:ok, + %ResolutionDetails{ + reason: :disabled, + flag_metadata: %{"posthog_reason" => "flag_disabled"} + }} = + Provider.resolve_boolean_value(provider(), "flag", true, @context) + end + + test "preserves string reasons used by local evaluation" do + expect_flag("flag", %{"reason" => "Evaluated locally"}) + + assert {:ok, + %ResolutionDetails{ + reason: :targeting_match, + flag_metadata: %{"posthog_reason" => "Evaluated locally"} + }} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + + test "omits metadata when no reason is available" do + expect_flag("flag", %{}) + + assert {:ok, %ResolutionDetails{flag_metadata: %{}}} = + Provider.resolve_boolean_value(provider(), "flag", false, @context) + end + end + describe "errors" do test "returns flag_not_found when the flag is not in the response" do expect_flags(%{}) @@ -325,6 +486,18 @@ defmodule PostHog.OpenFeature.ProviderTest do "test" end + test "resolves array payloads with a map default", %{client: client} do + expect_flag("flag", %{"metadata" => %{"payload" => "[1, 2]"}}) + assert OpenFeature.Client.get_map_value(client, "flag", %{}, context: @context) == [1, 2] + end + + test "surfaces disabled reason and metadata", %{client: client} do + expect_flag("flag", %{"enabled" => false, "reason" => %{"code" => "flag_disabled"}}) + + assert %{reason: :disabled, flag_metadata: %{"posthog_reason" => "flag_disabled"}} = + OpenFeature.Client.get_boolean_details(client, "flag", true, context: @context) + end + test "surfaces type_mismatch", %{client: client} do expect_flag("flag", %{"enabled" => true}) From f8a2487b08f7a8481d4a00cee7ebf1d5f07b8547 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 25 Sep 2026 14:50:05 +0200 Subject: [PATCH 6/6] chore: remove redundant OpenFeature follow-up changeset --- .sampo/changesets/roguish-knight-louhi.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .sampo/changesets/roguish-knight-louhi.md diff --git a/.sampo/changesets/roguish-knight-louhi.md b/.sampo/changesets/roguish-knight-louhi.md deleted file mode 100644 index 8652271..0000000 --- a/.sampo/changesets/roguish-knight-louhi.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -hex/posthog: patch ---- - -Support hexadecimal variants, JSON arrays, and reason metadata in the OpenFeature provider.