From c81ae0d051ef90d5ebe2d53346b4c8a5b4e5faa8 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 13:18:51 -0700 Subject: [PATCH 1/4] feat: add evaluate_flags/2 API for single-call flag evaluation Introduce PostHog.FeatureFlags.evaluate_flags/2 returning an Evaluations snapshot that powers branching across multiple flags and event enrichment from a single /flags request. Pairs with PostHog.FeatureFlags.set_in_context/2 for the idiomatic Elixir capture-enrichment flow via the existing per-process context, and with Evaluations.event_properties/1 for explicit one-off attach. %PostHog.FeatureFlags.Result{} gains :id, :version, :reason, :request_id, and :evaluated_at. The existing check/3, check!/3, and get_feature_flag_result/4 paths now attach $feature_flag_id, $feature_flag_version, $feature_flag_reason, and $feature_flag_request_id to $feature_flag_called events when the response provides them - needed for experiment exposure tracking. Generated-By: PostHog Code Task-Id: c6aa804c-618a-4229-b6a3-dc8c9ccff778 --- .sampo/changesets/evaluate-flags-api.md | 22 ++ lib/posthog/feature_flags.ex | 174 ++++++-- lib/posthog/feature_flags/evaluations.ex | 189 +++++++++ lib/posthog/feature_flags/result.ex | 39 +- .../feature_flags/evaluations_test.exs | 371 ++++++++++++++++++ test/posthog/feature_flags_test.exs | 39 ++ 6 files changed, 802 insertions(+), 32 deletions(-) create mode 100644 .sampo/changesets/evaluate-flags-api.md create mode 100644 lib/posthog/feature_flags/evaluations.ex create mode 100644 test/posthog/feature_flags/evaluations_test.exs diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md new file mode 100644 index 0000000..9a051d7 --- /dev/null +++ b/.sampo/changesets/evaluate-flags-api.md @@ -0,0 +1,22 @@ +--- +hex/posthog: minor +--- + +Add `PostHog.FeatureFlags.evaluate_flags/2` and the `PostHog.FeatureFlags.Evaluations` snapshot so a single `/flags` call can power both flag branching and event enrichment for one request: + +```elixir +{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user-123") + +if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard") do + render_new_dashboard() +end + +PostHog.FeatureFlags.set_in_context(snapshot) +PostHog.capture("page_viewed", %{distinct_id: "user-123"}) +``` + +The snapshot exposes `enabled?/2`, `get_flag/2`, `get_flag_payload/2`, `only/2`, `keys/1`, and `event_properties/1`. Pass `flag_keys: [...]` to `evaluate_flags/2` to scope the underlying `/flags` request itself. + +`$feature_flag_called` events fired from `check/3`, `check!/3`, `get_feature_flag_result/4`, and the new snapshot path now attach `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, and `$feature_flag_request_id` when the response provides them. `%PostHog.FeatureFlags.Result{}` gains matching `:id`, `:version`, `:reason`, `:request_id`, and `:evaluated_at` fields. + +Existing `check/3`, `check!/3`, and `get_feature_flag_result/4` continue to work unchanged. diff --git a/lib/posthog/feature_flags.ex b/lib/posthog/feature_flags.ex index 0b2ae86..808df7c 100644 --- a/lib/posthog/feature_flags.ex +++ b/lib/posthog/feature_flags.ex @@ -86,6 +86,108 @@ defmodule PostHog.FeatureFlags do end end + @doc false + def evaluate_flags(distinct_id_or_body) when not is_atom(distinct_id_or_body), + do: evaluate_flags(PostHog, distinct_id_or_body) + + @doc """ + Evaluates feature flags for a `distinct_id` and returns a snapshot. + + Returns `{:ok, %PostHog.FeatureFlags.Evaluations{}}` on success. The snapshot + represents a single `/flags` call and lets you branch on multiple flags and + enrich captured events from the same fetch — see + `PostHog.FeatureFlags.Evaluations` for the full snapshot API and + `set_in_context/2` for the recommended capture-enrichment flow. + + Accepts an optional `distinct_id` or a request body map. If neither is + passed, attempts to read `distinct_id` from the context. + + ## Body options + + When passing a map, the following keys are forwarded to the `/flags` request + body unchanged: + + - `:distinct_id` (required, unless found in context) + - `:groups` + - `:person_properties` + - `:group_properties` + - `:disable_geoip` + + Plus one snapshot-specific option: + + - `:flag_keys` - list of flag keys. Forwarded to the request as + `flag_keys_to_evaluate` so the server returns only those flags. This + scopes the network response, distinct from + `PostHog.FeatureFlags.Evaluations.only/2` which filters an already-fetched + snapshot in memory. + + ## Examples + + Evaluate flags for a `distinct_id`: + + {:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user123") + PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard") + + Evaluate a scoped set of flags with person properties: + + PostHog.FeatureFlags.evaluate_flags(%{ + distinct_id: "user123", + person_properties: %{plan: "enterprise"}, + flag_keys: ["new-dashboard", "beta-checkout"] + }) + + Evaluate through a named PostHog instance: + + PostHog.FeatureFlags.evaluate_flags(MyPostHog, "user123") + """ + @spec evaluate_flags(PostHog.supervisor_name(), PostHog.distinct_id() | map() | nil) :: + {:ok, __MODULE__.Evaluations.t()} | {:error, Exception.t()} + def evaluate_flags(name \\ PostHog, distinct_id_or_body \\ nil) do + with {:ok, %{distinct_id: distinct_id} = body} <- body_for_flags(distinct_id_or_body), + body = translate_flag_keys(body), + {:ok, %{body: response_body}} <- flags(name, body) do + {:ok, __MODULE__.Evaluations.new(name, distinct_id, response_body)} + end + end + + @doc false + def set_in_context(%__MODULE__.Evaluations{} = snapshot), + do: set_in_context(PostHog, snapshot) + + @doc """ + Copies a snapshot's `$feature/` and `$active_feature_flags` properties + into the per-process PostHog context. + + Any subsequent `PostHog.capture/3` from this process automatically attaches + these properties to the captured event — no additional `/flags` request, + with the values guaranteed to match what the snapshot already evaluated. + + This is the idiomatic Elixir way to enrich captured events from an + `evaluate_flags/2` snapshot. For one-off enrichment without touching context, + merge `PostHog.FeatureFlags.Evaluations.event_properties/1` into a capture's + properties directly. + + ## Examples + + {:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user123") + PostHog.FeatureFlags.set_in_context(snapshot) + + # All subsequent captures pick up $feature/* and $active_feature_flags + PostHog.capture("page_viewed", %{distinct_id: "user123"}) + """ + @spec set_in_context(PostHog.supervisor_name(), __MODULE__.Evaluations.t()) :: :ok + def set_in_context(name, %__MODULE__.Evaluations{} = snapshot) when is_atom(name) do + PostHog.set_context(name, __MODULE__.Evaluations.event_properties(snapshot)) + end + + defp translate_flag_keys(%{flag_keys: flag_keys} = body) when is_list(flag_keys) do + body + |> Map.delete(:flag_keys) + |> Map.put(:flag_keys_to_evaluate, flag_keys) + end + + defp translate_flag_keys(body), do: body + @doc false def check(flag_name, distinct_id_or_body) when not is_atom(flag_name), do: check(PostHog, flag_name, distinct_id_or_body) @@ -288,21 +390,10 @@ defmodule PostHog.FeatureFlags do {:ok, %{body: body}} <- flags(name, body) do case body do %{"flags" => %{^flag_name => flag_data}} -> - {enabled, variant} = extract_flag_enabled_and_variant(flag_data) - payload = get_in(flag_data, ["metadata", "payload"]) - - flag_result = %__MODULE__.Result{ - key: flag_name, - enabled: enabled, - variant: variant, - payload: payload - } - - evaluated_at = Map.get(body, "evaluatedAt") + flag_result = build_result(flag_name, flag_data, body) if send_event do - value = __MODULE__.Result.value(flag_result) - log_feature_flag_usage(name, distinct_id, flag_name, {:ok, value}, evaluated_at) + log_feature_flag_usage(name, distinct_id, flag_result) end {:ok, flag_result, body} @@ -315,6 +406,24 @@ defmodule PostHog.FeatureFlags do end end + @doc false + @spec build_result(String.t(), map(), map()) :: __MODULE__.Result.t() + def build_result(flag_name, flag_data, body) do + {enabled, variant} = extract_flag_enabled_and_variant(flag_data) + + %__MODULE__.Result{ + key: flag_name, + enabled: enabled, + variant: variant, + payload: get_in(flag_data, ["metadata", "payload"]), + id: get_in(flag_data, ["metadata", "id"]), + version: get_in(flag_data, ["metadata", "version"]), + reason: Map.get(flag_data, "reason"), + request_id: Map.get(body, "requestId"), + evaluated_at: Map.get(body, "evaluatedAt") + } + end + defp extract_flag_enabled_and_variant(flag_data) do enabled = Map.get(flag_data, "enabled", false) == true variant = Map.get(flag_data, "variant") @@ -368,27 +477,36 @@ defmodule PostHog.FeatureFlags do end end - defp log_feature_flag_usage(name, distinct_id, flag_name, result, evaluated_at) do - with {:ok, variant} <- result do - properties = %{ + @doc false + @spec log_feature_flag_usage( + PostHog.supervisor_name(), + PostHog.distinct_id(), + __MODULE__.Result.t() + ) :: + :ok | {:error, :missing_distinct_id} + def log_feature_flag_usage(name, distinct_id, %__MODULE__.Result{} = result) do + value = __MODULE__.Result.value(result) + + properties = + %{ distinct_id: distinct_id, - "$feature_flag": flag_name, - "$feature_flag_response": variant + "$feature_flag": result.key, + "$feature_flag_response": value } + |> maybe_put(:"$feature_flag_id", result.id) + |> maybe_put(:"$feature_flag_version", result.version) + |> maybe_put(:"$feature_flag_reason", result.reason) + |> maybe_put(:"$feature_flag_request_id", result.request_id) + |> maybe_put(:"$feature_flag_evaluated_at", result.evaluated_at) - properties = - if evaluated_at do - Map.put(properties, :"$feature_flag_evaluated_at", evaluated_at) - else - properties - end - - PostHog.capture(name, "$feature_flag_called", properties) + PostHog.capture(name, "$feature_flag_called", properties) - PostHog.set_context(name, %{"$feature/#{flag_name}" => variant}) - end + PostHog.set_context(name, %{"$feature/#{result.key}" => value}) end + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + defp body_for_flags(distinct_id_or_body) do case distinct_id_or_body do %{distinct_id: _distinct_id} = body -> diff --git a/lib/posthog/feature_flags/evaluations.ex b/lib/posthog/feature_flags/evaluations.ex new file mode 100644 index 0000000..5e2aa0c --- /dev/null +++ b/lib/posthog/feature_flags/evaluations.ex @@ -0,0 +1,189 @@ +defmodule PostHog.FeatureFlags.Evaluations do + @moduledoc """ + Snapshot of feature flag evaluations for a single `distinct_id`. + + An `Evaluations` struct represents the result of a single `/flags` call. It is + built by `PostHog.FeatureFlags.evaluate_flags/2` and lets you branch on + multiple flags and enrich captured events from the same fetch — without + paying the cost of one round-trip per flag. + + The struct itself is a plain immutable map of flag key to + `PostHog.FeatureFlags.Result`. Functions in this module are pure: they query + or filter a snapshot but never mutate it. + + ## Querying + + Use `enabled?/2`, `get_flag/2`, and `get_flag_payload/2` to read individual + flags. `enabled?/2` and `get_flag/2` fire a `$feature_flag_called` event + with full metadata (id, version, reason, request_id) on each call; + `get_flag_payload/2` does not fire an event. + + {:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user-123") + + if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard") do + render_new_dashboard() + end + + ## Enriching captures + + Call `PostHog.FeatureFlags.set_in_context/2` to copy the snapshot's + `$feature/` and `$active_feature_flags` properties into the per-process + context. Any subsequent `PostHog.capture/3` automatically picks them up — no + additional `/flags` request, and the values match what you branched on. + + PostHog.FeatureFlags.set_in_context(snapshot) + PostHog.capture("page_viewed", %{distinct_id: "user-123"}) + + Or merge `event_properties/1` directly into a capture's properties for an + explicit, one-off attach without touching context. + + ## Filtering + + Use `only/2` to narrow a snapshot to a specific list of flag keys before + calling `set_in_context/2` or `event_properties/1`. Unknown keys are dropped. + + narrowed = PostHog.FeatureFlags.Evaluations.only(snapshot, ["new-dashboard"]) + PostHog.FeatureFlags.set_in_context(narrowed) + """ + + alias PostHog.FeatureFlags.Result + + @typedoc """ + Snapshot of evaluated flags for a single `distinct_id`. + + - `:supervisor_name` - PostHog instance the snapshot was produced from; used + when `enabled?/2` and `get_flag/2` fire `$feature_flag_called` events. + - `:distinct_id` - resolved distinct ID the `/flags` request was made for. + - `:flags` - map of flag key to `t:PostHog.FeatureFlags.Result.t/0`. + - `:request_id` - request ID returned by `/flags`. + - `:evaluated_at` - server-side evaluation timestamp. + """ + @type t :: %__MODULE__{ + supervisor_name: PostHog.supervisor_name(), + distinct_id: PostHog.distinct_id(), + flags: %{String.t() => Result.t()}, + request_id: String.t() | nil, + evaluated_at: integer() | nil + } + + @enforce_keys [:supervisor_name, :distinct_id, :flags] + defstruct [:supervisor_name, :distinct_id, :flags, :request_id, :evaluated_at] + + @doc false + @spec new(PostHog.supervisor_name(), PostHog.distinct_id(), map()) :: t() + def new(supervisor_name, distinct_id, %{"flags" => flags_data} = body) + when is_map(flags_data) do + flags = + Map.new(flags_data, fn {key, flag_data} -> + {key, PostHog.FeatureFlags.build_result(key, flag_data, body)} + end) + + %__MODULE__{ + supervisor_name: supervisor_name, + distinct_id: distinct_id, + flags: flags, + request_id: Map.get(body, "requestId"), + evaluated_at: Map.get(body, "evaluatedAt") + } + end + + @doc """ + Returns whether the named flag is enabled in this snapshot. + + Returns `false` for unknown flags. Fires a `$feature_flag_called` event with + full metadata when the flag is present in the snapshot. + """ + @spec enabled?(t(), String.t()) :: boolean() + def enabled?(%__MODULE__{} = snapshot, key) when is_binary(key) do + case fetch_and_log(snapshot, key) do + {:ok, %Result{enabled: enabled}} -> enabled + :error -> false + end + end + + @doc """ + Returns the variant string, the enabled boolean, or `nil` for unknown flags. + + Fires a `$feature_flag_called` event with full metadata when the flag is + present in the snapshot. + """ + @spec get_flag(t(), String.t()) :: String.t() | boolean() | nil + def get_flag(%__MODULE__{} = snapshot, key) when is_binary(key) do + case fetch_and_log(snapshot, key) do + {:ok, %Result{} = result} -> Result.value(result) + :error -> nil + end + end + + @doc """ + Returns the configured payload for the flag, or `nil` for unknown flags or + flags without a payload. + + Does **not** fire a `$feature_flag_called` event. + """ + @spec get_flag_payload(t(), String.t()) :: any() | nil + def get_flag_payload(%__MODULE__{flags: flags}, key) when is_binary(key) do + case Map.fetch(flags, key) do + {:ok, %Result{payload: payload}} -> payload + :error -> nil + end + end + + @doc """ + Returns the sorted list of flag keys present in the snapshot. + """ + @spec keys(t()) :: [String.t()] + def keys(%__MODULE__{flags: flags}), do: flags |> Map.keys() |> Enum.sort() + + @doc """ + Returns a copy of the snapshot scoped to the given keys. Unknown keys are + silently dropped — the resulting snapshot contains only the intersection of + the requested keys with the snapshot's keys. + """ + @spec only(t(), [String.t()]) :: t() + def only(%__MODULE__{flags: flags} = snapshot, keys) when is_list(keys) do + %{snapshot | flags: Map.take(flags, keys)} + end + + @doc """ + Returns the `$feature/` and `$active_feature_flags` properties for this + snapshot, suitable for merging into a captured event's properties. + + - `$feature/` is set to the variant string when present, or to the + enabled boolean otherwise. Disabled flags are included with `false`. + - `$active_feature_flags` is the sorted list of keys whose flag is enabled. + + ## Examples + + properties = PostHog.FeatureFlags.Evaluations.event_properties(snapshot) + PostHog.capture("page_viewed", Map.merge(%{distinct_id: "u1"}, properties)) + """ + @spec event_properties(t()) :: %{String.t() => any()} + def event_properties(%__MODULE__{flags: flags}) do + {properties, active} = + Enum.reduce(flags, {%{}, []}, fn {key, %Result{} = result}, {props, active} -> + value = Result.value(result) + props = Map.put(props, "$feature/#{key}", value) + active = if result.enabled, do: [key | active], else: active + {props, active} + end) + + case active do + [] -> properties + keys -> Map.put(properties, :"$active_feature_flags", Enum.sort(keys)) + end + end + + defp fetch_and_log(%__MODULE__{flags: flags} = snapshot, key) do + with {:ok, %Result{} = result} <- Map.fetch(flags, key) do + log(snapshot, result) + {:ok, result} + end + end + + defp log(%__MODULE__{distinct_id: ""}, _result), do: :ok + + defp log(%__MODULE__{supervisor_name: name, distinct_id: distinct_id}, %Result{} = result) do + PostHog.FeatureFlags.log_feature_flag_usage(name, distinct_id, result) + end +end diff --git a/lib/posthog/feature_flags/result.ex b/lib/posthog/feature_flags/result.ex index ba55524..c94616f 100644 --- a/lib/posthog/feature_flags/result.ex +++ b/lib/posthog/feature_flags/result.ex @@ -3,10 +3,21 @@ defmodule PostHog.FeatureFlags.Result do Represents the result of a feature flag evaluation. This struct contains all the information returned when evaluating a feature flag: + - `key` - The name of the feature flag - `enabled` - Whether the flag is enabled for this user - `variant` - The variant assigned to this user (nil for boolean flags) - `payload` - The JSON payload configured for this flag/variant (nil if not set) + - `id` - Numeric flag ID from the PostHog backend (when available) + - `version` - Flag version from the PostHog backend (when available) + - `reason` - Reason map describing why this evaluation produced its value + - `request_id` - Request ID returned by the `/flags` endpoint (useful for experiment exposure tracking) + - `evaluated_at` - Server-side evaluation timestamp from the response + + The latter five fields are populated when the `/flags` response includes them + and are forwarded as `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, + `$feature_flag_request_id`, and `$feature_flag_evaluated_at` properties on + `$feature_flag_called` events. ## Examples @@ -18,12 +29,17 @@ defmodule PostHog.FeatureFlags.Result do payload: nil } - # Multivariant flag result with payload + # Multivariant flag result with payload and metadata %PostHog.FeatureFlags.Result{ key: "my-experiment", enabled: true, variant: "control", - payload: %{"button_color" => "blue"} + payload: %{"button_color" => "blue"}, + id: 154_429, + version: 4, + reason: %{"code" => "condition_match", "description" => "Matched condition set 1"}, + request_id: "0d23f243-399a-4904-b1a8-ec2037834b72", + evaluated_at: 1_234_567_890 } """ @@ -33,11 +49,26 @@ defmodule PostHog.FeatureFlags.Result do key: String.t(), enabled: boolean(), variant: String.t() | nil, - payload: json() + payload: json(), + id: integer() | nil, + version: integer() | nil, + reason: map() | nil, + request_id: String.t() | nil, + evaluated_at: integer() | nil } @enforce_keys [:key, :enabled] - defstruct [:key, :enabled, :variant, :payload] + defstruct [ + :key, + :enabled, + :variant, + :payload, + :id, + :version, + :reason, + :request_id, + :evaluated_at + ] @doc """ Returns the value of the feature flag result. diff --git a/test/posthog/feature_flags/evaluations_test.exs b/test/posthog/feature_flags/evaluations_test.exs new file mode 100644 index 0000000..269478e --- /dev/null +++ b/test/posthog/feature_flags/evaluations_test.exs @@ -0,0 +1,371 @@ +defmodule PostHog.FeatureFlags.EvaluationsTest do + use PostHog.Case, + async: Version.match?(System.version(), ">= 1.18.0"), + group: PostHog + + @moduletag config: [supervisor_name: PostHog] + + import Mox + + alias PostHog.API + alias PostHog.FeatureFlags + alias PostHog.FeatureFlags.Evaluations + alias PostHog.FeatureFlags.Result + + setup :setup_supervisor + setup :verify_on_exit! + + defp stub_flags_response do + %{ + status: 200, + body: %{ + "flags" => %{ + "boolean-flag" => %{ + "enabled" => true, + "key" => "boolean-flag", + "variant" => nil, + "metadata" => %{"id" => 1, "version" => 2, "payload" => nil}, + "reason" => %{"code" => "condition_match", "description" => "matched"} + }, + "variant-flag" => %{ + "enabled" => true, + "key" => "variant-flag", + "variant" => "control", + "metadata" => %{"id" => 2, "version" => 5, "payload" => %{"copy" => "hi"}}, + "reason" => %{"code" => "condition_match", "description" => "matched"} + }, + "disabled-flag" => %{ + "enabled" => false, + "key" => "disabled-flag", + "variant" => nil, + "metadata" => %{"id" => 3, "version" => 1, "payload" => nil}, + "reason" => %{"code" => "no_condition_match", "description" => "did not match"} + } + }, + "requestId" => "req-abc", + "evaluatedAt" => 1_700_000_000 + } + } + end + + describe "evaluate_flags/2" do + test "returns a snapshot containing every evaluated flag" do + expect(API.Mock, :request, fn _client, :post, "/flags", _opts -> + {:ok, stub_flags_response()} + end) + + assert {:ok, %Evaluations{distinct_id: "foo"} = snapshot} = + FeatureFlags.evaluate_flags("foo") + + assert Evaluations.keys(snapshot) == ["boolean-flag", "disabled-flag", "variant-flag"] + assert snapshot.request_id == "req-abc" + assert snapshot.evaluated_at == 1_700_000_000 + end + + test "makes exactly one /flags request" do + expect(API.Mock, :request, 1, fn _client, :post, "/flags", _opts -> + {:ok, stub_flags_response()} + end) + + assert {:ok, _} = FeatureFlags.evaluate_flags("foo") + end + + test "does not fire any $feature_flag_called events on construction" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + assert {:ok, _} = FeatureFlags.evaluate_flags("foo") + assert all_captured() == [] + end + + test "translates :flag_keys into flag_keys_to_evaluate in the request body" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{ + distinct_id: "foo", + flag_keys_to_evaluate: ["boolean-flag", "variant-flag"] + } + + {:ok, stub_flags_response()} + end) + + assert {:ok, _} = + FeatureFlags.evaluate_flags(%{ + distinct_id: "foo", + flag_keys: ["boolean-flag", "variant-flag"] + }) + end + + test "forwards person_properties unchanged in the request body" do + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{distinct_id: "foo", person_properties: %{plan: "enterprise"}} + {:ok, stub_flags_response()} + end) + + assert {:ok, _} = + FeatureFlags.evaluate_flags(%{ + distinct_id: "foo", + person_properties: %{plan: "enterprise"} + }) + end + + test "reads distinct_id from context when none is provided" do + PostHog.set_context(%{distinct_id: "from-context"}) + + expect(API.Mock, :request, fn _client, :post, "/flags", opts -> + assert opts[:json] == %{distinct_id: "from-context"} + {:ok, stub_flags_response()} + end) + + assert {:ok, %Evaluations{distinct_id: "from-context"}} = FeatureFlags.evaluate_flags() + end + + test "returns an error when distinct_id cannot be resolved" do + assert {:error, %PostHog.Error{}} = FeatureFlags.evaluate_flags(nil) + assert all_captured() == [] + end + + @tag config: [supervisor_name: MyPostHog] + test "supports a named PostHog instance" do + expect(API.Mock, :request, fn _client, :post, "/flags", _opts -> + {:ok, stub_flags_response()} + end) + + assert {:ok, %Evaluations{supervisor_name: MyPostHog}} = + FeatureFlags.evaluate_flags(MyPostHog, "foo") + end + end + + describe "enabled?/2 and get_flag/2" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "enabled?/2 returns the boolean for known flags", %{snapshot: snapshot} do + assert Evaluations.enabled?(snapshot, "boolean-flag") == true + assert Evaluations.enabled?(snapshot, "variant-flag") == true + assert Evaluations.enabled?(snapshot, "disabled-flag") == false + end + + test "enabled?/2 returns false for unknown flags without firing an event", + %{snapshot: snapshot} do + assert Evaluations.enabled?(snapshot, "unknown-flag") == false + assert all_captured() == [] + end + + test "get_flag/2 returns the variant when present", %{snapshot: snapshot} do + assert Evaluations.get_flag(snapshot, "variant-flag") == "control" + end + + test "get_flag/2 returns true/false for boolean flags", %{snapshot: snapshot} do + assert Evaluations.get_flag(snapshot, "boolean-flag") == true + assert Evaluations.get_flag(snapshot, "disabled-flag") == false + end + + test "get_flag/2 returns nil for unknown flags without firing an event", + %{snapshot: snapshot} do + assert Evaluations.get_flag(snapshot, "unknown-flag") == nil + assert all_captured() == [] + end + + test "fires $feature_flag_called with full metadata", %{snapshot: snapshot} do + assert Evaluations.get_flag(snapshot, "variant-flag") == "control" + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "foo", + properties: %{ + "$feature_flag": "variant-flag", + "$feature_flag_response": "control", + "$feature_flag_id": 2, + "$feature_flag_version": 5, + "$feature_flag_reason": %{"code" => "condition_match"}, + "$feature_flag_request_id": "req-abc", + "$feature_flag_evaluated_at": 1_700_000_000 + } + } + ] = all_captured() + end + + test "fires on every access (no dedup in this PR)", %{snapshot: snapshot} do + Evaluations.enabled?(snapshot, "boolean-flag") + Evaluations.enabled?(snapshot, "boolean-flag") + Evaluations.get_flag(snapshot, "boolean-flag") + + assert length(all_captured()) == 3 + end + end + + describe "get_flag_payload/2" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "returns the configured payload", %{snapshot: snapshot} do + assert Evaluations.get_flag_payload(snapshot, "variant-flag") == %{"copy" => "hi"} + end + + test "returns nil when no payload is configured", %{snapshot: snapshot} do + assert Evaluations.get_flag_payload(snapshot, "boolean-flag") == nil + end + + test "returns nil for unknown flags", %{snapshot: snapshot} do + assert Evaluations.get_flag_payload(snapshot, "unknown-flag") == nil + end + + test "does not fire a $feature_flag_called event", %{snapshot: snapshot} do + Evaluations.get_flag_payload(snapshot, "variant-flag") + Evaluations.get_flag_payload(snapshot, "unknown-flag") + + assert all_captured() == [] + end + end + + describe "only/2" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "narrows the snapshot to the requested keys", %{snapshot: snapshot} do + narrowed = Evaluations.only(snapshot, ["boolean-flag", "variant-flag"]) + assert Evaluations.keys(narrowed) == ["boolean-flag", "variant-flag"] + end + + test "silently drops unknown keys", %{snapshot: snapshot} do + narrowed = Evaluations.only(snapshot, ["boolean-flag", "does-not-exist"]) + assert Evaluations.keys(narrowed) == ["boolean-flag"] + end + + test "preserves snapshot metadata", %{snapshot: snapshot} do + narrowed = Evaluations.only(snapshot, ["boolean-flag"]) + + assert narrowed.distinct_id == snapshot.distinct_id + assert narrowed.supervisor_name == snapshot.supervisor_name + assert narrowed.request_id == snapshot.request_id + assert narrowed.evaluated_at == snapshot.evaluated_at + end + end + + describe "event_properties/1" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "produces $feature/ entries for every flag", %{snapshot: snapshot} do + properties = Evaluations.event_properties(snapshot) + + assert properties["$feature/boolean-flag"] == true + assert properties["$feature/variant-flag"] == "control" + assert properties["$feature/disabled-flag"] == false + end + + test "produces $active_feature_flags sorted, enabled-only", %{snapshot: snapshot} do + properties = Evaluations.event_properties(snapshot) + + assert properties[:"$active_feature_flags"] == ["boolean-flag", "variant-flag"] + end + + test "omits $active_feature_flags when no flag is enabled" do + snapshot = %Evaluations{ + supervisor_name: PostHog, + distinct_id: "foo", + flags: %{ + "off" => %Result{key: "off", enabled: false} + } + } + + properties = Evaluations.event_properties(snapshot) + + refute Map.has_key?(properties, :"$active_feature_flags") + assert properties["$feature/off"] == false + end + end + + describe "set_in_context/2" do + test "merges $feature/ and $active_feature_flags into the context" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + :ok = FeatureFlags.set_in_context(snapshot) + + context = PostHog.get_context() + assert context["$feature/boolean-flag"] == true + assert context["$feature/variant-flag"] == "control" + assert context["$feature/disabled-flag"] == false + assert context[:"$active_feature_flags"] == ["boolean-flag", "variant-flag"] + end + + test "captures triggered after set_in_context attach the snapshot's properties" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + :ok = FeatureFlags.set_in_context(snapshot) + + :ok = PostHog.capture("page_viewed", %{distinct_id: "foo"}) + + assert [ + %{ + event: "page_viewed", + distinct_id: "foo", + properties: properties + } + ] = all_captured() + + assert properties["$feature/boolean-flag"] == true + assert properties["$feature/variant-flag"] == "control" + assert properties[:"$active_feature_flags"] == ["boolean-flag", "variant-flag"] + end + + test "does not trigger an additional /flags request when capturing" do + expect(API.Mock, :request, 1, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + :ok = FeatureFlags.set_in_context(snapshot) + :ok = PostHog.capture("page_viewed", %{distinct_id: "foo"}) + end + end + + describe "empty distinct_id" do + test "manually-constructed snapshot with empty distinct_id does not fire events" do + snapshot = %Evaluations{ + supervisor_name: PostHog, + distinct_id: "", + flags: %{ + "flag" => %Result{key: "flag", enabled: true} + } + } + + Evaluations.enabled?(snapshot, "flag") + Evaluations.get_flag(snapshot, "flag") + + assert all_captured() == [] + end + end +end diff --git a/test/posthog/feature_flags_test.exs b/test/posthog/feature_flags_test.exs index 6f2b3b3..f612192 100644 --- a/test/posthog/feature_flags_test.exs +++ b/test/posthog/feature_flags_test.exs @@ -307,6 +307,45 @@ defmodule PostHog.FeatureFlagsTest do ] = all_captured() end + test "attaches rich metadata to $feature_flag_called when the response provides it" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{ + "myflag" => %{ + "enabled" => true, + "variant" => "variant1", + "metadata" => %{"id" => 42, "version" => 7, "payload" => nil}, + "reason" => %{"code" => "condition_match"} + } + }, + "requestId" => "req-xyz", + "evaluatedAt" => 1_700_000_000 + } + }} + end) + + assert {:ok, "variant1"} = FeatureFlags.check("myflag", "foo") + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "foo", + properties: %{ + "$feature_flag": "myflag", + "$feature_flag_response": "variant1", + "$feature_flag_id": 42, + "$feature_flag_version": 7, + "$feature_flag_reason": %{"code" => "condition_match"}, + "$feature_flag_request_id": "req-xyz", + "$feature_flag_evaluated_at": 1_700_000_000 + } + } + ] = all_captured() + end + @tag config: [supervisor_name: MyPostHog] test "custom PostHog instance" do expect(API.Mock, :request, fn client, method, url, opts -> From 60fa3e99997f92ef4d4ad51155cabb7e879b8e22 Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 14:07:46 -0700 Subject: [PATCH 2/4] feat: propagate $feature_flag_error to $feature_flag_called MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR feedback (mirrors posthog-python#539's 95eb1e9 fix). The /flags response carries `errorsWhileComputingFlags` to signal partial-evaluation failure, but events fired from both the existing single-flag path and the new snapshot path were dropping it. %PostHog.FeatureFlags.Result{} gains :errors_while_computing, populated from the response body. log_feature_flag_usage/3 now builds a comma-joined $feature_flag_error property when errors are present. The snapshot path also fires $feature_flag_called for unknown flags with $feature_flag_error: "flag_missing" — useful telemetry for "user asked for a flag that does not exist" — combined with errors_while_computing_flags when both apply ("errors_while_computing_flags,flag_missing"). The existing single-flag path is unchanged for missing flags (still returns nil/error). Generated-By: PostHog Code Task-Id: c6aa804c-618a-4229-b6a3-dc8c9ccff778 --- .sampo/changesets/evaluate-flags-api.md | 2 +- lib/posthog/feature_flags.ex | 30 ++++++- lib/posthog/feature_flags/evaluations.ex | 49 +++++++--- lib/posthog/feature_flags/result.ex | 12 ++- .../feature_flags/evaluations_test.exs | 89 ++++++++++++++++++- test/posthog/feature_flags_test.exs | 25 ++++++ 6 files changed, 186 insertions(+), 21 deletions(-) diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md index 9a051d7..fd7e719 100644 --- a/.sampo/changesets/evaluate-flags-api.md +++ b/.sampo/changesets/evaluate-flags-api.md @@ -17,6 +17,6 @@ PostHog.capture("page_viewed", %{distinct_id: "user-123"}) The snapshot exposes `enabled?/2`, `get_flag/2`, `get_flag_payload/2`, `only/2`, `keys/1`, and `event_properties/1`. Pass `flag_keys: [...]` to `evaluate_flags/2` to scope the underlying `/flags` request itself. -`$feature_flag_called` events fired from `check/3`, `check!/3`, `get_feature_flag_result/4`, and the new snapshot path now attach `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, and `$feature_flag_request_id` when the response provides them. `%PostHog.FeatureFlags.Result{}` gains matching `:id`, `:version`, `:reason`, `:request_id`, and `:evaluated_at` fields. +`$feature_flag_called` events fired from `check/3`, `check!/3`, `get_feature_flag_result/4`, and the new snapshot path now attach `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`, and `$feature_flag_error` (combining `errors_while_computing_flags` and, on the snapshot path, `flag_missing`) when the response provides them. `%PostHog.FeatureFlags.Result{}` gains matching `:id`, `:version`, `:reason`, `:request_id`, `:evaluated_at`, and `:errors_while_computing` fields. Existing `check/3`, `check!/3`, and `get_feature_flag_result/4` continue to work unchanged. diff --git a/lib/posthog/feature_flags.ex b/lib/posthog/feature_flags.ex index 808df7c..f6a2a46 100644 --- a/lib/posthog/feature_flags.ex +++ b/lib/posthog/feature_flags.ex @@ -420,7 +420,8 @@ defmodule PostHog.FeatureFlags do version: get_in(flag_data, ["metadata", "version"]), reason: Map.get(flag_data, "reason"), request_id: Map.get(body, "requestId"), - evaluated_at: Map.get(body, "evaluatedAt") + evaluated_at: Map.get(body, "evaluatedAt"), + errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true } end @@ -485,7 +486,21 @@ defmodule PostHog.FeatureFlags do ) :: :ok | {:error, :missing_distinct_id} def log_feature_flag_usage(name, distinct_id, %__MODULE__.Result{} = result) do + log_feature_flag_usage(name, distinct_id, result, []) + end + + @doc false + @spec log_feature_flag_usage( + PostHog.supervisor_name(), + PostHog.distinct_id(), + __MODULE__.Result.t(), + [String.t()] + ) :: + :ok | {:error, :missing_distinct_id} + def log_feature_flag_usage(name, distinct_id, %__MODULE__.Result{} = result, extra_errors) + when is_list(extra_errors) do value = __MODULE__.Result.value(result) + errors = build_error_codes(result, extra_errors) properties = %{ @@ -498,12 +513,23 @@ defmodule PostHog.FeatureFlags do |> maybe_put(:"$feature_flag_reason", result.reason) |> maybe_put(:"$feature_flag_request_id", result.request_id) |> maybe_put(:"$feature_flag_evaluated_at", result.evaluated_at) + |> maybe_put(:"$feature_flag_error", errors) PostHog.capture(name, "$feature_flag_called", properties) - PostHog.set_context(name, %{"$feature/#{result.key}" => value}) + if extra_errors == [] do + PostHog.set_context(name, %{"$feature/#{result.key}" => value}) + else + :ok + end end + defp build_error_codes(%__MODULE__.Result{errors_while_computing: true}, extra), + do: ["errors_while_computing_flags" | extra] |> Enum.join(",") + + defp build_error_codes(%__MODULE__.Result{}, []), do: nil + defp build_error_codes(%__MODULE__.Result{}, extra), do: Enum.join(extra, ",") + defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) diff --git a/lib/posthog/feature_flags/evaluations.ex b/lib/posthog/feature_flags/evaluations.ex index 5e2aa0c..8de4309 100644 --- a/lib/posthog/feature_flags/evaluations.ex +++ b/lib/posthog/feature_flags/evaluations.ex @@ -57,17 +57,29 @@ defmodule PostHog.FeatureFlags.Evaluations do - `:flags` - map of flag key to `t:PostHog.FeatureFlags.Result.t/0`. - `:request_id` - request ID returned by `/flags`. - `:evaluated_at` - server-side evaluation timestamp. + - `:errors_while_computing` - whether the response signaled + `errorsWhileComputingFlags`. When `true`, every event fired from this + snapshot includes `errors_while_computing_flags` in its + `$feature_flag_error` property. """ @type t :: %__MODULE__{ supervisor_name: PostHog.supervisor_name(), distinct_id: PostHog.distinct_id(), flags: %{String.t() => Result.t()}, request_id: String.t() | nil, - evaluated_at: integer() | nil + evaluated_at: integer() | nil, + errors_while_computing: boolean() } @enforce_keys [:supervisor_name, :distinct_id, :flags] - defstruct [:supervisor_name, :distinct_id, :flags, :request_id, :evaluated_at] + defstruct [ + :supervisor_name, + :distinct_id, + :flags, + :request_id, + :evaluated_at, + errors_while_computing: false + ] @doc false @spec new(PostHog.supervisor_name(), PostHog.distinct_id(), map()) :: t() @@ -83,7 +95,8 @@ defmodule PostHog.FeatureFlags.Evaluations do distinct_id: distinct_id, flags: flags, request_id: Map.get(body, "requestId"), - evaluated_at: Map.get(body, "evaluatedAt") + evaluated_at: Map.get(body, "evaluatedAt"), + errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true } end @@ -91,7 +104,8 @@ defmodule PostHog.FeatureFlags.Evaluations do Returns whether the named flag is enabled in this snapshot. Returns `false` for unknown flags. Fires a `$feature_flag_called` event with - full metadata when the flag is present in the snapshot. + full metadata when the flag is present, or with + `$feature_flag_error: "flag_missing"` when it is not. """ @spec enabled?(t(), String.t()) :: boolean() def enabled?(%__MODULE__{} = snapshot, key) when is_binary(key) do @@ -105,7 +119,7 @@ defmodule PostHog.FeatureFlags.Evaluations do Returns the variant string, the enabled boolean, or `nil` for unknown flags. Fires a `$feature_flag_called` event with full metadata when the flag is - present in the snapshot. + present, or with `$feature_flag_error: "flag_missing"` when it is not. """ @spec get_flag(t(), String.t()) :: String.t() | boolean() | nil def get_flag(%__MODULE__{} = snapshot, key) when is_binary(key) do @@ -175,15 +189,28 @@ defmodule PostHog.FeatureFlags.Evaluations do end defp fetch_and_log(%__MODULE__{flags: flags} = snapshot, key) do - with {:ok, %Result{} = result} <- Map.fetch(flags, key) do - log(snapshot, result) - {:ok, result} + case Map.fetch(flags, key) do + {:ok, %Result{} = result} -> + log(snapshot, result, []) + {:ok, result} + + :error -> + log(snapshot, missing_result(snapshot, key), ["flag_missing"]) + :error end end - defp log(%__MODULE__{distinct_id: ""}, _result), do: :ok + defp missing_result(%__MODULE__{errors_while_computing: ewc}, key) do + %Result{key: key, enabled: false, errors_while_computing: ewc} + end + + defp log(%__MODULE__{distinct_id: ""}, _result, _extra_errors), do: :ok - defp log(%__MODULE__{supervisor_name: name, distinct_id: distinct_id}, %Result{} = result) do - PostHog.FeatureFlags.log_feature_flag_usage(name, distinct_id, result) + defp log( + %__MODULE__{supervisor_name: name, distinct_id: distinct_id}, + %Result{} = result, + extra_errors + ) do + PostHog.FeatureFlags.log_feature_flag_usage(name, distinct_id, result, extra_errors) end end diff --git a/lib/posthog/feature_flags/result.ex b/lib/posthog/feature_flags/result.ex index c94616f..7c7453a 100644 --- a/lib/posthog/feature_flags/result.ex +++ b/lib/posthog/feature_flags/result.ex @@ -13,8 +13,12 @@ defmodule PostHog.FeatureFlags.Result do - `reason` - Reason map describing why this evaluation produced its value - `request_id` - Request ID returned by the `/flags` endpoint (useful for experiment exposure tracking) - `evaluated_at` - Server-side evaluation timestamp from the response + - `errors_while_computing` - Whether the response signaled + `errorsWhileComputingFlags`; values for some flags may be incomplete or + stale. Forwarded as `$feature_flag_error: "errors_while_computing_flags"` + on `$feature_flag_called` events. - The latter five fields are populated when the `/flags` response includes them + The metadata fields are populated when the `/flags` response includes them and are forwarded as `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`, and `$feature_flag_evaluated_at` properties on `$feature_flag_called` events. @@ -54,7 +58,8 @@ defmodule PostHog.FeatureFlags.Result do version: integer() | nil, reason: map() | nil, request_id: String.t() | nil, - evaluated_at: integer() | nil + evaluated_at: integer() | nil, + errors_while_computing: boolean() } @enforce_keys [:key, :enabled] @@ -67,7 +72,8 @@ defmodule PostHog.FeatureFlags.Result do :version, :reason, :request_id, - :evaluated_at + :evaluated_at, + errors_while_computing: false ] @doc """ diff --git a/test/posthog/feature_flags/evaluations_test.exs b/test/posthog/feature_flags/evaluations_test.exs index 269478e..04f41bd 100644 --- a/test/posthog/feature_flags/evaluations_test.exs +++ b/test/posthog/feature_flags/evaluations_test.exs @@ -152,10 +152,21 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do assert Evaluations.enabled?(snapshot, "disabled-flag") == false end - test "enabled?/2 returns false for unknown flags without firing an event", + test "enabled?/2 returns false for unknown flags and fires a flag_missing event", %{snapshot: snapshot} do assert Evaluations.enabled?(snapshot, "unknown-flag") == false - assert all_captured() == [] + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "foo", + properties: %{ + "$feature_flag": "unknown-flag", + "$feature_flag_response": false, + "$feature_flag_error": "flag_missing" + } + } + ] = all_captured() end test "get_flag/2 returns the variant when present", %{snapshot: snapshot} do @@ -167,10 +178,21 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do assert Evaluations.get_flag(snapshot, "disabled-flag") == false end - test "get_flag/2 returns nil for unknown flags without firing an event", + test "get_flag/2 returns nil for unknown flags and fires a flag_missing event", %{snapshot: snapshot} do assert Evaluations.get_flag(snapshot, "unknown-flag") == nil - assert all_captured() == [] + + assert [ + %{ + event: "$feature_flag_called", + distinct_id: "foo", + properties: %{ + "$feature_flag": "unknown-flag", + "$feature_flag_response": false, + "$feature_flag_error": "flag_missing" + } + } + ] = all_captured() end test "fires $feature_flag_called with full metadata", %{snapshot: snapshot} do @@ -352,6 +374,65 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do end end + describe "errors_while_computing propagation" do + defp errored_flags_response do + response = stub_flags_response() + put_in(response, [:body, "errorsWhileComputingFlags"], true) + end + + test "attaches errors_while_computing_flags to events for known flags" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, errored_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + assert snapshot.errors_while_computing == true + Evaluations.enabled?(snapshot, "boolean-flag") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{ + "$feature_flag": "boolean-flag", + "$feature_flag_error": "errors_while_computing_flags" + } + } + ] = all_captured() + end + + test "combines errors_while_computing_flags with flag_missing for missing flags" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, errored_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + Evaluations.enabled?(snapshot, "missing-flag") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{ + "$feature_flag": "missing-flag", + "$feature_flag_error": "errors_while_computing_flags,flag_missing" + } + } + ] = all_captured() + end + + test "omits $feature_flag_error when there are no errors", %{} do + # uses the default stub_flags_response with errorsWhileComputingFlags absent + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + Evaluations.enabled?(snapshot, "boolean-flag") + + assert [%{properties: properties}] = all_captured() + refute Map.has_key?(properties, :"$feature_flag_error") + end + end + describe "empty distinct_id" do test "manually-constructed snapshot with empty distinct_id does not fire events" do snapshot = %Evaluations{ diff --git a/test/posthog/feature_flags_test.exs b/test/posthog/feature_flags_test.exs index f612192..ea96673 100644 --- a/test/posthog/feature_flags_test.exs +++ b/test/posthog/feature_flags_test.exs @@ -307,6 +307,31 @@ defmodule PostHog.FeatureFlagsTest do ] = all_captured() end + test "attaches $feature_flag_error: errors_while_computing_flags when response signals errors" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{"myflag" => %{"enabled" => true, "variant" => "variant1"}}, + "errorsWhileComputingFlags" => true + } + }} + end) + + assert {:ok, "variant1"} = FeatureFlags.check("myflag", "foo") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{ + "$feature_flag": "myflag", + "$feature_flag_error": "errors_while_computing_flags" + } + } + ] = all_captured() + end + test "attaches rich metadata to $feature_flag_called when the response provides it" do expect(API.Mock, :request, fn _client, _method, _url, _opts -> {:ok, From c59530f2b07d7da92d64e4f3f11cadf99bdde47f Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 14:14:34 -0700 Subject: [PATCH 3/4] feat: deprecate check/check!/get_feature_flag_result(!) (Phase 2) Adds @deprecated to the four legacy single-flag entry points and their auto-generated lower-arity overloads, pointing users at evaluate_flags/2 + Evaluations. Compile-time warnings emit on every call site; functions continue to return the same values. Removal is planned for the next major. Each function also gains a "Deprecated" admonition in its @doc so generated HexDocs surface the migration guidance. Generated-By: PostHog Code Task-Id: c6aa804c-618a-4229-b6a3-dc8c9ccff778 --- .sampo/changesets/evaluate-flags-api.md | 2 +- lib/posthog/feature_flags.ex | 35 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md index fd7e719..9504ea4 100644 --- a/.sampo/changesets/evaluate-flags-api.md +++ b/.sampo/changesets/evaluate-flags-api.md @@ -19,4 +19,4 @@ The snapshot exposes `enabled?/2`, `get_flag/2`, `get_flag_payload/2`, `only/2`, `$feature_flag_called` events fired from `check/3`, `check!/3`, `get_feature_flag_result/4`, and the new snapshot path now attach `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`, and `$feature_flag_error` (combining `errors_while_computing_flags` and, on the snapshot path, `flag_missing`) when the response provides them. `%PostHog.FeatureFlags.Result{}` gains matching `:id`, `:version`, `:reason`, `:request_id`, `:evaluated_at`, and `:errors_while_computing` fields. -Existing `check/3`, `check!/3`, and `get_feature_flag_result/4` continue to work unchanged. +`check/3`, `check!/3`, `get_feature_flag_result/4`, and `get_feature_flag_result!/4` are now marked `@deprecated` and emit compile-time warnings pointing at `evaluate_flags/2`. They continue to return the same values; removal is planned for the next major. diff --git a/lib/posthog/feature_flags.ex b/lib/posthog/feature_flags.ex index f6a2a46..aac1746 100644 --- a/lib/posthog/feature_flags.ex +++ b/lib/posthog/feature_flags.ex @@ -188,13 +188,22 @@ defmodule PostHog.FeatureFlags do defp translate_flag_keys(body), do: body + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2" @doc false def check(flag_name, distinct_id_or_body) when not is_atom(flag_name), do: check(PostHog, flag_name, distinct_id_or_body) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2" @doc """ Checks feature flag + > #### Deprecated {: .warning} + > + > Use `PostHog.FeatureFlags.evaluate_flags/2` plus + > `PostHog.FeatureFlags.Evaluations.enabled?/2` or + > `PostHog.FeatureFlags.Evaluations.get_flag/2` instead. The snapshot lets + > one `/flags` call serve multiple flag checks plus event enrichment. + If there is a variant assigned, returns `{:ok, variant}`. Otherwise, `{:ok, true}` or `{:ok, false}`. @@ -243,19 +252,29 @@ defmodule PostHog.FeatureFlags do end end + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc false def get_feature_flag_result(flag_name, distinct_id_or_body) when not is_atom(flag_name) and not is_list(distinct_id_or_body), do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, []) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc false def get_feature_flag_result(flag_name, distinct_id_or_body, opts) when not is_atom(flag_name) and is_list(opts), do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, opts) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc """ Gets the full feature flag result including value and payload. + > #### Deprecated {: .warning} + > + > Use `PostHog.FeatureFlags.evaluate_flags/2` and access flags from the + > returned `PostHog.FeatureFlags.Evaluations` snapshot. The snapshot + > exposes the same metadata (id, version, reason, payload) plus filter + > helpers and capture enrichment via `set_in_context/2`. + Returns `{:ok, %PostHog.FeatureFlags.Result{}}` on success, `{:ok, nil}` if the flag is not found, or `{:error, reason}` on failure. @@ -322,19 +341,27 @@ defmodule PostHog.FeatureFlags do end end + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc false def get_feature_flag_result!(flag_name, distinct_id_or_body) when not is_atom(flag_name) and not is_list(distinct_id_or_body), do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, []) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc false def get_feature_flag_result!(flag_name, distinct_id_or_body, opts) when not is_atom(flag_name) and is_list(opts), do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, opts) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations" @doc """ Gets the full feature flag result or raises on error. + > #### Deprecated {: .warning} + > + > Use `PostHog.FeatureFlags.evaluate_flags/2` and access flags from the + > returned `PostHog.FeatureFlags.Evaluations` snapshot. + This is a wrapper around `get_feature_flag_result/4` that returns the result directly or raises an exception on error. This follows the Elixir convention where functions ending with `!` raise exceptions instead of returning error @@ -431,13 +458,21 @@ defmodule PostHog.FeatureFlags do {enabled, variant} end + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2" @doc false def check!(flag_name, distinct_id_or_body) when not is_atom(flag_name), do: check!(PostHog, flag_name, distinct_id_or_body) + @deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2" @doc """ Checks feature flag and returns the variant or raises on error. + > #### Deprecated {: .warning} + > + > Use `PostHog.FeatureFlags.evaluate_flags/2` and + > `PostHog.FeatureFlags.Evaluations.enabled?/2` / + > `PostHog.FeatureFlags.Evaluations.get_flag/2` instead. + This is a wrapper around `check/3` that returns the variant directly or raises an exception on error. This follows the Elixir convention where functions ending with `!` raise exceptions instead of returning error tuples. From 144c934bf7d8fc8b0017bfa08484dd0dbdd076c0 Mon Sep 17 00:00:00 2001 From: dylan Date: Thu, 30 Apr 2026 14:18:12 -0700 Subject: [PATCH 4/4] fix: address review feedback on evaluate_flags() PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five changes in response to dustinbyrne's review on #103: 1. Add only_accessed/1 + accessed/1 with Agent-backed access tracking. The Agent is linked to the calling process; cleanup is automatic. enabled?/2, get_flag/2, and get_flag_payload/2 now record access. Filtered snapshots from only/2 and only_accessed/1 get fresh access trackers — calls on a filtered view do not back-propagate to the parent. Empty access set returns an empty snapshot (matches the python PR's final behavior). 2. evaluate_flags/2 now returns an empty snapshot ({:ok, %Evaluations{...}}) when distinct_id cannot be resolved, instead of {:error, ...}. The empty snapshot has distinct_id: "" which short-circuits event firing in enabled?/2 and get_flag/2. 3. flag_missing events now use $feature_flag_response: nil to match the cross-SDK behavior and what get_flag/2 returns to the caller. 4. log_feature_flag_usage/4 now attaches $feature/ and $feature_flag_payload to $feature_flag_called events on both the new snapshot path and the existing single-flag path. Brings parity with posthog-python and posthog-js. 5. JSON-decode payload strings in build_result/3. PostHog stores payloads as JSON-encoded strings; the SDK now parses them before attaching to events and the Result struct's :payload field. Falls back to the raw string when parsing fails. nil and already-decoded values pass through. Generated-By: PostHog Code Task-Id: c6aa804c-618a-4229-b6a3-dc8c9ccff778 --- .sampo/changesets/evaluate-flags-api.md | 4 +- lib/posthog/feature_flags.ex | 55 ++++-- lib/posthog/feature_flags/evaluations.ex | 112 +++++++++-- .../feature_flags/evaluations_test.exs | 181 +++++++++++++++--- 4 files changed, 287 insertions(+), 65 deletions(-) diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md index 9504ea4..db49bea 100644 --- a/.sampo/changesets/evaluate-flags-api.md +++ b/.sampo/changesets/evaluate-flags-api.md @@ -15,8 +15,8 @@ PostHog.FeatureFlags.set_in_context(snapshot) PostHog.capture("page_viewed", %{distinct_id: "user-123"}) ``` -The snapshot exposes `enabled?/2`, `get_flag/2`, `get_flag_payload/2`, `only/2`, `keys/1`, and `event_properties/1`. Pass `flag_keys: [...]` to `evaluate_flags/2` to scope the underlying `/flags` request itself. +The snapshot exposes `enabled?/2`, `get_flag/2`, `get_flag_payload/2`, `only/2`, `only_accessed/1`, `accessed/1`, `keys/1`, and `event_properties/1`. Pass `flag_keys: [...]` to `evaluate_flags/2` to scope the underlying `/flags` request itself. When `distinct_id` cannot be resolved, `evaluate_flags/2` returns an empty snapshot whose accessors are no-ops (matching the cross-SDK behavior). -`$feature_flag_called` events fired from `check/3`, `check!/3`, `get_feature_flag_result/4`, and the new snapshot path now attach `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`, and `$feature_flag_error` (combining `errors_while_computing_flags` and, on the snapshot path, `flag_missing`) when the response provides them. `%PostHog.FeatureFlags.Result{}` gains matching `:id`, `:version`, `:reason`, `:request_id`, `:evaluated_at`, and `:errors_while_computing` fields. +`$feature_flag_called` events fired from `check/3`, `check!/3`, `get_feature_flag_result/4`, and the new snapshot path now attach `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`, `$feature_flag_payload`, `$feature/`, and `$feature_flag_error` (combining `errors_while_computing_flags` and, on the snapshot path, `flag_missing`) when the response provides them. JSON-encoded payloads in `/flags` responses are now decoded before being attached to events and the `:payload` field on `%PostHog.FeatureFlags.Result{}`. The struct also gains `:id`, `:version`, `:reason`, `:request_id`, `:evaluated_at`, and `:errors_while_computing`. `check/3`, `check!/3`, `get_feature_flag_result/4`, and `get_feature_flag_result!/4` are now marked `@deprecated` and emit compile-time warnings pointing at `evaluate_flags/2`. They continue to return the same values; removal is planned for the next major. diff --git a/lib/posthog/feature_flags.ex b/lib/posthog/feature_flags.ex index aac1746..7d7b08d 100644 --- a/lib/posthog/feature_flags.ex +++ b/lib/posthog/feature_flags.ex @@ -143,10 +143,24 @@ defmodule PostHog.FeatureFlags do @spec evaluate_flags(PostHog.supervisor_name(), PostHog.distinct_id() | map() | nil) :: {:ok, __MODULE__.Evaluations.t()} | {:error, Exception.t()} def evaluate_flags(name \\ PostHog, distinct_id_or_body \\ nil) do - with {:ok, %{distinct_id: distinct_id} = body} <- body_for_flags(distinct_id_or_body), - body = translate_flag_keys(body), - {:ok, %{body: response_body}} <- flags(name, body) do - {:ok, __MODULE__.Evaluations.new(name, distinct_id, response_body)} + case body_for_flags(distinct_id_or_body) do + {:ok, %{distinct_id: distinct_id} = body} -> + body = translate_flag_keys(body) + + case flags(name, body) do + {:ok, %{body: response_body}} -> + {:ok, __MODULE__.Evaluations.new(name, distinct_id, response_body)} + + {:error, _} = error -> + error + end + + {:error, _} -> + # Standardize on returning an empty snapshot when distinct_id can't be + # resolved — matches the cross-SDK behavior. The empty distinct_id + # short-circuits event firing in `enabled?/2` and `get_flag/2`, so no + # events leak with an empty distinct_id. + {:ok, __MODULE__.Evaluations.empty(name)} end end @@ -442,7 +456,7 @@ defmodule PostHog.FeatureFlags do key: flag_name, enabled: enabled, variant: variant, - payload: get_in(flag_data, ["metadata", "payload"]), + payload: normalize_payload(get_in(flag_data, ["metadata", "payload"])), id: get_in(flag_data, ["metadata", "id"]), version: get_in(flag_data, ["metadata", "version"]), reason: Map.get(flag_data, "reason"), @@ -452,6 +466,20 @@ defmodule PostHog.FeatureFlags do } end + # PostHog's `/flags` returns payloads as JSON-encoded strings (the user + # configures them as JSON in the UI). Decode them so callers receive the + # parsed value. Non-string or already-decoded payloads pass through as-is. + defp normalize_payload(nil), do: nil + + defp normalize_payload(payload) when is_binary(payload) do + case Jason.decode(payload) do + {:ok, decoded} -> decoded + {:error, _} -> payload + end + end + + defp normalize_payload(payload), do: payload + defp extract_flag_enabled_and_variant(flag_data) do enabled = Map.get(flag_data, "enabled", false) == true variant = Map.get(flag_data, "variant") @@ -534,28 +562,31 @@ defmodule PostHog.FeatureFlags do :ok | {:error, :missing_distinct_id} def log_feature_flag_usage(name, distinct_id, %__MODULE__.Result{} = result, extra_errors) when is_list(extra_errors) do - value = __MODULE__.Result.value(result) + flag_missing? = "flag_missing" in extra_errors + value = if flag_missing?, do: nil, else: __MODULE__.Result.value(result) errors = build_error_codes(result, extra_errors) properties = %{ - distinct_id: distinct_id, - "$feature_flag": result.key, - "$feature_flag_response": value + "$feature/#{result.key}" => value, + :distinct_id => distinct_id, + :"$feature_flag" => result.key, + :"$feature_flag_response" => value } |> maybe_put(:"$feature_flag_id", result.id) |> maybe_put(:"$feature_flag_version", result.version) |> maybe_put(:"$feature_flag_reason", result.reason) |> maybe_put(:"$feature_flag_request_id", result.request_id) |> maybe_put(:"$feature_flag_evaluated_at", result.evaluated_at) + |> maybe_put(:"$feature_flag_payload", result.payload) |> maybe_put(:"$feature_flag_error", errors) PostHog.capture(name, "$feature_flag_called", properties) - if extra_errors == [] do - PostHog.set_context(name, %{"$feature/#{result.key}" => value}) - else + if flag_missing? do :ok + else + PostHog.set_context(name, %{"$feature/#{result.key}" => value}) end end diff --git a/lib/posthog/feature_flags/evaluations.ex b/lib/posthog/feature_flags/evaluations.ex index 8de4309..5f5c2f1 100644 --- a/lib/posthog/feature_flags/evaluations.ex +++ b/lib/posthog/feature_flags/evaluations.ex @@ -7,16 +7,17 @@ defmodule PostHog.FeatureFlags.Evaluations do multiple flags and enrich captured events from the same fetch — without paying the cost of one round-trip per flag. - The struct itself is a plain immutable map of flag key to - `PostHog.FeatureFlags.Result`. Functions in this module are pure: they query - or filter a snapshot but never mutate it. + Each snapshot owns a small `Agent` linked to the calling process that tracks + which flags were accessed via `enabled?/2`, `get_flag/2`, and + `get_flag_payload/2`. The Agent exits with the calling process — no manual + cleanup is required. ## Querying Use `enabled?/2`, `get_flag/2`, and `get_flag_payload/2` to read individual flags. `enabled?/2` and `get_flag/2` fire a `$feature_flag_called` event with full metadata (id, version, reason, request_id) on each call; - `get_flag_payload/2` does not fire an event. + `get_flag_payload/2` records the access without firing an event. {:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user-123") @@ -39,10 +40,12 @@ defmodule PostHog.FeatureFlags.Evaluations do ## Filtering - Use `only/2` to narrow a snapshot to a specific list of flag keys before - calling `set_in_context/2` or `event_properties/1`. Unknown keys are dropped. + Use `only_accessed/1` to narrow a snapshot to flags accessed so far via + `enabled?/2`, `get_flag/2`, or `get_flag_payload/2`. Use `only/2` to narrow + by an explicit key list. Both return a fresh snapshot with its own access + tracker — calls on the filtered view do not back-propagate to the parent. - narrowed = PostHog.FeatureFlags.Evaluations.only(snapshot, ["new-dashboard"]) + narrowed = PostHog.FeatureFlags.Evaluations.only_accessed(snapshot) PostHog.FeatureFlags.set_in_context(narrowed) """ @@ -54,6 +57,8 @@ defmodule PostHog.FeatureFlags.Evaluations do - `:supervisor_name` - PostHog instance the snapshot was produced from; used when `enabled?/2` and `get_flag/2` fire `$feature_flag_called` events. - `:distinct_id` - resolved distinct ID the `/flags` request was made for. + `""` for the empty fallback returned when no `distinct_id` could be + resolved; events are short-circuited in that case. - `:flags` - map of flag key to `t:PostHog.FeatureFlags.Result.t/0`. - `:request_id` - request ID returned by `/flags`. - `:evaluated_at` - server-side evaluation timestamp. @@ -61,6 +66,7 @@ defmodule PostHog.FeatureFlags.Evaluations do `errorsWhileComputingFlags`. When `true`, every event fired from this snapshot includes `errors_while_computing_flags` in its `$feature_flag_error` property. + - `:accessed_pid` - pid of the Agent tracking accessed keys. """ @type t :: %__MODULE__{ supervisor_name: PostHog.supervisor_name(), @@ -68,14 +74,16 @@ defmodule PostHog.FeatureFlags.Evaluations do flags: %{String.t() => Result.t()}, request_id: String.t() | nil, evaluated_at: integer() | nil, - errors_while_computing: boolean() + errors_while_computing: boolean(), + accessed_pid: pid() } - @enforce_keys [:supervisor_name, :distinct_id, :flags] + @enforce_keys [:supervisor_name, :distinct_id, :flags, :accessed_pid] defstruct [ :supervisor_name, :distinct_id, :flags, + :accessed_pid, :request_id, :evaluated_at, errors_while_computing: false @@ -96,19 +104,34 @@ defmodule PostHog.FeatureFlags.Evaluations do flags: flags, request_id: Map.get(body, "requestId"), evaluated_at: Map.get(body, "evaluatedAt"), - errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true + errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true, + accessed_pid: start_accessed_agent() + } + end + + @doc false + @spec empty(PostHog.supervisor_name()) :: t() + def empty(supervisor_name) do + %__MODULE__{ + supervisor_name: supervisor_name, + distinct_id: "", + flags: %{}, + accessed_pid: start_accessed_agent() } end @doc """ Returns whether the named flag is enabled in this snapshot. - Returns `false` for unknown flags. Fires a `$feature_flag_called` event with - full metadata when the flag is present, or with - `$feature_flag_error: "flag_missing"` when it is not. + Returns `false` for unknown flags. Records the access. Fires a + `$feature_flag_called` event with full metadata when the flag is present, + or with `$feature_flag_error: "flag_missing"` and + `$feature_flag_response: nil` when it is not. """ @spec enabled?(t(), String.t()) :: boolean() def enabled?(%__MODULE__{} = snapshot, key) when is_binary(key) do + record_access(snapshot, key) + case fetch_and_log(snapshot, key) do {:ok, %Result{enabled: enabled}} -> enabled :error -> false @@ -118,11 +141,14 @@ defmodule PostHog.FeatureFlags.Evaluations do @doc """ Returns the variant string, the enabled boolean, or `nil` for unknown flags. - Fires a `$feature_flag_called` event with full metadata when the flag is - present, or with `$feature_flag_error: "flag_missing"` when it is not. + Records the access. Fires a `$feature_flag_called` event with full metadata + when the flag is present, or with `$feature_flag_error: "flag_missing"` and + `$feature_flag_response: nil` when it is not. """ @spec get_flag(t(), String.t()) :: String.t() | boolean() | nil def get_flag(%__MODULE__{} = snapshot, key) when is_binary(key) do + record_access(snapshot, key) + case fetch_and_log(snapshot, key) do {:ok, %Result{} = result} -> Result.value(result) :error -> nil @@ -131,12 +157,14 @@ defmodule PostHog.FeatureFlags.Evaluations do @doc """ Returns the configured payload for the flag, or `nil` for unknown flags or - flags without a payload. + flags without a payload. Records the access. Does **not** fire a `$feature_flag_called` event. """ @spec get_flag_payload(t(), String.t()) :: any() | nil - def get_flag_payload(%__MODULE__{flags: flags}, key) when is_binary(key) do + def get_flag_payload(%__MODULE__{flags: flags} = snapshot, key) when is_binary(key) do + record_access(snapshot, key) + case Map.fetch(flags, key) do {:ok, %Result{payload: payload}} -> payload :error -> nil @@ -149,14 +177,45 @@ defmodule PostHog.FeatureFlags.Evaluations do @spec keys(t()) :: [String.t()] def keys(%__MODULE__{flags: flags}), do: flags |> Map.keys() |> Enum.sort() + @doc """ + Returns the sorted list of keys accessed via `enabled?/2`, `get_flag/2`, or + `get_flag_payload/2` on this snapshot. + + Includes keys that were accessed but absent from the snapshot. + """ + @spec accessed(t()) :: [String.t()] + def accessed(%__MODULE__{accessed_pid: pid}) do + pid |> Agent.get(& &1) |> MapSet.to_list() |> Enum.sort() + end + + @doc """ + Returns a copy of the snapshot scoped to the flags accessed so far via + `enabled?/2`, `get_flag/2`, or `get_flag_payload/2`. + + Returns an empty snapshot when nothing has been accessed yet — including + no flags would be more surprising than helpful, since the caller asked for + "only what I touched." + + The returned snapshot has its own access tracker. Calls on it do not + back-propagate to the parent. + """ + @spec only_accessed(t()) :: t() + def only_accessed(%__MODULE__{flags: flags} = snapshot) do + accessed_set = MapSet.new(accessed(snapshot)) + keep = flags |> Map.keys() |> Enum.filter(&MapSet.member?(accessed_set, &1)) + clone_with(snapshot, Map.take(flags, keep)) + end + @doc """ Returns a copy of the snapshot scoped to the given keys. Unknown keys are - silently dropped — the resulting snapshot contains only the intersection of - the requested keys with the snapshot's keys. + silently dropped. + + The returned snapshot has its own access tracker. Calls on it do not + back-propagate to the parent. """ @spec only(t(), [String.t()]) :: t() def only(%__MODULE__{flags: flags} = snapshot, keys) when is_list(keys) do - %{snapshot | flags: Map.take(flags, keys)} + clone_with(snapshot, Map.take(flags, keys)) end @doc """ @@ -188,6 +247,19 @@ defmodule PostHog.FeatureFlags.Evaluations do end end + defp start_accessed_agent do + {:ok, pid} = Agent.start_link(fn -> MapSet.new() end) + pid + end + + defp record_access(%__MODULE__{accessed_pid: pid}, key) do + Agent.update(pid, &MapSet.put(&1, key)) + end + + defp clone_with(%__MODULE__{} = snapshot, flags) do + %{snapshot | flags: flags, accessed_pid: start_accessed_agent()} + end + defp fetch_and_log(%__MODULE__{flags: flags} = snapshot, key) do case Map.fetch(flags, key) do {:ok, %Result{} = result} -> diff --git a/test/posthog/feature_flags/evaluations_test.exs b/test/posthog/feature_flags/evaluations_test.exs index 04f41bd..c62e342 100644 --- a/test/posthog/feature_flags/evaluations_test.exs +++ b/test/posthog/feature_flags/evaluations_test.exs @@ -120,8 +120,10 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do assert {:ok, %Evaluations{distinct_id: "from-context"}} = FeatureFlags.evaluate_flags() end - test "returns an error when distinct_id cannot be resolved" do - assert {:error, %PostHog.Error{}} = FeatureFlags.evaluate_flags(nil) + test "returns an empty snapshot when distinct_id cannot be resolved" do + assert {:ok, %Evaluations{distinct_id: "", flags: %{}}} = + FeatureFlags.evaluate_flags(nil) + assert all_captured() == [] end @@ -162,7 +164,7 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do distinct_id: "foo", properties: %{ "$feature_flag": "unknown-flag", - "$feature_flag_response": false, + "$feature_flag_response": nil, "$feature_flag_error": "flag_missing" } } @@ -188,7 +190,7 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do distinct_id: "foo", properties: %{ "$feature_flag": "unknown-flag", - "$feature_flag_response": false, + "$feature_flag_response": nil, "$feature_flag_error": "flag_missing" } } @@ -202,17 +204,19 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do %{ event: "$feature_flag_called", distinct_id: "foo", - properties: %{ - "$feature_flag": "variant-flag", - "$feature_flag_response": "control", - "$feature_flag_id": 2, - "$feature_flag_version": 5, - "$feature_flag_reason": %{"code" => "condition_match"}, - "$feature_flag_request_id": "req-abc", - "$feature_flag_evaluated_at": 1_700_000_000 - } + properties: properties } ] = all_captured() + + assert properties[:"$feature_flag"] == "variant-flag" + assert properties[:"$feature_flag_response"] == "control" + assert properties[:"$feature_flag_id"] == 2 + assert properties[:"$feature_flag_version"] == 5 + assert %{"code" => "condition_match"} = properties[:"$feature_flag_reason"] + assert properties[:"$feature_flag_request_id"] == "req-abc" + assert properties[:"$feature_flag_evaluated_at"] == 1_700_000_000 + assert properties[:"$feature_flag_payload"] == %{"copy" => "hi"} + assert properties["$feature/variant-flag"] == "control" end test "fires on every access (no dedup in this PR)", %{snapshot: snapshot} do @@ -254,6 +258,60 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do end end + describe "only_accessed/1" do + setup do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + %{snapshot: snapshot} + end + + test "narrows the snapshot to flags accessed via enabled?/2", %{snapshot: snapshot} do + Evaluations.enabled?(snapshot, "boolean-flag") + narrowed = Evaluations.only_accessed(snapshot) + assert Evaluations.keys(narrowed) == ["boolean-flag"] + end + + test "narrows the snapshot to flags accessed via get_flag/2", %{snapshot: snapshot} do + Evaluations.get_flag(snapshot, "variant-flag") + narrowed = Evaluations.only_accessed(snapshot) + assert Evaluations.keys(narrowed) == ["variant-flag"] + end + + test "narrows the snapshot to flags accessed via get_flag_payload/2", %{snapshot: snapshot} do + Evaluations.get_flag_payload(snapshot, "variant-flag") + narrowed = Evaluations.only_accessed(snapshot) + assert Evaluations.keys(narrowed) == ["variant-flag"] + end + + test "returns an empty snapshot when nothing has been accessed", %{snapshot: snapshot} do + narrowed = Evaluations.only_accessed(snapshot) + assert Evaluations.keys(narrowed) == [] + end + + test "filtered snapshot does not back-propagate access to the parent", + %{snapshot: snapshot} do + Evaluations.enabled?(snapshot, "boolean-flag") + narrowed = Evaluations.only_accessed(snapshot) + + Evaluations.enabled?(narrowed, "boolean-flag") + Evaluations.get_flag(narrowed, "variant-flag") + + assert Evaluations.accessed(snapshot) == ["boolean-flag"] + end + + test "drops keys that were accessed but absent from the snapshot", + %{snapshot: snapshot} do + Evaluations.enabled?(snapshot, "boolean-flag") + Evaluations.enabled?(snapshot, "missing-flag") + + narrowed = Evaluations.only_accessed(snapshot) + assert Evaluations.keys(narrowed) == ["boolean-flag"] + end + end + describe "only/2" do setup do expect(API.Mock, :request, fn _client, _method, _url, _opts -> @@ -309,14 +367,19 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do end test "omits $active_feature_flags when no flag is enabled" do - snapshot = %Evaluations{ - supervisor_name: PostHog, - distinct_id: "foo", - flags: %{ - "off" => %Result{key: "off", enabled: false} - } - } + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{ + "off" => %{"enabled" => false, "key" => "off"} + } + } + }} + end) + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") properties = Evaluations.event_properties(snapshot) refute Map.has_key?(properties, :"$active_feature_flags") @@ -374,6 +437,62 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do end end + describe "payload normalization" do + test "JSON-decodes string payloads from the response" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{ + "json-flag" => %{ + "enabled" => true, + "key" => "json-flag", + "metadata" => %{"payload" => ~s({"copy":"hi","count":3})} + } + } + } + }} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + + assert Evaluations.get_flag_payload(snapshot, "json-flag") == + %{"copy" => "hi", "count" => 3} + end + + test "leaves non-JSON string payloads as-is" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{ + "string-flag" => %{ + "enabled" => true, + "key" => "string-flag", + "metadata" => %{"payload" => "not json"} + } + } + } + }} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + + assert Evaluations.get_flag_payload(snapshot, "string-flag") == "not json" + end + + test "leaves nil payloads as nil" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, stub_flags_response()} + end) + + {:ok, snapshot} = FeatureFlags.evaluate_flags("foo") + assert Evaluations.get_flag_payload(snapshot, "boolean-flag") == nil + end + end + describe "errors_while_computing propagation" do defp errored_flags_response do response = stub_flags_response() @@ -433,18 +552,18 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do end end - describe "empty distinct_id" do - test "manually-constructed snapshot with empty distinct_id does not fire events" do - snapshot = %Evaluations{ - supervisor_name: PostHog, - distinct_id: "", - flags: %{ - "flag" => %Result{key: "flag", enabled: true} - } - } + describe "empty snapshot fallback" do + test "evaluate_flags(nil) returns an empty snapshot, not an error" do + assert {:ok, %Evaluations{distinct_id: "", flags: %{}}} = + FeatureFlags.evaluate_flags(nil) + end + + test "empty snapshot does not fire events when accessed" do + {:ok, snapshot} = FeatureFlags.evaluate_flags(nil) - Evaluations.enabled?(snapshot, "flag") - Evaluations.get_flag(snapshot, "flag") + Evaluations.enabled?(snapshot, "any-flag") + Evaluations.get_flag(snapshot, "any-flag") + Evaluations.get_flag_payload(snapshot, "any-flag") assert all_captured() == [] end