Skip to content
Open
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/mellow-ibex-sointu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
hex/posthog: minor
---

Send minimal `$feature_flag_called` events when the `/flags` response carries the server-controlled `minimalFlagCalledEvents` gate and the evaluated flag reports `has_experiment: false`. Minimal events keep only an allowlisted set of properties (flag identity, evaluation metadata, `$groups`, `$process_person_profile`, `$lib`, `$lib_version`, `$is_server`); everything else, including context and global properties, is stripped. Experiment-linked flags and responses without the gate keep the full event shape.
16 changes: 11 additions & 5 deletions lib/posthog.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,17 @@ defmodule PostHog do
@spec bare_capture(supervisor_name(), event(), distinct_id(), properties()) :: :ok
def bare_capture(name \\ __MODULE__, event, distinct_id, properties \\ %{}) do
config = PostHog.Registry.config(name)
capture_prepared(config, event, distinct_id, Map.merge(properties, config.global_properties))
end

properties =
properties
|> Map.merge(config.global_properties)
|> LoggerJSON.Formatter.RedactorEncoder.encode([])
# Captures an event whose properties are already final: no context or global
# properties are merged in. Used for minimal $feature_flag_called events,
# whose allowlisted shape must not be re-enriched. Takes an already-fetched
# config to avoid a second registry lookup on the capture hot path.
@doc false
@spec capture_prepared(PostHog.Config.config(), event(), distinct_id(), properties()) :: :ok
def capture_prepared(config, event, distinct_id, properties) do
properties = LoggerJSON.Formatter.RedactorEncoder.encode(properties, [])

event = %{
event: event,
Expand All @@ -84,7 +90,7 @@ defmodule PostHog do

case run_before_send(config.before_send, event) do
nil -> :ok
event -> PostHog.Sender.send(event, name)
event -> PostHog.Sender.send(event, config.supervisor_name)
end
end

Expand Down
58 changes: 56 additions & 2 deletions lib/posthog/feature_flags.ex
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ defmodule PostHog.FeatureFlags do
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
errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true,
minimal_flag_called_events: Map.get(body, "minimalFlagCalledEvents") == true
}
end

Expand Down Expand Up @@ -632,7 +633,7 @@ defmodule PostHog.FeatureFlags do
|> maybe_put(:"$feature_flag_error", errors)

if PostHog.FeatureFlags.CalledCache.first_seen?(name, distinct_id, result.key, value) do
PostHog.capture(name, "$feature_flag_called", properties)
capture_called_event(name, distinct_id, result, properties)
end

if flag_missing? do
Expand All @@ -642,6 +643,59 @@ defmodule PostHog.FeatureFlags do
end
end

# Strict allowlist for minimal $feature_flag_called events, per the
# cross-SDK contract. Both atom and string forms are kept because context
# and global properties may use either key type.
@minimal_event_property_atoms [
:"$feature_flag",
:"$feature_flag_response",
:"$feature_flag_has_experiment",
:"$feature_flag_id",
:"$feature_flag_version",
:"$feature_flag_reason",
:"$feature_flag_request_id",
:"$feature_flag_evaluated_at",
:"$feature_flag_error",
:"$groups",
:"$process_person_profile",
:"$lib",
:"$lib_version",
:"$is_server"
]
@minimal_event_properties @minimal_event_property_atoms ++
Enum.map(@minimal_event_property_atoms, &Atom.to_string/1)

# Sends the minimal allowlisted event only when the server gate is on and
# the flag is known not to be linked to an experiment. Any missing signal
# (gate absent, has_experiment unknown) falls back to the full legacy event.
defp capture_called_event(name, distinct_id, %__MODULE__.Result{} = result, properties) do
if result.minimal_flag_called_events and result.has_experiment == false do
capture_minimal_called_event(name, distinct_id, properties)
else
PostHog.capture(name, "$feature_flag_called", properties)
end
end

# Assembles properties the same way capture/3 and bare_capture/4 would
# (context first, then global properties), then keeps only the allowlisted
# ones so the minimal shape stays predictable regardless of context tags or
# customer global properties.
defp capture_minimal_called_event(name, distinct_id, properties) do
config = PostHog.config(name)

# before_send still runs after this projection and may re-inflate the
# event. That's the accepted customer escape hatch; the SDK itself must
# not enrich the event after the allowlist.
minimal_properties =
name
|> PostHog.get_event_context("$feature_flag_called")
|> Map.merge(properties)
|> Map.merge(config.global_properties)
|> Map.take(@minimal_event_properties)

PostHog.capture_prepared(config, "$feature_flag_called", distinct_id, minimal_properties)
end

defp build_error_codes(%__MODULE__.Result{errors_while_computing: true}, extra),
do: ["errors_while_computing_flags" | extra] |> Enum.join(",")

Expand Down
11 changes: 9 additions & 2 deletions lib/posthog/feature_flags/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ defmodule PostHog.FeatureFlags.Result do
`errorsWhileComputingFlags`; values for some flags may be incomplete or
stale. Forwarded as `$feature_flag_error: "errors_while_computing_flags"`
on `$feature_flag_called` events.
- `minimal_flag_called_events` - Whether the response signaled the top-level
`minimalFlagCalledEvents` gate. When `true` and `has_experiment` is
explicitly `false`, `$feature_flag_called` events for this flag are sent
with a minimal, allowlisted property shape. `false` whenever the server
did not report the gate.

The metadata fields are populated when the `/flags` response includes them
and are forwarded as `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`,
Expand Down Expand Up @@ -71,7 +76,8 @@ defmodule PostHog.FeatureFlags.Result do
request_id: String.t() | nil,
evaluated_at: integer() | nil,
has_experiment: boolean() | nil,
errors_while_computing: boolean()
errors_while_computing: boolean(),
minimal_flag_called_events: boolean()
}

@enforce_keys [:key, :enabled]
Expand All @@ -86,7 +92,8 @@ defmodule PostHog.FeatureFlags.Result do
:request_id,
:evaluated_at,
:has_experiment,
errors_while_computing: false
errors_while_computing: false,
minimal_flag_called_events: false
]

@doc """
Expand Down
69 changes: 69 additions & 0 deletions test/posthog/feature_flags/evaluations_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -613,4 +613,73 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do
assert all_captured() == []
end
end

describe "minimal flag called events gate" do
setup do
expect(API.Mock, :request, fn _client, _method, _url, _opts ->
{:ok,
%{
status: 200,
body: %{
"flags" => %{
"plain-flag" => %{
"enabled" => true,
"metadata" => %{"id" => 1, "version" => 2, "has_experiment" => false}
},
"experiment-flag" => %{
"enabled" => true,
"variant" => "control",
"metadata" => %{"id" => 2, "version" => 5, "has_experiment" => true}
}
},
"requestId" => "req-abc",
"evaluatedAt" => 1_700_000_000,
"minimalFlagCalledEvents" => true
}
}}
end)

{:ok, snapshot} = FeatureFlags.evaluate_flags("foo")
%{snapshot: snapshot}
end

test "events fired from the snapshot respect the gate per flag", %{snapshot: snapshot} do
assert Evaluations.enabled?(snapshot, "plain-flag") == true
assert Evaluations.get_flag(snapshot, "experiment-flag") == "control"

events = all_captured()
assert length(events) == 2

assert %{event: "$feature_flag_called", distinct_id: "foo", properties: minimal} =
Enum.find(events, &(&1.properties[:"$feature_flag"] == "plain-flag"))

assert %{event: "$feature_flag_called", distinct_id: "foo", properties: full} =
Enum.find(events, &(&1.properties[:"$feature_flag"] == "experiment-flag"))

assert minimal == %{
"$feature_flag": "plain-flag",
"$feature_flag_response": true,
"$feature_flag_has_experiment": false,
"$feature_flag_id": 1,
"$feature_flag_version": 2,
"$feature_flag_request_id": "req-abc",
"$feature_flag_evaluated_at": 1_700_000_000,
"$lib": "posthog-elixir",
"$lib_version": PostHog.Lib.version(),
"$is_server": true
}

assert full["$feature/experiment-flag"] == "control"
assert full[:"$feature_flag_has_experiment"] == true
assert full[:"$is_server"] == true
end

test "missing flags fall back to the full event shape", %{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"
assert properties[:"$is_server"] == true
end
end
end
139 changes: 139 additions & 0 deletions test/posthog/feature_flags_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,145 @@ defmodule PostHog.FeatureFlagsTest do
end
end

describe "minimal $feature_flag_called events" do
defp minimal_gated_response(overrides) do
flag =
Map.merge(
%{
"enabled" => true,
"variant" => "variant1",
"metadata" => %{
"id" => 42,
"version" => 7,
"payload" => ~s({"copy": "hi"}),
"has_experiment" => false
},
"reason" => %{"code" => "condition_match"}
},
Map.get(overrides, "flag", %{})
)

body =
Map.merge(
%{
"flags" => %{"myflag" => flag},
"requestId" => "req-xyz",
"evaluatedAt" => 1_700_000_000,
"minimalFlagCalledEvents" => true
},
Map.delete(overrides, "flag")
)

{:ok, %{status: 200, body: body}}
end

@tag config: [supervisor_name: PostHog, global_properties: %{team: "growth"}]
test "sends exactly the allowlisted properties when gated and the flag has no experiment" do
expect(API.Mock, :request, fn _client, _method, _url, _opts ->
minimal_gated_response(%{"errorsWhileComputingFlags" => true})
end)

PostHog.set_context(%{
some_tag: "ctx",
"$feature/other-flag": true,
"$groups": %{company: "acme"},
"$process_person_profile": false
})

assert {:ok, "variant1"} = FeatureFlags.check("myflag", "foo")

assert [%{event: "$feature_flag_called", distinct_id: "foo", properties: properties}] =
all_captured()

assert properties == %{
"$feature_flag": "myflag",
"$feature_flag_response": "variant1",
"$feature_flag_has_experiment": false,
"$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,
"$feature_flag_error": "errors_while_computing_flags",
"$groups": %{company: "acme"},
"$process_person_profile": false,
"$lib": "posthog-elixir",
"$lib_version": PostHog.Lib.version(),
"$is_server": true
}
end

test "keeps string-keyed allowlisted context properties on minimal events" do
expect(API.Mock, :request, fn _client, _method, _url, _opts ->
minimal_gated_response(%{})
end)

PostHog.set_context(%{
"some_tag" => "ctx",
"$groups" => %{"company" => "acme"},
"$process_person_profile" => false
})

assert {:ok, "variant1"} = FeatureFlags.check("myflag", "foo")

assert [%{event: "$feature_flag_called", properties: properties}] = all_captured()
assert properties["$groups"] == %{"company" => "acme"}
assert properties["$process_person_profile"] == false
refute Map.has_key?(properties, "some_tag")
refute Map.has_key?(properties, "$feature/myflag")
end

test "sends the full event when gated but the flag has an experiment" do
expect(API.Mock, :request, fn _client, _method, _url, _opts ->
minimal_gated_response(%{"flag" => %{"metadata" => %{"has_experiment" => true}}})
end)

PostHog.set_context(%{some_tag: "ctx"})

assert {:ok, "variant1"} = FeatureFlags.check("myflag", "foo")

assert [%{event: "$feature_flag_called", properties: properties}] = all_captured()
assert properties["$feature/myflag"] == "variant1"
assert properties[:"$feature_flag_has_experiment"] == true
assert properties[:some_tag] == "ctx"
assert properties[:"$is_server"] == true
end

test "sends the full event when the gate is absent, false, or unparseable" do
for {gate_overrides, distinct_id} <- [
{%{"minimalFlagCalledEvents" => nil}, "user-absent"},
{%{"minimalFlagCalledEvents" => false}, "user-false"},
{%{"minimalFlagCalledEvents" => "yes"}, "user-unparseable"}
] do
expect(API.Mock, :request, fn _client, _method, _url, _opts ->
minimal_gated_response(gate_overrides)
end)

assert {:ok, "variant1"} = FeatureFlags.check("myflag", distinct_id)
end

events = all_captured()
assert length(events) == 3

for %{event: "$feature_flag_called", properties: properties} <- events do
assert properties["$feature/myflag"] == "variant1"
assert properties[:"$is_server"] == true
end
end

test "sends the full event when gated but has_experiment is missing" do
expect(API.Mock, :request, fn _client, _method, _url, _opts ->
minimal_gated_response(%{"flag" => %{"metadata" => %{"id" => 42, "version" => 7}}})
end)

assert {:ok, "variant1"} = FeatureFlags.check("myflag", "foo")

assert [%{event: "$feature_flag_called", properties: properties}] = all_captured()
assert properties["$feature/myflag"] == "variant1"
refute Map.has_key?(properties, :"$feature_flag_has_experiment")
end
end

describe "get_feature_flag_result/4" do
test "returns Result struct for boolean flag" do
expect(API.Mock, :request, fn _client, method, url, opts ->
Expand Down
Loading