Skip to content

Commit d21c2cb

Browse files
committed
feat: Add get_feature_flag_result, get_feature_flag_result! API
1 parent d6f6146 commit d21c2cb

File tree

4 files changed

+801
-22
lines changed

4 files changed

+801
-22
lines changed

lib/posthog/feature_flag_result.ex

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
defmodule PostHog.FeatureFlagResult do
2+
@moduledoc """
3+
Represents the result of a feature flag evaluation.
4+
5+
This struct contains all the information returned when evaluating a feature flag:
6+
- `key` - The name of the feature flag
7+
- `enabled` - Whether the flag is enabled for this user
8+
- `variant` - The variant assigned to this user (nil for boolean flags)
9+
- `payload` - The JSON payload configured for this flag/variant (nil if not set)
10+
11+
## Examples
12+
13+
# Boolean flag result
14+
%PostHog.FeatureFlagResult{
15+
key: "my-feature",
16+
enabled: true,
17+
variant: nil,
18+
payload: nil
19+
}
20+
21+
# Multivariant flag result with payload
22+
%PostHog.FeatureFlagResult{
23+
key: "my-experiment",
24+
enabled: true,
25+
variant: "control",
26+
payload: %{"button_color" => "blue"}
27+
}
28+
"""
29+
30+
@type t :: %__MODULE__{
31+
key: String.t(),
32+
enabled: boolean(),
33+
variant: String.t() | nil,
34+
payload: any()
35+
}
36+
37+
defstruct key: nil, enabled: false, variant: nil, payload: nil
38+
39+
@doc """
40+
Returns the value of the feature flag result.
41+
42+
If a variant is present, returns the variant string. Otherwise, returns the
43+
enabled boolean status. This provides backwards compatibility with existing
44+
code that expects a simple value from feature flag checks.
45+
46+
## Examples
47+
48+
iex> result = %PostHog.FeatureFlagResult{key: "flag", enabled: true, variant: "control", payload: nil}
49+
iex> PostHog.FeatureFlagResult.value(result)
50+
"control"
51+
52+
iex> result = %PostHog.FeatureFlagResult{key: "flag", enabled: true, variant: nil, payload: nil}
53+
iex> PostHog.FeatureFlagResult.value(result)
54+
true
55+
56+
iex> result = %PostHog.FeatureFlagResult{key: "flag", enabled: false, variant: nil, payload: nil}
57+
iex> PostHog.FeatureFlagResult.value(result)
58+
false
59+
"""
60+
@spec value(t()) :: boolean() | String.t()
61+
def value(%__MODULE__{variant: variant}) when not is_nil(variant), do: variant
62+
def value(%__MODULE__{enabled: enabled}), do: enabled
63+
end

lib/posthog/feature_flags.ex

Lines changed: 189 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ defmodule PostHog.FeatureFlags do
33
Convenience functions to work with Feature Flags API
44
"""
55

6+
alias PostHog.FeatureFlagResult
7+
68
@doc """
79
Make request to [`/flags`](https://posthog.com/docs/api/flags) API.
810
@@ -125,37 +127,202 @@ defmodule PostHog.FeatureFlags do
125127
@spec check(PostHog.supervisor_name(), String.t(), PostHog.distinct_id() | map() | nil) ::
126128
{:ok, boolean()} | {:ok, String.t()} | {:error, Exception.t()}
127129
def check(name \\ PostHog, flag_name, distinct_id_or_body \\ nil) do
128-
with {:ok, %{distinct_id: distinct_id} = body} <- body_for_flags(distinct_id_or_body),
129-
{:ok, %{body: body}} <- flags(name, body) do
130-
result =
131-
case body do
132-
%{"flags" => %{^flag_name => %{"variant" => variant}}} when not is_nil(variant) ->
133-
{:ok, variant}
130+
case evaluate_flag(name, flag_name, distinct_id_or_body, []) do
131+
{:ok, %FeatureFlagResult{} = flag_result, _resp_body} ->
132+
{:ok, FeatureFlagResult.value(flag_result)}
134133

135-
%{"flags" => %{^flag_name => %{"enabled" => true}}} ->
136-
{:ok, true}
134+
{:ok, nil, resp_body} ->
135+
{:error,
136+
%PostHog.UnexpectedResponseError{
137+
response: resp_body,
138+
message: "Feature flag #{flag_name} was not found in the response"
139+
}}
137140

138-
%{"flags" => %{^flag_name => _}} ->
139-
{:ok, false}
141+
{:error, reason, _resp_body} ->
142+
{:error, reason}
143+
end
144+
end
140145

141-
%{"flags" => _} ->
142-
{:error,
143-
%PostHog.UnexpectedResponseError{
144-
response: body,
145-
message: "Feature flag #{flag_name} was not found in the response"
146-
}}
147-
end
146+
@doc false
147+
def get_feature_flag_result(flag_name, distinct_id_or_body)
148+
when not is_atom(flag_name) and not is_list(distinct_id_or_body),
149+
do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, [])
150+
151+
@doc false
152+
def get_feature_flag_result(flag_name, distinct_id_or_body, opts)
153+
when not is_atom(flag_name) and is_list(opts),
154+
do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, opts)
155+
156+
@doc """
157+
Gets the full feature flag result including value and payload.
158+
159+
Returns `{:ok, %FeatureFlagResult{}}` on success, `{:ok, nil}` if the flag
160+
is not found, or `{:error, reason}` on failure.
161+
162+
The `FeatureFlagResult` struct contains:
163+
- `key` - The flag name
164+
- `enabled` - Whether the flag is enabled
165+
- `variant` - The variant string (nil for boolean flags)
166+
- `payload` - The JSON payload configured for the flag (nil if not set)
167+
168+
By default, this function will
169+
[send](https://posthog.com/docs/api/flags#step-3-send-a-feature_flag_called-event)
170+
a `$feature_flag_called` event and
171+
[set](https://posthog.com/docs/api/flags#step-2-include-feature-flag-information-when-capturing-events)
172+
the `$feature/feature-flag-name` property in context.
173+
174+
## Options
175+
176+
- `:send_event` - Whether to send the `$feature_flag_called` event. Defaults to `true`.
177+
178+
## Examples
179+
180+
Get feature flag result for `distinct_id`:
181+
182+
iex> PostHog.FeatureFlags.get_feature_flag_result("example-feature-flag-1", "user123")
183+
{:ok, %PostHog.FeatureFlagResult{key: "example-feature-flag-1", enabled: true, variant: nil, payload: nil}}
184+
185+
Get feature flag result with payload:
186+
187+
iex> PostHog.FeatureFlags.get_feature_flag_result("feature-with-payload", "user123")
188+
{:ok, %PostHog.FeatureFlagResult{key: "feature-with-payload", enabled: true, variant: "variant1", payload: %{"key" => "value"}}}
189+
190+
Get feature flag result without sending event:
191+
192+
iex> PostHog.FeatureFlags.get_feature_flag_result("my-flag", "user123", send_event: false)
193+
{:ok, %PostHog.FeatureFlagResult{key: "my-flag", enabled: true, variant: nil, payload: nil}}
194+
195+
Flag not found returns `{:ok, nil}`:
196+
197+
iex> PostHog.FeatureFlags.get_feature_flag_result("non-existent-flag", "user123")
198+
{:ok, nil}
199+
200+
Get feature flag result for `distinct_id` in the current context:
148201
149-
evaluated_at = Map.get(body, "evaluatedAt")
202+
iex> PostHog.set_context(%{distinct_id: "user123"})
203+
iex> PostHog.FeatureFlags.get_feature_flag_result("example-feature-flag-1")
204+
{:ok, %PostHog.FeatureFlagResult{key: "example-feature-flag-1", enabled: true, variant: nil, payload: nil}}
150205
151-
# Make sure we keep track of the feature flag usage for debugging purposes
152-
# Users are NOT charged extra for this, but it's still good to have.
153-
log_feature_flag_usage(name, distinct_id, flag_name, result, evaluated_at)
206+
Get feature flag result through a named PostHog instance:
154207
155-
result
208+
PostHog.FeatureFlags.get_feature_flag_result(MyPostHog, "example-feature-flag-1", "user123")
209+
"""
210+
@spec get_feature_flag_result(
211+
PostHog.supervisor_name(),
212+
String.t(),
213+
PostHog.distinct_id() | map() | nil,
214+
keyword()
215+
) ::
216+
{:ok, FeatureFlagResult.t() | nil} | {:error, Exception.t()}
217+
def get_feature_flag_result(name \\ PostHog, flag_name, distinct_id_or_body \\ nil, opts \\ []) do
218+
case evaluate_flag(name, flag_name, distinct_id_or_body, opts) do
219+
{:ok, %FeatureFlagResult{} = result, _resp_body} -> {:ok, result}
220+
{:ok, nil, _resp_body} -> {:ok, nil}
221+
{:error, reason, _resp_body} -> {:error, reason}
156222
end
157223
end
158224

225+
@doc false
226+
def get_feature_flag_result!(flag_name, distinct_id_or_body)
227+
when not is_atom(flag_name) and not is_list(distinct_id_or_body),
228+
do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, [])
229+
230+
@doc false
231+
def get_feature_flag_result!(flag_name, distinct_id_or_body, opts)
232+
when not is_atom(flag_name) and is_list(opts),
233+
do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, opts)
234+
235+
@doc """
236+
Gets the full feature flag result or raises on error.
237+
238+
This is a wrapper around `get_feature_flag_result/4` that returns the result
239+
directly or raises an exception on error. This follows the Elixir convention
240+
where functions ending with `!` raise exceptions instead of returning error
241+
tuples.
242+
243+
Returns `nil` if the flag is not found (does not raise), consistent with
244+
other PostHog SDKs.
245+
246+
> **Warning**: Use this function with care as it will raise an error if there
247+
> are any API errors (e.g. missing `distinct_id`). For more resilient code,
248+
> use `get_feature_flag_result/4` which returns `{:error, reason}` instead of
249+
> raising.
250+
251+
## Options
252+
253+
- `:send_event` - Whether to send the `$feature_flag_called` event. Defaults to `true`.
254+
255+
## Examples
256+
257+
Get feature flag result for `distinct_id`:
258+
259+
iex> PostHog.FeatureFlags.get_feature_flag_result!("example-feature-flag-1", "user123")
260+
%PostHog.FeatureFlagResult{key: "example-feature-flag-1", enabled: true, variant: nil, payload: nil}
261+
262+
Returns `nil` when flag is not found:
263+
264+
iex> PostHog.FeatureFlags.get_feature_flag_result!("non-existent-flag", "user123")
265+
nil
266+
267+
Raises an error when `distinct_id` is missing:
268+
269+
iex> PostHog.FeatureFlags.get_feature_flag_result!("example-feature-flag-1")
270+
** (PostHog.Error) distinct_id is required but wasn't explicitly provided or found in the context
271+
"""
272+
@spec get_feature_flag_result!(
273+
PostHog.supervisor_name(),
274+
String.t(),
275+
PostHog.distinct_id() | map() | nil,
276+
keyword()
277+
) ::
278+
FeatureFlagResult.t() | nil | no_return()
279+
def get_feature_flag_result!(name \\ PostHog, flag_name, distinct_id_or_body \\ nil, opts \\ []) do
280+
case get_feature_flag_result(name, flag_name, distinct_id_or_body, opts) do
281+
{:ok, result} -> result
282+
{:error, error} -> raise error
283+
end
284+
end
285+
286+
defp evaluate_flag(name, flag_name, distinct_id_or_body, opts) do
287+
send_event = Keyword.get(opts, :send_event, true)
288+
289+
with {:ok, %{distinct_id: distinct_id} = body} <- body_for_flags(distinct_id_or_body),
290+
{:ok, %{body: resp_body}} <- flags(name, body) do
291+
case resp_body do
292+
%{"flags" => %{^flag_name => flag_data}} ->
293+
{enabled, variant} = extract_flag_enabled_and_variant(flag_data)
294+
payload = get_in(flag_data, ["metadata", "payload"])
295+
296+
flag_result = %FeatureFlagResult{
297+
key: flag_name,
298+
enabled: enabled,
299+
variant: variant,
300+
payload: payload
301+
}
302+
303+
evaluated_at = Map.get(resp_body, "evaluatedAt")
304+
305+
if send_event do
306+
value = FeatureFlagResult.value(flag_result)
307+
log_feature_flag_usage(name, distinct_id, flag_name, {:ok, value}, evaluated_at)
308+
end
309+
310+
{:ok, flag_result, resp_body}
311+
312+
%{"flags" => _} ->
313+
{:ok, nil, resp_body}
314+
end
315+
else
316+
{:error, reason} -> {:error, reason, nil}
317+
end
318+
end
319+
320+
defp extract_flag_enabled_and_variant(flag_data) do
321+
enabled = Map.get(flag_data, "enabled", false) == true
322+
variant = Map.get(flag_data, "variant")
323+
{enabled, variant}
324+
end
325+
159326
@doc false
160327
def check!(flag_name, distinct_id_or_body) when not is_atom(flag_name),
161328
do: check!(PostHog, flag_name, distinct_id_or_body)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
defmodule PostHog.FeatureFlagResultTest do
2+
use ExUnit.Case, async: true
3+
4+
alias PostHog.FeatureFlagResult
5+
6+
describe "struct" do
7+
test "creates struct with all fields" do
8+
result = %FeatureFlagResult{
9+
key: "my-flag",
10+
enabled: true,
11+
variant: "control",
12+
payload: %{"key" => "value"}
13+
}
14+
15+
assert result.key == "my-flag"
16+
assert result.enabled == true
17+
assert result.variant == "control"
18+
assert result.payload == %{"key" => "value"}
19+
end
20+
21+
test "creates struct with nil defaults" do
22+
result = %FeatureFlagResult{key: "my-flag", enabled: false}
23+
24+
assert result.key == "my-flag"
25+
assert result.enabled == false
26+
assert result.variant == nil
27+
assert result.payload == nil
28+
end
29+
end
30+
31+
describe "value/1" do
32+
test "returns variant when present" do
33+
result = %FeatureFlagResult{
34+
key: "my-flag",
35+
enabled: true,
36+
variant: "control",
37+
payload: nil
38+
}
39+
40+
assert FeatureFlagResult.value(result) == "control"
41+
end
42+
43+
test "returns variant even when empty string" do
44+
result = %FeatureFlagResult{
45+
key: "my-flag",
46+
enabled: true,
47+
variant: "",
48+
payload: nil
49+
}
50+
51+
# Empty string is still a variant, but per implementation nil check
52+
# empty string is not nil, so it returns the variant
53+
assert FeatureFlagResult.value(result) == ""
54+
end
55+
56+
test "returns true when enabled and no variant" do
57+
result = %FeatureFlagResult{
58+
key: "my-flag",
59+
enabled: true,
60+
variant: nil,
61+
payload: nil
62+
}
63+
64+
assert FeatureFlagResult.value(result) == true
65+
end
66+
67+
test "returns false when not enabled and no variant" do
68+
result = %FeatureFlagResult{
69+
key: "my-flag",
70+
enabled: false,
71+
variant: nil,
72+
payload: nil
73+
}
74+
75+
assert FeatureFlagResult.value(result) == false
76+
end
77+
78+
test "variant takes precedence over enabled status" do
79+
# Edge case: variant present but enabled is false
80+
result = %FeatureFlagResult{
81+
key: "my-flag",
82+
enabled: false,
83+
variant: "test-variant",
84+
payload: nil
85+
}
86+
87+
assert FeatureFlagResult.value(result) == "test-variant"
88+
end
89+
end
90+
end

0 commit comments

Comments
 (0)