From 8bf44efc1d07a14de302e6ba6ae3153ec14bfc49 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 15 Jul 2026 09:12:06 -0700 Subject: [PATCH 1/2] Add $feature_flag_has_experiment to $feature_flag_called events --- .sampo/changesets/steady-falcon-vaeinoe.md | 5 ++ lib/posthog/feature_flags.ex | 4 +- lib/posthog/feature_flags/result.ex | 6 ++ .../lib/sdk_compliance_adapter/router.ex | 15 +++++ .../feature_flags/evaluations_test.exs | 20 +++++- test/posthog/feature_flags/result_test.exs | 1 + test/posthog/feature_flags_test.exs | 62 ++++++++++++++++++- 7 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 .sampo/changesets/steady-falcon-vaeinoe.md diff --git a/.sampo/changesets/steady-falcon-vaeinoe.md b/.sampo/changesets/steady-falcon-vaeinoe.md new file mode 100644 index 0000000..2a90fcd --- /dev/null +++ b/.sampo/changesets/steady-falcon-vaeinoe.md @@ -0,0 +1,5 @@ +--- +hex/posthog: minor +--- + +Add a `$feature_flag_has_experiment` boolean property to `$feature_flag_called` events, sourced from the `has_experiment` field in the `/flags` response metadata. Defaults to `false` when the server does not report it. diff --git a/lib/posthog/feature_flags.ex b/lib/posthog/feature_flags.ex index 33d4ba2..8fe3ad8 100644 --- a/lib/posthog/feature_flags.ex +++ b/lib/posthog/feature_flags.ex @@ -501,6 +501,7 @@ defmodule PostHog.FeatureFlags do reason: Map.get(flag_data, "reason"), request_id: Map.get(body, "requestId"), evaluated_at: Map.get(body, "evaluatedAt"), + has_experiment: get_in(flag_data, ["metadata", "has_experiment"]) == true, errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true } end @@ -610,7 +611,8 @@ defmodule PostHog.FeatureFlags do "$feature/#{result.key}" => value, :distinct_id => distinct_id, :"$feature_flag" => result.key, - :"$feature_flag_response" => value + :"$feature_flag_response" => value, + :"$feature_flag_has_experiment" => result.has_experiment } |> maybe_put(:"$feature_flag_id", result.id) |> maybe_put(:"$feature_flag_version", result.version) diff --git a/lib/posthog/feature_flags/result.ex b/lib/posthog/feature_flags/result.ex index 51aae26..30b7cb1 100644 --- a/lib/posthog/feature_flags/result.ex +++ b/lib/posthog/feature_flags/result.ex @@ -13,6 +13,10 @@ 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 + - `has_experiment` - Whether the flag is linked to an experiment. Defaults to + `false` when the server does not report the field (older deployments). + Forwarded as `$feature_flag_has_experiment` on `$feature_flag_called` + events. - `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"` @@ -66,6 +70,7 @@ defmodule PostHog.FeatureFlags.Result do reason: map() | nil, request_id: String.t() | nil, evaluated_at: integer() | nil, + has_experiment: boolean(), errors_while_computing: boolean() } @@ -80,6 +85,7 @@ defmodule PostHog.FeatureFlags.Result do :reason, :request_id, :evaluated_at, + has_experiment: false, errors_while_computing: false ] diff --git a/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex b/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex index 9bf8cba..69812b1 100644 --- a/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex +++ b/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex @@ -172,6 +172,7 @@ defmodule SdkComplianceAdapter.Router do %{ "$feature_flag" => key, "$feature_flag_response" => value, + "$feature_flag_has_experiment" => extract_has_experiment(flags, key), "$feature/#{key}" => value } ) @@ -277,6 +278,20 @@ defmodule SdkComplianceAdapter.Router do defp extract_flag_value(_flags, _key), do: false + # Reads has_experiment from the flag's v2 metadata. Legacy featureFlags + # entries are bare values without metadata, so they default to false. + defp extract_has_experiment(flags, key) when is_map(flags) do + case Map.get(flags, key) do + flag_data when is_map(flag_data) -> + get_in(flag_data, ["metadata", "has_experiment"]) == true + + _ -> + false + end + end + + defp extract_has_experiment(_flags, _key), do: false + defp stop_posthog do case Process.whereis(SdkComplianceAdapter.PostHog) do nil -> diff --git a/test/posthog/feature_flags/evaluations_test.exs b/test/posthog/feature_flags/evaluations_test.exs index 85d7ad8..8f384aa 100644 --- a/test/posthog/feature_flags/evaluations_test.exs +++ b/test/posthog/feature_flags/evaluations_test.exs @@ -30,7 +30,12 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do "enabled" => true, "key" => "variant-flag", "variant" => "control", - "metadata" => %{"id" => 2, "version" => 5, "payload" => %{"copy" => "hi"}}, + "metadata" => %{ + "id" => 2, + "version" => 5, + "payload" => %{"copy" => "hi"}, + "has_experiment" => true + }, "reason" => %{"code" => "condition_match", "description" => "matched"} }, "disabled-flag" => %{ @@ -221,9 +226,22 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do 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_flag_has_experiment"] == true assert properties["$feature/variant-flag"] == "control" end + test "fires $feature_flag_has_experiment: false when the response omits has_experiment", + %{snapshot: snapshot} do + assert Evaluations.enabled?(snapshot, "boolean-flag") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{"$feature_flag_has_experiment": false} + } + ] = all_captured() + end + test "dedupes repeated access for the same flag value", %{snapshot: snapshot} do Evaluations.enabled?(snapshot, "boolean-flag") Evaluations.enabled?(snapshot, "boolean-flag") diff --git a/test/posthog/feature_flags/result_test.exs b/test/posthog/feature_flags/result_test.exs index da6dfec..8c28687 100644 --- a/test/posthog/feature_flags/result_test.exs +++ b/test/posthog/feature_flags/result_test.exs @@ -25,6 +25,7 @@ defmodule PostHog.FeatureFlags.ResultTest do assert result.enabled == false assert result.variant == nil assert result.payload == nil + assert result.has_experiment == false end end diff --git a/test/posthog/feature_flags_test.exs b/test/posthog/feature_flags_test.exs index 807e9b3..ab368e3 100644 --- a/test/posthog/feature_flags_test.exs +++ b/test/posthog/feature_flags_test.exs @@ -348,7 +348,12 @@ defmodule PostHog.FeatureFlagsTest do "myflag" => %{ "enabled" => true, "variant" => "variant1", - "metadata" => %{"id" => 42, "version" => 7, "payload" => nil}, + "metadata" => %{ + "id" => 42, + "version" => 7, + "payload" => nil, + "has_experiment" => true + }, "reason" => %{"code" => "condition_match"} } }, @@ -371,12 +376,65 @@ defmodule PostHog.FeatureFlagsTest do "$feature_flag_version": 7, "$feature_flag_reason": %{"code" => "condition_match"}, "$feature_flag_request_id": "req-xyz", - "$feature_flag_evaluated_at": 1_700_000_000 + "$feature_flag_evaluated_at": 1_700_000_000, + "$feature_flag_has_experiment": true } } ] = all_captured() end + test "sets $feature_flag_has_experiment: false when the response reports it as false" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{ + "myflag" => %{ + "enabled" => true, + "metadata" => %{"id" => 42, "version" => 7, "has_experiment" => false} + } + } + } + }} + end) + + assert {:ok, true} = FeatureFlags.check("myflag", "foo") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{"$feature_flag_has_experiment": false} + } + ] = all_captured() + end + + test "sets $feature_flag_has_experiment: false when the response omits has_experiment" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{ + "myflag" => %{ + "enabled" => true, + "metadata" => %{"id" => 42, "version" => 7} + } + } + } + }} + end) + + assert {:ok, true} = FeatureFlags.check("myflag", "foo") + + assert [ + %{ + event: "$feature_flag_called", + properties: %{"$feature_flag_has_experiment": false} + } + ] = all_captured() + end + @tag config: [supervisor_name: MyPostHog] test "custom PostHog instance" do expect(API.Mock, :request, fn client, method, url, opts -> From f3972668ee6549c5df41af867c05f457747ed65c Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 15 Jul 2026 12:38:34 -0700 Subject: [PATCH 2/2] Omit $feature_flag_has_experiment when the server does not report it Claude-Session: https://claude.ai/code/session_01QLf4hBzmodKFt8V7jRf8bx --- .sampo/changesets/steady-falcon-vaeinoe.md | 2 +- lib/posthog/feature_flags.ex | 15 ++++++-- lib/posthog/feature_flags/result.ex | 12 +++---- .../lib/sdk_compliance_adapter/router.ex | 35 +++++++++++++------ .../feature_flags/evaluations_test.exs | 18 ++++++---- test/posthog/feature_flags/result_test.exs | 2 +- test/posthog/feature_flags_test.exs | 30 ++++++++++++---- 7 files changed, 79 insertions(+), 35 deletions(-) diff --git a/.sampo/changesets/steady-falcon-vaeinoe.md b/.sampo/changesets/steady-falcon-vaeinoe.md index 2a90fcd..a1ab283 100644 --- a/.sampo/changesets/steady-falcon-vaeinoe.md +++ b/.sampo/changesets/steady-falcon-vaeinoe.md @@ -2,4 +2,4 @@ hex/posthog: minor --- -Add a `$feature_flag_has_experiment` boolean property to `$feature_flag_called` events, sourced from the `has_experiment` field in the `/flags` response metadata. Defaults to `false` when the server does not report it. +Add a `$feature_flag_has_experiment` boolean property to `$feature_flag_called` events, sourced from the `has_experiment` field in the `/flags` response metadata. The property is only sent when the server explicitly reports the field; it is omitted when unknown (older deployments). diff --git a/lib/posthog/feature_flags.ex b/lib/posthog/feature_flags.ex index 8fe3ad8..cc9a679 100644 --- a/lib/posthog/feature_flags.ex +++ b/lib/posthog/feature_flags.ex @@ -501,11 +501,20 @@ defmodule PostHog.FeatureFlags do reason: Map.get(flag_data, "reason"), request_id: Map.get(body, "requestId"), evaluated_at: Map.get(body, "evaluatedAt"), - has_experiment: get_in(flag_data, ["metadata", "has_experiment"]) == true, + has_experiment: parse_has_experiment(flag_data), errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true } end + # `nil` means the server did not report the field (older deployments); the + # `$feature_flag_has_experiment` property is omitted in that case. + defp parse_has_experiment(flag_data) do + case get_in(flag_data, ["metadata", "has_experiment"]) do + value when is_boolean(value) -> value + _ -> nil + end + 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. @@ -611,8 +620,7 @@ defmodule PostHog.FeatureFlags do "$feature/#{result.key}" => value, :distinct_id => distinct_id, :"$feature_flag" => result.key, - :"$feature_flag_response" => value, - :"$feature_flag_has_experiment" => result.has_experiment + :"$feature_flag_response" => value } |> maybe_put(:"$feature_flag_id", result.id) |> maybe_put(:"$feature_flag_version", result.version) @@ -620,6 +628,7 @@ defmodule PostHog.FeatureFlags do |> 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_has_experiment", result.has_experiment) |> maybe_put(:"$feature_flag_error", errors) if PostHog.FeatureFlags.CalledCache.first_seen?(name, distinct_id, result.key, value) do diff --git a/lib/posthog/feature_flags/result.ex b/lib/posthog/feature_flags/result.ex index 30b7cb1..38ccb09 100644 --- a/lib/posthog/feature_flags/result.ex +++ b/lib/posthog/feature_flags/result.ex @@ -13,10 +13,10 @@ 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 - - `has_experiment` - Whether the flag is linked to an experiment. Defaults to - `false` when the server does not report the field (older deployments). - Forwarded as `$feature_flag_has_experiment` on `$feature_flag_called` - events. + - `has_experiment` - Whether the flag is linked to an experiment. `nil` when + the server does not report the field (older deployments). Forwarded as + `$feature_flag_has_experiment` on `$feature_flag_called` events only when + the server reported it. - `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"` @@ -70,7 +70,7 @@ defmodule PostHog.FeatureFlags.Result do reason: map() | nil, request_id: String.t() | nil, evaluated_at: integer() | nil, - has_experiment: boolean(), + has_experiment: boolean() | nil, errors_while_computing: boolean() } @@ -85,7 +85,7 @@ defmodule PostHog.FeatureFlags.Result do :reason, :request_id, :evaluated_at, - has_experiment: false, + :has_experiment, errors_while_computing: false ] diff --git a/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex b/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex index 69812b1..1c49382 100644 --- a/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex +++ b/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex @@ -165,16 +165,22 @@ defmodule SdkComplianceAdapter.Router do flags = Map.get(resp_body, "featureFlags") || Map.get(resp_body, "flags") || %{} value = extract_flag_value(flags, key) + properties = + maybe_put( + %{ + "$feature_flag" => key, + "$feature_flag_response" => value, + "$feature/#{key}" => value + }, + "$feature_flag_has_experiment", + extract_has_experiment(flags, key) + ) + PostHog.bare_capture( SdkComplianceAdapter.PostHog, "$feature_flag_called", distinct_id, - %{ - "$feature_flag" => key, - "$feature_flag_response" => value, - "$feature_flag_has_experiment" => extract_has_experiment(flags, key), - "$feature/#{key}" => value - } + properties ) SdkComplianceAdapter.State.increment_events_captured() @@ -278,19 +284,26 @@ defmodule SdkComplianceAdapter.Router do defp extract_flag_value(_flags, _key), do: false - # Reads has_experiment from the flag's v2 metadata. Legacy featureFlags - # entries are bare values without metadata, so they default to false. + # Reads has_experiment from the flag's v2 metadata. Returns nil when the + # server did not report it (legacy featureFlags entries are bare values + # without metadata); the property is omitted in that case. defp extract_has_experiment(flags, key) when is_map(flags) do case Map.get(flags, key) do flag_data when is_map(flag_data) -> - get_in(flag_data, ["metadata", "has_experiment"]) == true + case get_in(flag_data, ["metadata", "has_experiment"]) do + value when is_boolean(value) -> value + _ -> nil + end _ -> - false + nil end end - defp extract_has_experiment(_flags, _key), do: false + defp extract_has_experiment(_flags, _key), do: nil + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) defp stop_posthog do case Process.whereis(SdkComplianceAdapter.PostHog) do diff --git a/test/posthog/feature_flags/evaluations_test.exs b/test/posthog/feature_flags/evaluations_test.exs index 8f384aa..d117d2c 100644 --- a/test/posthog/feature_flags/evaluations_test.exs +++ b/test/posthog/feature_flags/evaluations_test.exs @@ -230,16 +230,20 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do assert properties["$feature/variant-flag"] == "control" end - test "fires $feature_flag_has_experiment: false when the response omits has_experiment", + test "omits $feature_flag_has_experiment when the response omits has_experiment", %{snapshot: snapshot} do assert Evaluations.enabled?(snapshot, "boolean-flag") - assert [ - %{ - event: "$feature_flag_called", - properties: %{"$feature_flag_has_experiment": false} - } - ] = all_captured() + assert [%{event: "$feature_flag_called", properties: properties}] = all_captured() + refute Map.has_key?(properties, :"$feature_flag_has_experiment") + end + + test "omits $feature_flag_has_experiment on flag_missing events", %{snapshot: snapshot} do + assert Evaluations.enabled?(snapshot, "unknown-flag") == false + + assert [%{event: "$feature_flag_called", properties: properties}] = all_captured() + assert properties[:"$feature_flag_error"] == "flag_missing" + refute Map.has_key?(properties, :"$feature_flag_has_experiment") end test "dedupes repeated access for the same flag value", %{snapshot: snapshot} do diff --git a/test/posthog/feature_flags/result_test.exs b/test/posthog/feature_flags/result_test.exs index 8c28687..093e04b 100644 --- a/test/posthog/feature_flags/result_test.exs +++ b/test/posthog/feature_flags/result_test.exs @@ -25,7 +25,7 @@ defmodule PostHog.FeatureFlags.ResultTest do assert result.enabled == false assert result.variant == nil assert result.payload == nil - assert result.has_experiment == false + assert result.has_experiment == nil end end diff --git a/test/posthog/feature_flags_test.exs b/test/posthog/feature_flags_test.exs index ab368e3..61b7e1c 100644 --- a/test/posthog/feature_flags_test.exs +++ b/test/posthog/feature_flags_test.exs @@ -409,7 +409,7 @@ defmodule PostHog.FeatureFlagsTest do ] = all_captured() end - test "sets $feature_flag_has_experiment: false when the response omits has_experiment" do + test "omits $feature_flag_has_experiment when the response omits has_experiment" do expect(API.Mock, :request, fn _client, _method, _url, _opts -> {:ok, %{ @@ -427,12 +427,30 @@ defmodule PostHog.FeatureFlagsTest do assert {:ok, true} = FeatureFlags.check("myflag", "foo") - assert [ - %{ - event: "$feature_flag_called", - properties: %{"$feature_flag_has_experiment": false} + assert [%{event: "$feature_flag_called", properties: properties}] = all_captured() + refute Map.has_key?(properties, :"$feature_flag_has_experiment") + end + + test "omits $feature_flag_has_experiment when the response reports a non-boolean value" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, + %{ + status: 200, + body: %{ + "flags" => %{ + "myflag" => %{ + "enabled" => true, + "metadata" => %{"id" => 42, "has_experiment" => "yes"} } - ] = all_captured() + } + } + }} + end) + + assert {:ok, true} = FeatureFlags.check("myflag", "foo") + + assert [%{event: "$feature_flag_called", properties: properties}] = all_captured() + refute Map.has_key?(properties, :"$feature_flag_has_experiment") end @tag config: [supervisor_name: MyPostHog]