Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/steady-falcon-vaeinoe.md
Original file line number Diff line number Diff line change
@@ -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).
11 changes: 11 additions & 0 deletions lib/posthog/feature_flags.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/posthog/feature_flags/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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()
}

Expand All @@ -80,6 +85,7 @@ defmodule PostHog.FeatureFlags.Result do
:reason,
:request_id,
:evaluated_at,
:has_experiment,
errors_while_computing: false
]

Expand Down
38 changes: 33 additions & 5 deletions sdk_compliance_adapter/lib/sdk_compliance_adapter/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 ->
Expand Down
24 changes: 23 additions & 1 deletion test/posthog/feature_flags/evaluations_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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" => %{
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions test/posthog/feature_flags/result_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 78 additions & 2 deletions test/posthog/feature_flags_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}
},
Expand All @@ -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 ->
Expand Down
Loading