Skip to content

Commit 473796e

Browse files
Merge pull request #82 from PostHog/feat/get-feature-flag-result
feat: Add `get_feature_flag_result`, `get_feature_flag_result!` API
2 parents 066d4fd + b37ae8c commit 473796e

File tree

6 files changed

+845
-27
lines changed

6 files changed

+845
-27
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
hex/posthog: minor
3+
---
4+
5+
We've now added a new `get_feature_flag_result` method that can be used to get a full view of your feature flags including the payload rather than simply a boolean/string from the enabled/variant state.

README.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ iex> PostHog.FeatureFlags.check("example-feature-flag-3", "user123")
148148
{:error, %PostHog.UnexpectedResponseError{message: "Feature flag example-feature-flag-3 was not found in the response", response: ...}}
149149
```
150150

151-
If you're feeling adventurous and/or is simply writing a script you can use the `PostHog.FeatureFlags.check!/2` helper instead and it will return a boolean or raise an error.
151+
If you're feeling adventurous and/or simply writing a script, you can use the `PostHog.FeatureFlags.check!/2` helper instead and it will return a boolean or raise an error.
152152

153153
```elixir
154154
# Simple boolean feature flag
@@ -159,12 +159,45 @@ true
159159
iex> PostHog.FeatureFlags.check!("example-feature-flag-2", "user123")
160160
"variant2"
161161

162-
163162
# Raises error if feature flag doesn't exist
164163
iex> PostHog.FeatureFlags.check!("example-feature-flag-3", "user123")
165164
** (PostHog.UnexpectedResponseError) Feature flag example-feature-flag-3 was not found in the response
166165
```
167166

167+
### Getting the Full Flag Result
168+
169+
If you need more than just the value -- for example, the payload configured for a
170+
flag or variant -- use `PostHog.FeatureFlags.get_feature_flag_result/2`:
171+
172+
```elixir
173+
iex> PostHog.FeatureFlags.get_feature_flag_result("my-flag", "user123")
174+
{:ok, %PostHog.FeatureFlags.Result{key: "my-flag", enabled: true, variant: nil, payload: nil}}
175+
176+
# Multivariant flag with a JSON payload
177+
iex> PostHog.FeatureFlags.get_feature_flag_result("my-experiment", "user123")
178+
{:ok, %PostHog.FeatureFlags.Result{key: "my-experiment", enabled: true, variant: "control", payload: %{"button_color" => "blue"}}}
179+
180+
# Flag not found
181+
iex> PostHog.FeatureFlags.get_feature_flag_result("non-existent-flag", "user123")
182+
{:ok, nil}
183+
```
184+
185+
By default this sends a `$feature_flag_called` event, which PostHog uses to
186+
track feature flag usage in your analytics, and to measure experiment exposure
187+
when the flag is linked to an A/B test. You can opt out with `send_event: false`:
188+
189+
```elixir
190+
iex> PostHog.FeatureFlags.get_feature_flag_result("my-flag", "user123", send_event: false)
191+
{:ok, %PostHog.FeatureFlags.Result{key: "my-flag", enabled: true, variant: nil, payload: nil}}
192+
```
193+
194+
A bang variant is also available:
195+
196+
```elixir
197+
iex> PostHog.FeatureFlags.get_feature_flag_result!("my-flag", "user123")
198+
%PostHog.FeatureFlags.Result{key: "my-flag", enabled: true, variant: nil, payload: nil}
199+
```
200+
168201
## Error Tracking
169202

170203
Error Tracking is enabled by default.

lib/posthog/feature_flags.ex

Lines changed: 188 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -125,37 +125,202 @@ defmodule PostHog.FeatureFlags do
125125
@spec check(PostHog.supervisor_name(), String.t(), PostHog.distinct_id() | map() | nil) ::
126126
{:ok, boolean()} | {:ok, String.t()} | {:error, Exception.t()}
127127
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}
128+
case evaluate_flag(name, flag_name, distinct_id_or_body, []) do
129+
{:ok, %__MODULE__.Result{} = flag_result, _body} ->
130+
{:ok, __MODULE__.Result.value(flag_result)}
134131

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

138-
%{"flags" => %{^flag_name => _}} ->
139-
{:ok, false}
139+
{:error, reason, _body} ->
140+
{:error, reason}
141+
end
142+
end
140143

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
144+
@doc false
145+
def get_feature_flag_result(flag_name, distinct_id_or_body)
146+
when not is_atom(flag_name) and not is_list(distinct_id_or_body),
147+
do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, [])
148+
149+
@doc false
150+
def get_feature_flag_result(flag_name, distinct_id_or_body, opts)
151+
when not is_atom(flag_name) and is_list(opts),
152+
do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, opts)
153+
154+
@doc """
155+
Gets the full feature flag result including value and payload.
156+
157+
Returns `{:ok, %PostHog.FeatureFlags.Result{}}` on success, `{:ok, nil}` if the flag
158+
is not found, or `{:error, reason}` on failure.
159+
160+
The `PostHog.FeatureFlags.Result` struct contains:
161+
- `key` - The flag name
162+
- `enabled` - Whether the flag is enabled
163+
- `variant` - The variant string (nil for boolean flags)
164+
- `payload` - The JSON payload configured for the flag (nil if not set)
165+
166+
By default, this function will
167+
[send](https://posthog.com/docs/api/flags#step-3-send-a-feature_flag_called-event)
168+
a `$feature_flag_called` event and
169+
[set](https://posthog.com/docs/api/flags#step-2-include-feature-flag-information-when-capturing-events)
170+
the `$feature/feature-flag-name` property in context.
171+
172+
## Options
173+
174+
- `:send_event` - Whether to send the `$feature_flag_called` event. Defaults to `true`.
175+
176+
## Examples
177+
178+
Get feature flag result for `distinct_id`:
179+
180+
iex> PostHog.FeatureFlags.get_feature_flag_result("example-feature-flag-1", "user123")
181+
{:ok, %PostHog.FeatureFlags.Result{key: "example-feature-flag-1", enabled: true, variant: nil, payload: nil}}
182+
183+
Get feature flag result with payload:
184+
185+
iex> PostHog.FeatureFlags.get_feature_flag_result("feature-with-payload", "user123")
186+
{:ok, %PostHog.FeatureFlags.Result{key: "feature-with-payload", enabled: true, variant: "variant1", payload: %{"key" => "value"}}}
187+
188+
Get feature flag result without sending event:
189+
190+
iex> PostHog.FeatureFlags.get_feature_flag_result("my-flag", "user123", send_event: false)
191+
{:ok, %PostHog.FeatureFlags.Result{key: "my-flag", enabled: true, variant: nil, payload: nil}}
192+
193+
Flag not found returns `{:ok, nil}`:
194+
195+
iex> PostHog.FeatureFlags.get_feature_flag_result("non-existent-flag", "user123")
196+
{:ok, nil}
197+
198+
Get feature flag result for `distinct_id` in the current context:
148199
149-
evaluated_at = Map.get(body, "evaluatedAt")
200+
iex> PostHog.set_context(%{distinct_id: "user123"})
201+
iex> PostHog.FeatureFlags.get_feature_flag_result("example-feature-flag-1")
202+
{:ok, %PostHog.FeatureFlags.Result{key: "example-feature-flag-1", enabled: true, variant: nil, payload: nil}}
203+
204+
Get feature flag result through a named PostHog instance:
205+
206+
PostHog.FeatureFlags.get_feature_flag_result(MyPostHog, "example-feature-flag-1", "user123")
207+
"""
208+
@spec get_feature_flag_result(
209+
PostHog.supervisor_name(),
210+
String.t(),
211+
PostHog.distinct_id() | map() | nil,
212+
keyword()
213+
) ::
214+
{:ok, __MODULE__.Result.t() | nil} | {:error, Exception.t()}
215+
def get_feature_flag_result(name \\ PostHog, flag_name, distinct_id_or_body \\ nil, opts \\ []) do
216+
case evaluate_flag(name, flag_name, distinct_id_or_body, opts) do
217+
{:ok, %__MODULE__.Result{} = result, _body} -> {:ok, result}
218+
{:ok, nil, _body} -> {:ok, nil}
219+
{:error, reason, _body} -> {:error, reason}
220+
end
221+
end
222+
223+
@doc false
224+
def get_feature_flag_result!(flag_name, distinct_id_or_body)
225+
when not is_atom(flag_name) and not is_list(distinct_id_or_body),
226+
do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, [])
227+
228+
@doc false
229+
def get_feature_flag_result!(flag_name, distinct_id_or_body, opts)
230+
when not is_atom(flag_name) and is_list(opts),
231+
do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, opts)
232+
233+
@doc """
234+
Gets the full feature flag result or raises on error.
235+
236+
This is a wrapper around `get_feature_flag_result/4` that returns the result
237+
directly or raises an exception on error. This follows the Elixir convention
238+
where functions ending with `!` raise exceptions instead of returning error
239+
tuples.
240+
241+
Returns `nil` if the flag is not found (does not raise), consistent with
242+
other PostHog SDKs.
243+
244+
> **Warning**: Use this function with care as it will raise an error if there
245+
> are any API errors (e.g. missing `distinct_id`). For more resilient code,
246+
> use `get_feature_flag_result/4` which returns `{:error, reason}` instead of
247+
> raising.
248+
249+
## Options
250+
251+
- `:send_event` - Whether to send the `$feature_flag_called` event. Defaults to `true`.
150252
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)
253+
## Examples
254+
255+
Get feature flag result for `distinct_id`:
256+
257+
iex> PostHog.FeatureFlags.get_feature_flag_result!("example-feature-flag-1", "user123")
258+
%PostHog.FeatureFlags.Result{key: "example-feature-flag-1", enabled: true, variant: nil, payload: nil}
259+
260+
Returns `nil` when flag is not found:
261+
262+
iex> PostHog.FeatureFlags.get_feature_flag_result!("non-existent-flag", "user123")
263+
nil
154264
155-
result
265+
Raises an error when `distinct_id` is missing:
266+
267+
iex> PostHog.FeatureFlags.get_feature_flag_result!("example-feature-flag-1")
268+
** (PostHog.Error) distinct_id is required but wasn't explicitly provided or found in the context
269+
"""
270+
@spec get_feature_flag_result!(
271+
PostHog.supervisor_name(),
272+
String.t(),
273+
PostHog.distinct_id() | map() | nil,
274+
keyword()
275+
) ::
276+
__MODULE__.Result.t() | nil | no_return()
277+
def get_feature_flag_result!(name \\ PostHog, flag_name, distinct_id_or_body \\ nil, opts \\ []) do
278+
case get_feature_flag_result(name, flag_name, distinct_id_or_body, opts) do
279+
{:ok, result} -> result
280+
{:error, error} -> raise error
156281
end
157282
end
158283

284+
defp evaluate_flag(name, flag_name, distinct_id_or_body, opts) do
285+
send_event = Keyword.get(opts, :send_event, true)
286+
287+
with {:ok, %{distinct_id: distinct_id} = body} <- body_for_flags(distinct_id_or_body),
288+
{:ok, %{body: body}} <- flags(name, body) do
289+
case body do
290+
%{"flags" => %{^flag_name => flag_data}} ->
291+
{enabled, variant} = extract_flag_enabled_and_variant(flag_data)
292+
payload = get_in(flag_data, ["metadata", "payload"])
293+
294+
flag_result = %__MODULE__.Result{
295+
key: flag_name,
296+
enabled: enabled,
297+
variant: variant,
298+
payload: payload
299+
}
300+
301+
evaluated_at = Map.get(body, "evaluatedAt")
302+
303+
if send_event do
304+
value = __MODULE__.Result.value(flag_result)
305+
log_feature_flag_usage(name, distinct_id, flag_name, {:ok, value}, evaluated_at)
306+
end
307+
308+
{:ok, flag_result, body}
309+
310+
%{"flags" => _} ->
311+
{:ok, nil, body}
312+
end
313+
else
314+
{:error, reason} -> {:error, reason, nil}
315+
end
316+
end
317+
318+
defp extract_flag_enabled_and_variant(flag_data) do
319+
enabled = Map.get(flag_data, "enabled", false) == true
320+
variant = Map.get(flag_data, "variant")
321+
{enabled, variant}
322+
end
323+
159324
@doc false
160325
def check!(flag_name, distinct_id_or_body) when not is_atom(flag_name),
161326
do: check!(PostHog, flag_name, distinct_id_or_body)
@@ -238,7 +403,7 @@ defmodule PostHog.FeatureFlags do
238403
{:error,
239404
%PostHog.Error{
240405
message:
241-
"distinct_id is required but wasn't explicitely provided or found in the context"
406+
"distinct_id is required but wasn't explicitly provided or found in the context"
242407
}}
243408
end
244409

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
defmodule PostHog.FeatureFlags.Result 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.FeatureFlags.Result{
15+
key: "my-feature",
16+
enabled: true,
17+
variant: nil,
18+
payload: nil
19+
}
20+
21+
# Multivariant flag result with payload
22+
%PostHog.FeatureFlags.Result{
23+
key: "my-experiment",
24+
enabled: true,
25+
variant: "control",
26+
payload: %{"button_color" => "blue"}
27+
}
28+
"""
29+
30+
@type json :: String.t() | number() | boolean() | nil | [json()] | %{String.t() => json()}
31+
32+
@type t :: %__MODULE__{
33+
key: String.t(),
34+
enabled: boolean(),
35+
variant: String.t() | nil,
36+
payload: json()
37+
}
38+
39+
@enforce_keys [:key, :enabled]
40+
defstruct [:key, :enabled, :variant, :payload]
41+
42+
@doc """
43+
Returns the value of the feature flag result.
44+
45+
If a variant is present, returns the variant string. Otherwise, returns the
46+
enabled boolean status. This provides backwards compatibility with existing
47+
code that expects a simple value from feature flag checks.
48+
49+
## Examples
50+
51+
iex> result = %PostHog.FeatureFlags.Result{key: "flag", enabled: true, variant: "control", payload: nil}
52+
iex> PostHog.FeatureFlags.Result.value(result)
53+
"control"
54+
55+
iex> result = %PostHog.FeatureFlags.Result{key: "flag", enabled: true, variant: nil, payload: nil}
56+
iex> PostHog.FeatureFlags.Result.value(result)
57+
true
58+
59+
iex> result = %PostHog.FeatureFlags.Result{key: "flag", enabled: false, variant: nil, payload: nil}
60+
iex> PostHog.FeatureFlags.Result.value(result)
61+
false
62+
"""
63+
@spec value(t()) :: boolean() | String.t()
64+
def value(%__MODULE__{variant: variant}) when not is_nil(variant), do: variant
65+
def value(%__MODULE__{enabled: enabled}), do: enabled
66+
end

0 commit comments

Comments
 (0)