Skip to content
5 changes: 5 additions & 0 deletions .sampo/changesets/venerable-baroness-erika.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
hex/posthog: minor
---

Add local feature flag definition polling and evaluation with safe remote fallback.
18 changes: 18 additions & 0 deletions lib/posthog/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,22 @@ defmodule PostHog.API do
max_retries: client.feature_flags_request_max_retries
)
end

def flag_definitions(
%__MODULE__.Client{} = client,
project_api_key,
%PostHog.Config.Secret{} = secret_key,
etag
) do
headers =
[{"authorization", "Bearer #{PostHog.Config.Secret.reveal(secret_key)}"}]
|> then(fn headers ->
if is_binary(etag), do: [{"if-none-match", etag} | headers], else: headers
end)

client.module.request(client.client, :get, "/flags/definitions",
Comment thread
veria-ai[bot] marked this conversation as resolved.
params: %{token: project_api_key, send_cohorts: true},
headers: headers
)
end
end
4 changes: 2 additions & 2 deletions lib/posthog/api/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ defmodule PostHog.API.Client do
Response tuple returned by `c:request/4`.

Successful responses must expose at least a numeric `:status` and decoded
`:body`; errors should return the exception or error struct from the HTTP
client.
`:body`; they may also expose response `:headers`. Errors should return the
exception or error struct from the HTTP client.
"""
@type response() :: {:ok, %{status: non_neg_integer(), body: any()}} | {:error, Exception.t()}

Expand Down
83 changes: 82 additions & 1 deletion lib/posthog/config.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
defmodule PostHog.Config do
require Logger

defmodule Secret do
@moduledoc false
@enforce_keys [:value]
defstruct [:value]

@type t :: %__MODULE__{value: String.t()}

@doc false
@spec new(String.t()) :: t()
def new(value) when is_binary(value), do: %__MODULE__{value: value}

@doc false
@spec reveal(t()) :: String.t()
def reveal(%__MODULE__{value: value}), do: value
end

defimpl Inspect, for: Secret do
import Inspect.Algebra

def inspect(_secret, _opts), do: concat(["#PostHog.Config.Secret<redacted>"])
end

@default_api_host "https://us.i.posthog.com"

@shared_schema [
Expand Down Expand Up @@ -38,6 +60,45 @@ defmodule PostHog.Config do
doc:
"Number of retries for /flags requests after network, transport, or timeout failures. Set to 0 to disable retries."
],
secret_key: [
type: {:or, [:string, nil]},
default: nil,
doc: """
A privileged project secret (`phs_`) or appropriately scoped personal API key (`phx_`)
used only to load definitions for local feature flag evaluation. Local evaluation is inert
and starts no poller when this value is absent or blank.
"""
],
enable_local_evaluation: [
type: :boolean,
default: true,
doc:
"Enable local feature flag evaluation when a non-empty `secret_key` is configured."
],
feature_flags_poll_interval_ms: [
type: :pos_integer,
default: 30_000,
doc:
"Interval in milliseconds between local feature flag definition refreshes."
],
flag_definition_cache_provider: [
type: {:or, [{:tuple, [:atom, :any]}, nil]},
default: nil,
doc:
"Optional `{module, state}` implementing `PostHog.FeatureFlags.FlagDefinitionCacheProvider`."
],
flag_definition_cache_provider_timeout_ms: [
type: :pos_integer,
default: 5_000,
doc:
"Maximum time in milliseconds allowed for each definition cache provider callback."
],
flag_definition_request_timeout_ms: [
type: :pos_integer,
default: 10_000,
doc:
"Maximum time in milliseconds allowed for a local feature flag definition request."
],
supervisor_name: [
type: :atom,
default: PostHog,
Expand Down Expand Up @@ -219,7 +280,7 @@ defmodule PostHog.Config do

## Remarks

String `:api_key` and `:api_host` values are trimmed before validation. A blank
String `:api_key`, `:secret_key`, and `:api_host` values are trimmed before validation. A blank
`:api_host` falls back to the default PostHog US ingestion host.
"""
@spec validate(options()) ::
Expand Down Expand Up @@ -253,6 +314,10 @@ defmodule PostHog.Config do

final_config =
config
|> Map.update!(:secret_key, fn
nil -> nil
secret_key -> Secret.new(secret_key)
end)
|> Map.put(:api_client, client)
|> Map.put(:enabled, not api_key_blank?)
|> Map.put(
Expand All @@ -274,6 +339,13 @@ defmodule PostHog.Config do
normalized_options
end
end)
|> then(fn normalized_options ->
if Keyword.has_key?(normalized_options, :secret_key) do
Keyword.update!(normalized_options, :secret_key, &normalize_secret_key/1)
else
normalized_options
end
end)
|> then(fn normalized_options ->
if Keyword.has_key?(normalized_options, :api_host) do
Keyword.update!(normalized_options, :api_host, &normalize_api_host/1)
Expand All @@ -287,6 +359,15 @@ defmodule PostHog.Config do
defp normalize_api_key(nil), do: ""
defp normalize_api_key(api_key), do: api_key

defp normalize_secret_key(secret_key) when is_binary(secret_key) do
case String.trim(secret_key) do
"" -> nil
value -> value
end
end

defp normalize_secret_key(secret_key), do: secret_key

defp normalize_api_host(api_host) when is_binary(api_host) do
api_host
|> String.trim()
Expand Down
Loading
Loading