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
22 changes: 22 additions & 0 deletions .sampo/changesets/evaluate-flags-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
hex/posthog: minor
---

Add `PostHog.FeatureFlags.evaluate_flags/2` and the `PostHog.FeatureFlags.Evaluations` snapshot so a single `/flags` call can power both flag branching and event enrichment for one request:

```elixir
{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user-123")

if PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard") do
render_new_dashboard()
end

PostHog.FeatureFlags.set_in_context(snapshot)
PostHog.capture("page_viewed", %{distinct_id: "user-123"})
```

The snapshot exposes `enabled?/2`, `get_flag/2`, `get_flag_payload/2`, `only/2`, `only_accessed/1`, `accessed/1`, `keys/1`, and `event_properties/1`. Pass `flag_keys: [...]` to `evaluate_flags/2` to scope the underlying `/flags` request itself. When `distinct_id` cannot be resolved, `evaluate_flags/2` returns an empty snapshot whose accessors are no-ops (matching the cross-SDK behavior).

`$feature_flag_called` events fired from `check/3`, `check!/3`, `get_feature_flag_result/4`, and the new snapshot path now attach `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`, `$feature_flag_payload`, `$feature/<key>`, and `$feature_flag_error` (combining `errors_while_computing_flags` and, on the snapshot path, `flag_missing`) when the response provides them. JSON-encoded payloads in `/flags` responses are now decoded before being attached to events and the `:payload` field on `%PostHog.FeatureFlags.Result{}`. The struct also gains `:id`, `:version`, `:reason`, `:request_id`, `:evaluated_at`, and `:errors_while_computing`.

`check/3`, `check!/3`, `get_feature_flag_result/4`, and `get_feature_flag_result!/4` are now marked `@deprecated` and emit compile-time warnings pointing at `evaluate_flags/2`. They continue to return the same values; removal is planned for the next major.
268 changes: 239 additions & 29 deletions lib/posthog/feature_flags.ex
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,138 @@ defmodule PostHog.FeatureFlags do
end
end

@doc false
def evaluate_flags(distinct_id_or_body) when not is_atom(distinct_id_or_body),
do: evaluate_flags(PostHog, distinct_id_or_body)

@doc """
Evaluates feature flags for a `distinct_id` and returns a snapshot.

Returns `{:ok, %PostHog.FeatureFlags.Evaluations{}}` on success. The snapshot
represents a single `/flags` call and lets you branch on multiple flags and
enrich captured events from the same fetch — see
`PostHog.FeatureFlags.Evaluations` for the full snapshot API and
`set_in_context/2` for the recommended capture-enrichment flow.

Accepts an optional `distinct_id` or a request body map. If neither is
passed, attempts to read `distinct_id` from the context.

## Body options

When passing a map, the following keys are forwarded to the `/flags` request
body unchanged:

- `:distinct_id` (required, unless found in context)
- `:groups`
- `:person_properties`
- `:group_properties`
- `:disable_geoip`

Plus one snapshot-specific option:

- `:flag_keys` - list of flag keys. Forwarded to the request as
`flag_keys_to_evaluate` so the server returns only those flags. This
scopes the network response, distinct from
`PostHog.FeatureFlags.Evaluations.only/2` which filters an already-fetched
snapshot in memory.

## Examples

Evaluate flags for a `distinct_id`:

{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user123")
PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard")

Evaluate a scoped set of flags with person properties:

PostHog.FeatureFlags.evaluate_flags(%{
distinct_id: "user123",
person_properties: %{plan: "enterprise"},
flag_keys: ["new-dashboard", "beta-checkout"]
})

Evaluate through a named PostHog instance:

PostHog.FeatureFlags.evaluate_flags(MyPostHog, "user123")
"""
@spec evaluate_flags(PostHog.supervisor_name(), PostHog.distinct_id() | map() | nil) ::
{:ok, __MODULE__.Evaluations.t()} | {:error, Exception.t()}
def evaluate_flags(name \\ PostHog, distinct_id_or_body \\ nil) do
case body_for_flags(distinct_id_or_body) do
{:ok, %{distinct_id: distinct_id} = body} ->
body = translate_flag_keys(body)

case flags(name, body) do
{:ok, %{body: response_body}} ->
{:ok, __MODULE__.Evaluations.new(name, distinct_id, response_body)}

{:error, _} = error ->
error
end

{:error, _} ->
# Standardize on returning an empty snapshot when distinct_id can't be
# resolved — matches the cross-SDK behavior. The empty distinct_id
# short-circuits event firing in `enabled?/2` and `get_flag/2`, so no
# events leak with an empty distinct_id.
{:ok, __MODULE__.Evaluations.empty(name)}
end
end

@doc false
def set_in_context(%__MODULE__.Evaluations{} = snapshot),
do: set_in_context(PostHog, snapshot)

@doc """
Copies a snapshot's `$feature/<key>` and `$active_feature_flags` properties
into the per-process PostHog context.

Any subsequent `PostHog.capture/3` from this process automatically attaches
these properties to the captured event — no additional `/flags` request,
with the values guaranteed to match what the snapshot already evaluated.

This is the idiomatic Elixir way to enrich captured events from an
`evaluate_flags/2` snapshot. For one-off enrichment without touching context,
merge `PostHog.FeatureFlags.Evaluations.event_properties/1` into a capture's
properties directly.

## Examples

{:ok, snapshot} = PostHog.FeatureFlags.evaluate_flags("user123")
PostHog.FeatureFlags.set_in_context(snapshot)

# All subsequent captures pick up $feature/* and $active_feature_flags
PostHog.capture("page_viewed", %{distinct_id: "user123"})
"""
@spec set_in_context(PostHog.supervisor_name(), __MODULE__.Evaluations.t()) :: :ok
def set_in_context(name, %__MODULE__.Evaluations{} = snapshot) when is_atom(name) do
PostHog.set_context(name, __MODULE__.Evaluations.event_properties(snapshot))
end

defp translate_flag_keys(%{flag_keys: flag_keys} = body) when is_list(flag_keys) do
body
|> Map.delete(:flag_keys)
|> Map.put(:flag_keys_to_evaluate, flag_keys)
end

defp translate_flag_keys(body), do: body

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2"
@doc false
def check(flag_name, distinct_id_or_body) when not is_atom(flag_name),
do: check(PostHog, flag_name, distinct_id_or_body)

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2"
@doc """
Checks feature flag

> #### Deprecated {: .warning}
>
> Use `PostHog.FeatureFlags.evaluate_flags/2` plus
> `PostHog.FeatureFlags.Evaluations.enabled?/2` or
> `PostHog.FeatureFlags.Evaluations.get_flag/2` instead. The snapshot lets
> one `/flags` call serve multiple flag checks plus event enrichment.

If there is a variant assigned, returns `{:ok, variant}`. Otherwise, `{:ok,
true}` or `{:ok, false}`.

Expand Down Expand Up @@ -141,19 +266,29 @@ defmodule PostHog.FeatureFlags do
end
end

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations"
@doc false
def get_feature_flag_result(flag_name, distinct_id_or_body)
when not is_atom(flag_name) and not is_list(distinct_id_or_body),
do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, [])

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations"
@doc false
def get_feature_flag_result(flag_name, distinct_id_or_body, opts)
when not is_atom(flag_name) and is_list(opts),
do: get_feature_flag_result(PostHog, flag_name, distinct_id_or_body, opts)

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations"
@doc """
Gets the full feature flag result including value and payload.

> #### Deprecated {: .warning}
>
> Use `PostHog.FeatureFlags.evaluate_flags/2` and access flags from the
> returned `PostHog.FeatureFlags.Evaluations` snapshot. The snapshot
> exposes the same metadata (id, version, reason, payload) plus filter
> helpers and capture enrichment via `set_in_context/2`.

Returns `{:ok, %PostHog.FeatureFlags.Result{}}` on success, `{:ok, nil}` if the flag
is not found, or `{:error, reason}` on failure.

Expand Down Expand Up @@ -220,19 +355,27 @@ defmodule PostHog.FeatureFlags do
end
end

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations"
@doc false
def get_feature_flag_result!(flag_name, distinct_id_or_body)
when not is_atom(flag_name) and not is_list(distinct_id_or_body),
do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, [])

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations"
@doc false
def get_feature_flag_result!(flag_name, distinct_id_or_body, opts)
when not is_atom(flag_name) and is_list(opts),
do: get_feature_flag_result!(PostHog, flag_name, distinct_id_or_body, opts)

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations"
@doc """
Gets the full feature flag result or raises on error.

> #### Deprecated {: .warning}
>
> Use `PostHog.FeatureFlags.evaluate_flags/2` and access flags from the
> returned `PostHog.FeatureFlags.Evaluations` snapshot.

This is a wrapper around `get_feature_flag_result/4` that returns the result
directly or raises an exception on error. This follows the Elixir convention
where functions ending with `!` raise exceptions instead of returning error
Expand Down Expand Up @@ -288,21 +431,10 @@ defmodule PostHog.FeatureFlags do
{:ok, %{body: body}} <- flags(name, body) do
case body do
%{"flags" => %{^flag_name => flag_data}} ->
{enabled, variant} = extract_flag_enabled_and_variant(flag_data)
payload = get_in(flag_data, ["metadata", "payload"])

flag_result = %__MODULE__.Result{
key: flag_name,
enabled: enabled,
variant: variant,
payload: payload
}

evaluated_at = Map.get(body, "evaluatedAt")
flag_result = build_result(flag_name, flag_data, body)

if send_event do
value = __MODULE__.Result.value(flag_result)
log_feature_flag_usage(name, distinct_id, flag_name, {:ok, value}, evaluated_at)
log_feature_flag_usage(name, distinct_id, flag_result)
end

{:ok, flag_result, body}
Expand All @@ -315,19 +447,60 @@ defmodule PostHog.FeatureFlags do
end
end

@doc false
@spec build_result(String.t(), map(), map()) :: __MODULE__.Result.t()
def build_result(flag_name, flag_data, body) do
{enabled, variant} = extract_flag_enabled_and_variant(flag_data)

%__MODULE__.Result{
key: flag_name,
enabled: enabled,
variant: variant,
payload: normalize_payload(get_in(flag_data, ["metadata", "payload"])),
id: get_in(flag_data, ["metadata", "id"]),
version: get_in(flag_data, ["metadata", "version"]),
reason: Map.get(flag_data, "reason"),
request_id: Map.get(body, "requestId"),
evaluated_at: Map.get(body, "evaluatedAt"),
errors_while_computing: Map.get(body, "errorsWhileComputingFlags") == true
}
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.
defp normalize_payload(nil), do: nil

defp normalize_payload(payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} -> decoded
{:error, _} -> payload
end
end

defp normalize_payload(payload), do: payload

defp extract_flag_enabled_and_variant(flag_data) do
enabled = Map.get(flag_data, "enabled", false) == true
variant = Map.get(flag_data, "variant")
{enabled, variant}
end

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2"
@doc false
def check!(flag_name, distinct_id_or_body) when not is_atom(flag_name),
do: check!(PostHog, flag_name, distinct_id_or_body)

@deprecated "Use PostHog.FeatureFlags.evaluate_flags/2 with PostHog.FeatureFlags.Evaluations.enabled?/2 or get_flag/2"
@doc """
Checks feature flag and returns the variant or raises on error.

> #### Deprecated {: .warning}
>
> Use `PostHog.FeatureFlags.evaluate_flags/2` and
> `PostHog.FeatureFlags.Evaluations.enabled?/2` /
> `PostHog.FeatureFlags.Evaluations.get_flag/2` instead.

This is a wrapper around `check/3` that returns the variant directly
or raises an exception on error. This follows the Elixir convention where
functions ending with `!` raise exceptions instead of returning error tuples.
Expand Down Expand Up @@ -368,26 +541,63 @@ defmodule PostHog.FeatureFlags do
end
end

defp log_feature_flag_usage(name, distinct_id, flag_name, result, evaluated_at) do
with {:ok, variant} <- result do
properties = %{
distinct_id: distinct_id,
"$feature_flag": flag_name,
"$feature_flag_response": variant
@doc false
@spec log_feature_flag_usage(
PostHog.supervisor_name(),
PostHog.distinct_id(),
__MODULE__.Result.t()
) ::
:ok | {:error, :missing_distinct_id}
def log_feature_flag_usage(name, distinct_id, %__MODULE__.Result{} = result) do
log_feature_flag_usage(name, distinct_id, result, [])
end

@doc false
@spec log_feature_flag_usage(
PostHog.supervisor_name(),
PostHog.distinct_id(),
__MODULE__.Result.t(),
[String.t()]
) ::
:ok | {:error, :missing_distinct_id}
def log_feature_flag_usage(name, distinct_id, %__MODULE__.Result{} = result, extra_errors)
when is_list(extra_errors) do
flag_missing? = "flag_missing" in extra_errors
value = if flag_missing?, do: nil, else: __MODULE__.Result.value(result)
errors = build_error_codes(result, extra_errors)

properties =
%{
"$feature/#{result.key}" => value,
:distinct_id => distinct_id,
:"$feature_flag" => result.key,
:"$feature_flag_response" => value
}
Comment on lines +569 to 575
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Compared to the other SDKs, we're missing $feature/<key>, $feature_flag_payload

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added in 144c934. log_feature_flag_usage/4 now attaches $feature/<key> (string key, e.g. "$feature/my-flag" => "variant1") and $feature_flag_payload (omitted when nil) on every $feature_flag_called event. Both the existing single-flag path and the new snapshot path emit them.

New assertions in the "fires $feature_flag_called with full metadata" test cover both properties.

|> maybe_put(:"$feature_flag_id", result.id)
|> maybe_put(:"$feature_flag_version", result.version)
|> maybe_put(:"$feature_flag_reason", result.reason)
|> 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_error", errors)

PostHog.capture(name, "$feature_flag_called", properties)

if flag_missing? do
:ok
else
PostHog.set_context(name, %{"$feature/#{result.key}" => value})
end
end

properties =
if evaluated_at do
Map.put(properties, :"$feature_flag_evaluated_at", evaluated_at)
else
properties
end
defp build_error_codes(%__MODULE__.Result{errors_while_computing: true}, extra),
do: ["errors_while_computing_flags" | extra] |> Enum.join(",")

PostHog.capture(name, "$feature_flag_called", properties)
defp build_error_codes(%__MODULE__.Result{}, []), do: nil
defp build_error_codes(%__MODULE__.Result{}, extra), do: Enum.join(extra, ",")

PostHog.set_context(name, %{"$feature/#{flag_name}" => variant})
end
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)

defp body_for_flags(distinct_id_or_body) do
case distinct_id_or_body do
Expand Down
Loading
Loading