diff --git a/.sampo/changesets/steady-falcon-vaeinoe.md b/.sampo/changesets/steady-falcon-vaeinoe.md new file mode 100644 index 0000000..a1ab283 --- /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. 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 33d4ba2..cc9a679 100644 --- a/lib/posthog/feature_flags.ex +++ b/lib/posthog/feature_flags.ex @@ -501,10 +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: 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. @@ -618,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 51aae26..38ccb09 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. `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"` @@ -66,6 +70,7 @@ defmodule PostHog.FeatureFlags.Result do reason: map() | nil, request_id: String.t() | nil, evaluated_at: integer() | nil, + has_experiment: boolean() | nil, errors_while_computing: boolean() } @@ -80,6 +85,7 @@ defmodule PostHog.FeatureFlags.Result do :reason, :request_id, :evaluated_at, + :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 9bf8cba..1c49382 100644 --- a/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex +++ b/sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex @@ -165,15 +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/#{key}" => value - } + properties ) SdkComplianceAdapter.State.increment_events_captured() @@ -277,6 +284,27 @@ defmodule SdkComplianceAdapter.Router do defp extract_flag_value(_flags, _key), do: 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) -> + case get_in(flag_data, ["metadata", "has_experiment"]) do + value when is_boolean(value) -> value + _ -> nil + end + + _ -> + nil + end + end + + 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 nil -> diff --git a/test/posthog/feature_flags/evaluations_test.exs b/test/posthog/feature_flags/evaluations_test.exs index 85d7ad8..d117d2c 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,26 @@ 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 "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: 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 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..093e04b 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 == nil end end diff --git a/test/posthog/feature_flags_test.exs b/test/posthog/feature_flags_test.exs index 807e9b3..61b7e1c 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,83 @@ 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 "omits $feature_flag_has_experiment 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: 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"} + } + } + } + }} + 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] test "custom PostHog instance" do expect(API.Mock, :request, fn client, method, url, opts ->