diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7b1fd6e..970d6bb 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,8 +26,8 @@ jobs: - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: 1.16.3 - otp-version: 26.2 + elixir-version: 1.18.3 + otp-version: 27.3 - name: Detect version bump type id: bump-type diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdc2aa7..07fdfa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,29 @@ jobs: name: Build and test runs-on: ubuntu-latest + # This is a complicated set of strategies, but Elixir has a complicated set of supported versions. + # See https://hexdocs.pm/elixir/compatibility-and-deprecations.html + # See https://hexdocs.pm/elixir/compatibility-and-deprecations.html#between-elixir-and-erlang-otp + # + # NOTE: We're intentionally not including Elixir 1.14.x + OTP 23.x + # because erlef/setup-beam@v1 doesn't support it. strategy: matrix: - elixir: ["1.12.x", "1.13.x", "1.14.x", "1.15.x", "1.16.x"] - otp: ["24.x"] + elixir: ["1.14.x", "1.15.x", "1.16.x", "1.17.x", "1.18.x"] + otp: ["24.x", "25.x", "26.x", "27.x"] + exclude: + # Elixir 1.17 and 1.18 don't support OTP 24 + - elixir: "1.17.x" + otp: "24.x" + - elixir: "1.18.x" + otp: "24.x" + # Elixir 1.14, 1.15 and 1.16 don't support OTP 27 + - elixir: "1.15.x" + otp: "27.x" + - elixir: "1.16.x" + otp: "27.x" + - elixir: "1.14.x" + otp: "27.x" steps: - uses: actions/checkout@v3 @@ -28,14 +47,44 @@ jobs: uses: actions/cache@v3 with: path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}-${{ matrix.elixir }}-${{ matrix.otp }} + # Fallback to same elixir version, then same lockfile, then any cache + restore-keys: | + ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}-${{ matrix.elixir }}- + ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}- + ${{ runner.os }}-mix- - name: Install dependencies run: mix deps.get - - name: Check formatting - run: mix format --check-formatted - - name: Run tests run: mix test + + format: + name: Format code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: 1.18.3 + otp-version: 27.3 + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}-1.18.3-27.3 + # Fallback to same elixir version, then same lockfile, then any cache + restore-keys: | + ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}-1.18.3- + ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}- + ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get + + - name: Check formatting + run: mix format --check-formatted diff --git a/.tool-versions b/.tool-versions index 51acfef..cf4dd14 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.16.3 -erlang 26.2.2 +elixir 1.18.3 +erlang 27.3.3 diff --git a/README.md b/README.md index 0fd5061..b9079d7 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,48 @@ Add `posthog` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:posthog, "~> 0.3"} + {:posthog, "~> 1.0"} ] end ``` +You'll also need to include this library under your application tree. You can do so by including `:posthog` under your `:extra_applications` key inside `mix.exs` + +```elixir +# mix.exs +def application do + [ + extra_applications: [ + # ... your existing applications + :posthog + ] + ] +``` + +### Application Customization + +This library includes `Posthog.Application` because we bundle `Cachex` to avoid you from being charged too often for feature-flag checks against the same `{flag, distinct_id}` tuple. + +This cache is located under `:posthog_feature_flag_cache`. If you want more control over the application, you can init it yourself in your own `application.ex` + +```elixir +# lib/my_app/application.ex + +defmodule MyApp.Application do + use Application + + def start(_type, _args) do + children = [ + # Your other application children... + {Posthog.Application, []} + ] + + opts = [strategy: :one_for_one, name: MyApp.Supervisor] + Supervisor.start_link(children, opts) + end +end +``` + ## Configuration Add your PostHog configuration to your application's config: @@ -37,8 +74,7 @@ config :posthog, # Optional configurations config :posthog, - json_library: Jason, # Default JSON parser (optional) - version: 3 # API version (optional, defaults to 3) + json_library: Jason # Default JSON parser (optional) ``` ## Usage @@ -109,13 +145,13 @@ Check specific feature flag: ```elixir # Boolean feature flag {:ok, flag} = Posthog.feature_flag("new-dashboard", "user_123") -# Returns: %Posthog.FeatureFlag{name: "new-dashboard", value: true, enabled: true} +# Returns: %Posthog.FeatureFlag{name: "new-dashboard", payload: true, enabled: true} # Multivariate feature flag {:ok, flag} = Posthog.feature_flag("pricing-test", "user_123") # Returns: %Posthog.FeatureFlag{ # name: "pricing-test", -# value: %{"price" => 99, "period" => "monthly"}, +# payload: %{"price" => 99, "period" => "monthly"}, # enabled: "variant-a" # } @@ -135,6 +171,15 @@ Posthog.feature_flags("user_123", ) ``` +#### Stop sending `$feature_flag_called` + +We automatically send `$feature_flag_called` events so that you can properly keep track of which feature flags you're accessing via `Posthog.feature_flag()` calls. If you wanna save some events, you can disable this by adding `send_feature_flag_event: false` to the call: + +```elixir +# Boolean feature flag +{:ok, flag} = Posthog.feature_flag("new-dashboard", "user_123", send_feature_flag_event: false) +``` + ## Local Development Run `bin/setup` to install development dependencies or run the following commands manually: @@ -158,6 +203,12 @@ bin/test (This runs `mix test`). +Format code: + +```sh +bin/fmt +``` + ### Troubleshooting If you encounter WX library issues during Erlang installation: diff --git a/bin/fmt b/bin/fmt new file mode 100755 index 0000000..68d9478 --- /dev/null +++ b/bin/fmt @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#/ Usage: bin/test +#/ Description: Runs all the unit tests for this project +source bin/helpers/_utils.sh +set_source_and_root_dir + +mix format \ No newline at end of file diff --git a/examples/feature_flag_demo/.tool-versions b/examples/feature_flag_demo/.tool-versions new file mode 100644 index 0000000..cf4dd14 --- /dev/null +++ b/examples/feature_flag_demo/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.18.3 +erlang 27.3.3 diff --git a/examples/feature_flag_demo/README.md b/examples/feature_flag_demo/README.md index 22ba68a..ffec147 100644 --- a/examples/feature_flag_demo/README.md +++ b/examples/feature_flag_demo/README.md @@ -1,37 +1,37 @@ -# PostHog Feature Flag Demo +# Feature Flag Demo -A simple console application that demonstrates how to use PostHog's feature flag functionality. +A simple console application to demonstrate PostHog feature flag functionality. ## Setup -1. Make sure you have the required environment variables set: +1. Install dependencies: + ```bash + bin/setup + ``` -```bash -export POSTHOG_API_KEY="your_project_api_key" -export POSTHOG_API_URL="https://app.posthog.com" # or your self-hosted instance -``` - -2. Install dependencies: - -```bash -mix deps.get -``` +2. Set your PostHog API key and URL: + ```bash + export POSTHOG_API_KEY="your_project_api_key" + export POSTHOG_API_URL="https://app.posthog.com" # Or your self-hosted instance + ``` ## Usage -Basic usage: - +Run the demo with: ```bash -mix run run.exs --flag "your-feature-flag" --distinct-id "user123" +mix run run.exs --flag FLAG_NAME --distinct-id USER_ID [options] ``` -With group properties: +Options: +- `--flag FLAG_NAME` - The name of the feature flag to check +- `--distinct-id USER_ID` - The distinct ID of the user +- `--groups GROUPS` - JSON string of group properties (optional) +- `--group_properties PROPERTIES` - JSON string of group properties (optional) +- `--person_properties PROPERTIES` - JSON string of person properties (optional) +Example: ```bash -mix run run.exs --flag "your-feature-flag" --distinct-id "user123" \ - --groups '{"company": "company123"}' \ - --group_properties '{"company": {"industry": "tech"}}' \ - --person_properties '{"email": "user@example.com"}' +mix run run.exs --flag "test-flag" --distinct-id "user123" ``` ## Example Output @@ -40,7 +40,7 @@ If the feature flag is enabled: ```bash Feature flag 'your-feature-flag' is ENABLED -Value: true +Payload: true ``` If the feature flag is disabled: diff --git a/examples/feature_flag_demo/bin/setup b/examples/feature_flag_demo/bin/setup new file mode 100755 index 0000000..52e94ee --- /dev/null +++ b/examples/feature_flag_demo/bin/setup @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e + +cd "$(dirname "$0")/.." + +echo "Installing dependencies..." +mix deps.get + +echo "Setup complete!" \ No newline at end of file diff --git a/examples/feature_flag_demo/config/config.exs b/examples/feature_flag_demo/config/config.exs index 8ef2483..a8bc44b 100644 --- a/examples/feature_flag_demo/config/config.exs +++ b/examples/feature_flag_demo/config/config.exs @@ -1,8 +1,8 @@ import Config # Remove trailing slash from API URL if present -api_url = System.get_env("POSTHOG_API_URL", "https://app.posthog.com") -api_url = if String.ends_with?(api_url, "/"), do: String.slice(api_url, 0..-2), else: api_url +api_url = System.get_env("POSTHOG_API_URL", "https://us.posthog.com") +api_url = if String.ends_with?(api_url, "/"), do: String.slice(api_url, 0..-2//-1), else: api_url config :posthog, api_url: api_url, diff --git a/examples/feature_flag_demo/lib/feature_flag_demo.ex b/examples/feature_flag_demo/lib/feature_flag_demo.ex index 6e68087..9e251fb 100644 --- a/examples/feature_flag_demo/lib/feature_flag_demo.ex +++ b/examples/feature_flag_demo/lib/feature_flag_demo.ex @@ -50,7 +50,7 @@ defmodule FeatureFlagDemo do distinct_id = Keyword.get(opts, :distinct_id) if is_nil(flag) or is_nil(distinct_id) do - IO.puts("Error: --flag and --distinct-id are required") + IO.puts("Error: --flag and --distinct-id are both required") process([]) else check_feature_flag(flag, distinct_id, opts) @@ -69,9 +69,9 @@ defmodule FeatureFlagDemo do group_properties: group_properties, person_properties: person_properties ) do - {:ok, %{enabled: true, value: value}} -> + {:ok, %{enabled: true, payload: payload}} -> IO.puts("Feature flag '#{flag}' is ENABLED") - IO.puts("Value: #{inspect(value)}") + IO.puts("Payload: #{inspect(payload)}") {:ok, %{enabled: false}} -> IO.puts("Feature flag '#{flag}' is DISABLED") diff --git a/examples/feature_flag_demo/lib/feature_flag_demo/application.ex b/examples/feature_flag_demo/lib/feature_flag_demo/application.ex new file mode 100644 index 0000000..f8a02ee --- /dev/null +++ b/examples/feature_flag_demo/lib/feature_flag_demo/application.ex @@ -0,0 +1,12 @@ +defmodule FeatureFlagDemo.Application do + @moduledoc false + + use Application + + def start(_type, _args) do + children = [] + + opts = [strategy: :one_for_one, name: FeatureFlagDemo.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/examples/feature_flag_demo/mix.exs b/examples/feature_flag_demo/mix.exs index f752617..ad940c1 100644 --- a/examples/feature_flag_demo/mix.exs +++ b/examples/feature_flag_demo/mix.exs @@ -13,7 +13,8 @@ defmodule FeatureFlagDemo.MixProject do def application do [ - extra_applications: [:logger] + extra_applications: [:logger, :posthog], + mod: {FeatureFlagDemo.Application, []} ] end @@ -23,4 +24,4 @@ defmodule FeatureFlagDemo.MixProject do {:jason, "~> 1.4"} ] end -end \ No newline at end of file +end diff --git a/examples/feature_flag_demo/mix.lock b/examples/feature_flag_demo/mix.lock index 4b1b50c..363127b 100644 --- a/examples/feature_flag_demo/mix.lock +++ b/examples/feature_flag_demo/mix.lock @@ -1,11 +1,17 @@ %{ + "cachex": {:hex, :cachex, "4.0.4", "192b5a34ae7f2c866cf835d796005c31ccf65e50ee973fbbbda6c773c0f40322", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "a0417593fcca4b6bd0330bb3bbd507c379d5287213ab990dbc0dd704cedede0a"}, "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, + "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, + "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, } diff --git a/lib/posthog.ex b/lib/posthog.ex index 677f49d..78ac1f8 100644 --- a/lib/posthog.ex +++ b/lib/posthog.ex @@ -1,3 +1,5 @@ +require Logger + defmodule Posthog do @moduledoc """ A comprehensive Elixir client for PostHog's analytics and feature flag APIs. @@ -118,6 +120,18 @@ defmodule Posthog do """ @typep result() :: {:ok, term()} | {:error, term()} @typep timestamp() :: DateTime.t() | NaiveDateTime.t() | String.t() | nil + @typep cache_key() :: {:feature_flag_called, binary(), binary()} + @typep feature_flag_called_event_properties_key() :: + :"$feature_flag" + | :"$feature_flag_response" + | :"$feature_flag_id" + | :"$feature_flag_version" + | :"$feature_flag_reason" + | :"$feature_flag_request_id" + | :distinct_id + @typep feature_flag_called_event_properties() :: %{ + feature_flag_called_event_properties_key() => any() | nil + } alias Posthog.{Client, FeatureFlag} @@ -185,28 +199,112 @@ defmodule Posthog do # Boolean feature flag {:ok, flag} = Posthog.feature_flag("new-dashboard", "user_123") - # Returns: %Posthog.FeatureFlag{name: "new-dashboard", value: true, enabled: true} + # Returns: %Posthog.FeatureFlag{name: "new-dashboard", payload: true, enabled: true} # Multivariate feature flag {:ok, flag} = Posthog.feature_flag("pricing-test", "user_123") # Returns: %Posthog.FeatureFlag{ # name: "pricing-test", - # value: %{"price" => 99, "period" => "monthly"}, + # payload: %{"price" => 99, "period" => "monthly"}, # enabled: "variant-a" # } """ - @spec feature_flag(binary(), binary(), keyword()) :: result() + @spec feature_flag(binary(), binary(), Client.feature_flag_opts()) :: result() def feature_flag(flag, distinct_id, opts \\ []) do - with {:ok, %{feature_flags: flags, feature_flag_payloads: feature_flag_payloads}} <- - feature_flags(distinct_id, opts), - enabled when not is_nil(enabled) <- flags[flag] do - {:ok, FeatureFlag.new(flag, enabled, Map.get(feature_flag_payloads, flag))} + with {:ok, response} <- Client._decide_request(distinct_id, opts), + enabled when not is_nil(enabled) <- response.feature_flags[flag] do + # Only capture if send_feature_flag_event is true (default) + if Keyword.get(opts, :send_feature_flag_event, true), + do: + capture_feature_flag_called_event( + %{ + "distinct_id" => distinct_id, + "$feature_flag" => flag, + "$feature_flag_response" => enabled + }, + response + ) + + {:ok, FeatureFlag.new(flag, enabled, Map.get(response.feature_flag_payloads, flag))} else {:error, _} = err -> err nil -> {:error, :not_found} end end + @spec capture_feature_flag_called_event(feature_flag_called_event_properties(), map()) :: + :ok + defp capture_feature_flag_called_event(properties, response) do + # Create a unique key for this distinct_id and flag combination + cache_key = {:feature_flag_called, properties["distinct_id"], properties["$feature_flag"]} + + # Check if we've seen this combination before using Cachex + case Cachex.exists?(Posthog.Application.cache_name(), cache_key) do + {:ok, false} -> + do_capture_feature_flag_called_event(cache_key, properties, response) + + # Should be `{:error, :no_cache}` but Dyalixir is wrongly assuming that doesn't exist + {:error, _} -> + # Cache doesn't exist, let's capture the event PLUS notify user they should be initing it + do_capture_feature_flag_called_event(cache_key, properties, response) + + Logger.error(""" + [posthog] Cachex process `#{inspect(Posthog.Application.cache_name())}` is not running. + + ➤ This likely means you forgot to include `posthog` as an application dependency (mix.exs): + + Example: + + extra_applications: [..., :posthog] + + + ➤ Or, add `Posthog.Application` to your supervision tree (lib/my_lib/application.ex). + + Example: + {Posthog.Application, []} + """) + + {:ok, true} -> + # Entry already exists, no need to do anything + :ok + end + end + + @spec do_capture_feature_flag_called_event( + cache_key(), + feature_flag_called_event_properties(), + map() + ) :: :ok + defp do_capture_feature_flag_called_event(cache_key, properties, response) do + flag = properties["$feature_flag"] + + properties = + if Map.has_key?(response, :flags) do + Map.merge(properties, %{ + "$feature_flag_id" => response.flags[flag]["metadata"]["id"], + "$feature_flag_version" => response.flags[flag]["metadata"]["version"], + "$feature_flag_reason" => response.flags[flag]["reason"]["description"] + }) + else + properties + end + + properties = + if Map.get(response, :request_id) do + Map.put(properties, "$feature_flag_request_id", response.request_id) + else + properties + end + + # Send the event to our server + Client.capture("$feature_flag_called", properties, []) + + # Add new entry to cache using Cachex + Cachex.put(Posthog.Application.cache_name(), cache_key, true) + + :ok + end + @doc """ Checks if a feature flag is enabled for a given distinct ID. diff --git a/lib/posthog/application.ex b/lib/posthog/application.ex new file mode 100644 index 0000000..120ecdb --- /dev/null +++ b/lib/posthog/application.ex @@ -0,0 +1,39 @@ +defmodule Posthog.Application do + @moduledoc false + + use Application + import Cachex.Spec + + @cache_name :posthog_feature_flag_cache + def cache_name, do: @cache_name + + def start(_type, args) do + cache_name = Keyword.get(args, :cache_name, @cache_name) + + children = [ + # Start Cachex for feature flag event deduplication. + # The 50,000 entries limit is the same used for `posthog-python`, but otherwise arbitrary. + {Cachex, + name: cache_name, + hooks: [ + # Turns this into a LRU cache by writing to the log when an item is accessed + hook(module: Cachex.Limit.Accessed), + + # Runs a `Cachex.prune/3` call every X seconds (see below) to keep it under the entries limit + hook( + module: Cachex.Limit.Scheduled, + args: { + 50_000, + # options for `Cachex.prune/3` + [], + # options for `Cachex.Limit.Scheduled`, run every 10 seconds + [frequency: 10_000] + } + ) + ]} + ] + + opts = [strategy: :one_for_one, name: Posthog.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/posthog/client.ex b/lib/posthog/client.ex index f4253f2..b374f78 100644 --- a/lib/posthog/client.ex +++ b/lib/posthog/client.ex @@ -95,6 +95,13 @@ defmodule Posthog.Client do timestamp: timestamp() ] + @typedoc """ + Feature flag specific options that should not be passed to capture events. + + * `:send_feature_flag_event` - Whether to capture the `$feature_flag_called` event (default: true) + """ + @type feature_flag_opts :: opts() | [send_feature_flag_event: boolean()] + @lib_version Mix.Project.config()[:version] @lib_name "posthog-elixir" @@ -206,18 +213,60 @@ defmodule Posthog.Client do @spec feature_flags(binary(), opts()) :: {:ok, Posthog.FeatureFlag.flag_response()} | {:error, response() | term()} def feature_flags(distinct_id, opts) do + case _decide_request(distinct_id, opts) do + {:ok, response} -> + {:ok, + %{ + feature_flags: response.feature_flags, + feature_flag_payloads: response.feature_flag_payloads + }} + + err -> + err + end + end + + @doc false + def _decide_request(distinct_id, opts) do body = opts |> Keyword.take(~w[groups group_properties person_properties]a) - |> Enum.reduce(%{distinct_id: distinct_id}, fn {k, v}, map -> Map.put(map, k, v) end) + |> Enum.reduce(%{distinct_id: distinct_id}, fn {k, v}, acc -> Map.put(acc, k, v) end) - case post!("/decide", body, headers(opts[:headers])) do + case post!("/decide?v=4", body, headers(opts[:headers])) do {:ok, %{body: body}} -> - {:ok, - %{ - feature_flags: Map.get(body, "featureFlags", %{}), - feature_flag_payloads: decode_feature_flag_payloads(body) - }} + if Map.has_key?(body, "flags") do + flags = body["flags"] + + feature_flags = + Map.new(flags, fn {k, v} -> + {k, if(v["variant"], do: v["variant"], else: v["enabled"])} + end) + + feature_flag_payloads = + Map.new(flags, fn {k, v} -> + {k, + if(v["metadata"]["payload"], + do: decode_feature_flag_payload(v["metadata"]["payload"]), + else: nil + )} + end) + + {:ok, + %{ + flags: flags, + feature_flags: feature_flags, + feature_flag_payloads: feature_flag_payloads, + request_id: body["requestId"] + }} + else + {:ok, + %{ + feature_flags: Map.get(body, "featureFlags", %{}), + feature_flag_payloads: decode_feature_flag_payloads(body), + request_id: body["requestId"] + }} + end err -> err @@ -268,7 +317,7 @@ defmodule Posthog.Client do |> Map.put(:api_key, api_key()) |> encode(json_library()) - url = api_url() <> path <> "?v=#{api_version()}" + url = api_url() <> path :hackney.post(url, headers, body, []) |> handle() @@ -350,12 +399,6 @@ defmodule Posthog.Client do Application.get_env(@app, :json_library, Jason) end - @doc false - @spec api_version() :: pos_integer() - defp api_version do - Application.get_env(@app, :version, 3) - end - @doc false @spec lib_properties() :: map() defp lib_properties do diff --git a/lib/posthog/feature_flag.ex b/lib/posthog/feature_flag.ex index 8b41b9a..4fec6c1 100644 --- a/lib/posthog/feature_flag.ex +++ b/lib/posthog/feature_flag.ex @@ -9,7 +9,7 @@ defmodule Posthog.FeatureFlag do The `FeatureFlag` struct contains: * `name` - The name of the feature flag - * `value` - The payload value associated with the flag (can be any term) + * `payload` - The payload value associated with the flag (can be any term) * `enabled` - The evaluation result (boolean for on/off flags, string for multivariate flags) ## Examples @@ -17,18 +17,18 @@ defmodule Posthog.FeatureFlag do # Boolean feature flag %Posthog.FeatureFlag{ name: "new-dashboard", - value: true, + payload: true, enabled: true } # Multivariate feature flag %Posthog.FeatureFlag{ name: "pricing-test", - value: %{"price" => 99, "period" => "monthly"}, + payload: %{"price" => 99, "period" => "monthly"}, enabled: "variant-a" } """ - defstruct [:name, :value, :enabled] + defstruct [:name, :payload, :enabled] @typedoc """ Represents the enabled state of a feature flag. @@ -55,12 +55,12 @@ defmodule Posthog.FeatureFlag do Fields: * `name` - The name of the feature flag (string) - * `value` - The payload value associated with the flag (any term) + * `payload` - The payload value associated with the flag (any term) * `enabled` - The evaluation result (boolean or string) """ @type t :: %__MODULE__{ name: binary(), - value: term(), + payload: term(), enabled: variant() } @@ -71,7 +71,7 @@ defmodule Posthog.FeatureFlag do * `name` - The name of the feature flag * `enabled` - The evaluation result (boolean or string) - * `value` - The payload value associated with the flag + * `payload` - The payload value associated with the flag ## Examples @@ -83,8 +83,8 @@ defmodule Posthog.FeatureFlag do %{"price" => 99, "period" => "monthly"}) """ @spec new(binary(), variant(), term()) :: t() - def new(name, enabled, value) do - struct!(__MODULE__, name: name, enabled: enabled, value: value) + def new(name, enabled, payload) do + struct!(__MODULE__, name: name, enabled: enabled, payload: payload) end @doc """ diff --git a/lib/posthog/guard.ex b/lib/posthog/guard.ex index 5ebacf2..039d9f4 100644 --- a/lib/posthog/guard.ex +++ b/lib/posthog/guard.ex @@ -17,10 +17,10 @@ defmodule Posthog.Guard do iex> import Posthog.Guard iex> match?({:ok, val} when is_keyword_list(val), {:ok, [foo: 1, bar: 2]}) true - iex> match?({:ok, val} when is_keyword_list(val), {:ok, [1, 2, 3]}) - false iex> match?({:ok, val} when is_keyword_list(val), {:ok, [{:a, 1}, {:b, 2}]}) true + iex> match?({:ok, val} when is_keyword_list(val), {:ok, [{1, 2}, {3, 4}]}) + false """ defguard is_keyword_list(term) when is_list(term) and length(term) > 0 and diff --git a/mix.exs b/mix.exs index 5b7f75e..8e357c3 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Posthog.MixProject do app: :posthog, deps: deps(), description: description(), - elixir: "~> 1.12", + elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), package: package(), docs: docs(), @@ -19,7 +19,8 @@ defmodule Posthog.MixProject do def application do [ - extra_applications: [:logger] + extra_applications: [:logger], + mod: {Posthog.Application, []} ] end @@ -58,7 +59,8 @@ defmodule Posthog.MixProject do {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:hackney, "~> 1.20"}, {:jason, "~> 1.4", optional: true}, - {:mimic, "~> 1.11", only: :test} + {:mimic, "~> 1.11", only: :test}, + {:cachex, "~> 4.0.4"} ] end end diff --git a/mix.lock b/mix.lock index a0d2297..d59e7d4 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,17 @@ %{ + "cachex": {:hex, :cachex, "4.0.4", "192b5a34ae7f2c866cf835d796005c31ccf65e50ee973fbbbda6c773c0f40322", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "a0417593fcca4b6bd0330bb3bbd507c379d5287213ab990dbc0dd704cedede0a"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, + "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, @@ -16,6 +20,8 @@ "mimic": {:hex, :mimic, "1.11.0", "49b126687520b6e179acab305068ad7d72bfea8abe94908a6c0c8ca0a5b7bdc7", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "8b16b1809ca947cffbaede146cd42da8c1c326af67a84b59b01c204d54e4f1a2"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, } diff --git a/test/posthog/client_test.exs b/test/posthog/client_test.exs index 6d6d91f..34951f2 100644 --- a/test/posthog/client_test.exs +++ b/test/posthog/client_test.exs @@ -4,6 +4,7 @@ defmodule Posthog.ClientTest do # Make private functions testable @moduletag :capture_log import Posthog.Client, only: [], warn: false + import Mimic alias Posthog.Client describe "build_event/3" do @@ -77,4 +78,55 @@ defmodule Posthog.ClientTest do } end end + + describe "capture/3" do + test "captures an event with basic properties" do + stub(:hackney, :post, fn url, headers, body, _opts -> + assert url == "https://us.posthog.com/capture" + assert headers == [{"content-type", "application/json"}] + decoded = Jason.decode!(body) + assert decoded["event"] == "test_event" + assert decoded["properties"]["distinct_id"] == "user_123" + {:ok, 200, [], "ref"} + end) + + stub(:hackney, :body, fn "ref" -> {:ok, "{}"} end) + + assert {:ok, %{status: 200}} = Client.capture("test_event", %{distinct_id: "user_123"}, []) + end + + test "captures an event with timestamp" do + timestamp = "2024-03-20T12:00:00Z" + + stub(:hackney, :post, fn _url, _headers, body, _opts -> + decoded = Jason.decode!(body) + assert decoded["timestamp"] == timestamp + {:ok, 200, [], "ref"} + end) + + stub(:hackney, :body, fn "ref" -> {:ok, "{}"} end) + + assert {:ok, %{status: 200}} = + Client.capture("test_event", %{distinct_id: "user_123"}, timestamp: timestamp) + end + + test "captures an event with custom headers" do + stub(:hackney, :post, fn _url, headers, _body, _opts -> + assert Enum.sort(headers) == + Enum.sort([ + {"content-type", "application/json"}, + {"x-forwarded-for", "127.0.0.1"} + ]) + + {:ok, 200, [], "ref"} + end) + + stub(:hackney, :body, fn "ref" -> {:ok, "{}"} end) + + assert {:ok, %{status: 200}} = + Client.capture("test_event", %{distinct_id: "user_123"}, + headers: [{"x-forwarded-for", "127.0.0.1"}] + ) + end + end end diff --git a/test/posthog_test.exs b/test/posthog_test.exs index b80e994..a68cd3b 100644 --- a/test/posthog_test.exs +++ b/test/posthog_test.exs @@ -2,28 +2,210 @@ defmodule PosthogTest do use ExUnit.Case, async: true import Mimic + setup do + # Clear the cache before each test + Cachex.clear(Posthog.Application.cache_name()) + stub_with(:hackney, HackneyStub) + {:ok, _} = HackneyStub.State.start_link([]) + :ok + end + describe "feature_flag/3" do - test "when feature flag exists, returns feature flag struct" do + test "when feature flag exists, returns feature flag struct and captures event" do stub_with(:hackney, HackneyStub) - assert Posthog.feature_flag("my-awesome-flag", "user_123") == + HackneyStub.verify_capture(fn decoded -> + assert decoded["event"] == "$feature_flag_called" + assert decoded["properties"]["distinct_id"] == "user_123" + assert decoded["properties"]["$feature_flag"] == "my-awesome-flag" + assert decoded["properties"]["$feature_flag_response"] == true + assert decoded["properties"]["$feature_flag_id"] == 1 + assert decoded["properties"]["$feature_flag_version"] == 23 + assert decoded["properties"]["$feature_flag_reason"] == "Matched condition set 3" + + assert decoded["properties"]["$feature_flag_request_id"] == + "0f801b5b-0776-42ca-b0f7-8375c95730bf" + end) + + assert {:ok, + %Posthog.FeatureFlag{ + name: "my-awesome-flag", + enabled: true, + payload: "example-payload-string" + }} = Posthog.feature_flag("my-awesome-flag", "user_123") + end + + test "when variant flag exists, returns feature flag struct with variant and captures event" do + stub_with(:hackney, HackneyStub) + + HackneyStub.verify_capture(fn decoded -> + assert decoded["event"] == "$feature_flag_called" + assert decoded["properties"]["distinct_id"] == "user_123" + assert decoded["properties"]["$feature_flag"] == "my-multivariate-flag" + assert decoded["properties"]["$feature_flag_response"] == "some-string-value" + assert decoded["properties"]["$feature_flag_id"] == 3 + assert decoded["properties"]["$feature_flag_version"] == 1 + assert decoded["properties"]["$feature_flag_reason"] == "Matched condition set 1" + + assert decoded["properties"]["$feature_flag_request_id"] == + "0f801b5b-0776-42ca-b0f7-8375c95730bf" + end) + + assert {:ok, + %Posthog.FeatureFlag{ + name: "my-multivariate-flag", + enabled: "some-string-value", + payload: nil + }} = Posthog.feature_flag("my-multivariate-flag", "user_123") + end + + test "Does not capture feature_flag_called event twice for same distinct_id and flag key" do + stub_with(:hackney, HackneyStub) + copy(Posthog.Client) + + # Initialize the counter in the process dictionary + Process.put(:capture_count, 0) + + stub(Posthog.Client, :capture, fn "$feature_flag_called", properties, _opts -> + # Increment the counter in the process dictionary + Process.put(:capture_count, Process.get(:capture_count) + 1) + assert properties["distinct_id"] == "user_123" + assert properties["$feature_flag"] == "my-multivariate-flag" + assert properties["$feature_flag_response"] == "some-string-value" + assert properties["$feature_flag_id"] == 3 + assert properties["$feature_flag_version"] == 1 + assert properties["$feature_flag_reason"] == "Matched condition set 1" + assert properties["$feature_flag_request_id"] == "0f801b5b-0776-42ca-b0f7-8375c95730bf" + {:ok, %{status: 200}} + end) + + # First call + assert {:ok, + %Posthog.FeatureFlag{ + name: "my-multivariate-flag", + enabled: "some-string-value", + payload: nil + }} = Posthog.feature_flag("my-multivariate-flag", "user_123") + + # Second call with same parameters + assert {:ok, + %Posthog.FeatureFlag{ + name: "my-multivariate-flag", + enabled: "some-string-value", + payload: nil + }} = Posthog.feature_flag("my-multivariate-flag", "user_123") + + # Verify capture was only called once + assert Process.get(:capture_count) == 1 + end + + test "Captures feature_flag_called event for different user IDs or flag keys" do + stub_with(:hackney, HackneyStub) + copy(Posthog.Client) + + # Initialize the counter in the process dictionary + Process.put(:capture_count, 0) + + # Keep track of seen combinations + Process.put(:seen_combinations, MapSet.new()) + + stub(Posthog.Client, :capture, fn "$feature_flag_called", properties, _opts -> + # Increment the counter in the process dictionary + Process.put(:capture_count, Process.get(:capture_count) + 1) + + # Add this combination to seen combinations + combination = { + properties["distinct_id"], + properties["$feature_flag"], + properties["$feature_flag_response"] + } + + Process.put( + :seen_combinations, + MapSet.put(Process.get(:seen_combinations), combination) + ) + + # Verify properties are correct regardless of order + assert properties["distinct_id"] in ["user_123", "user_456"] + assert properties["$feature_flag"] in ["my-multivariate-flag", "my-awesome-flag"] + assert properties["$feature_flag_response"] in [true, "some-string-value"] + assert properties["$feature_flag_id"] in [1, 3] + assert properties["$feature_flag_version"] in [1, 23] + + assert properties["$feature_flag_reason"] in [ + "Matched condition set 1", + "Matched condition set 3" + ] + + assert properties["$feature_flag_request_id"] == "0f801b5b-0776-42ca-b0f7-8375c95730bf" + + {:ok, %{status: 200}} + end) + + # Call feature_flag with different combinations + assert {:ok, + %Posthog.FeatureFlag{ + name: "my-multivariate-flag", + enabled: "some-string-value", + payload: nil + }} = Posthog.feature_flag("my-multivariate-flag", "user_123") + + assert {:ok, + %Posthog.FeatureFlag{ + name: "my-multivariate-flag", + enabled: "some-string-value", + payload: nil + }} = Posthog.feature_flag("my-multivariate-flag", "user_456") + + assert {:ok, + %Posthog.FeatureFlag{ + name: "my-awesome-flag", + enabled: true, + payload: "example-payload-string" + }} = Posthog.feature_flag("my-awesome-flag", "user_123") + + # Verify we got all three unique combinations + assert Process.get(:capture_count) == 3 + assert MapSet.size(Process.get(:seen_combinations)) == 3 + end + + test "Does not capture event when send_feature_flag_event is false" do + stub_with(:hackney, HackneyStub) + copy(Posthog.Client) + reject(&Posthog.Client.capture/3) + + assert {:ok, + %Posthog.FeatureFlag{ + name: "my-multivariate-flag", + enabled: "some-string-value", + payload: nil + }} = + Posthog.feature_flag("my-multivariate-flag", "user_123", + send_feature_flag_event: false + ) + end + + test "when feature flag has a json payload, will return decoded payload" do + stub_with(:hackney, HackneyStub) + + assert Posthog.feature_flag("my-awesome-flag-2", "user_123") == {:ok, %Posthog.FeatureFlag{ enabled: true, - name: "my-awesome-flag", - value: "example-payload-string" + name: "my-awesome-flag-2", + payload: %{"color" => "blue", "animal" => "hedgehog"} }} end - test "when feature flag has a json payload, will return decoded payload" do + test "when feature flag has an array payload, will return decoded payload" do stub_with(:hackney, HackneyStub) - assert Posthog.feature_flag("my-awesome-flag-2", "user_123") == + assert Posthog.feature_flag("array-payload", "user_123") == {:ok, %Posthog.FeatureFlag{ enabled: true, - name: "my-awesome-flag-2", - value: %{"color" => "blue", "animal" => "hedgehog"} + name: "array-payload", + payload: [0, 1, 2] }} end @@ -35,12 +217,12 @@ defmodule PosthogTest do %Posthog.FeatureFlag{ enabled: false, name: "flag-thats-not-on", - value: nil + payload: nil }} end test "when feature flag does not exist, returns not_found" do - stub_with(:hackney, HackneyStub) + stub_with(:hackney, HackneyStubV3) assert Posthog.feature_flag("does-not-exist", "user_123") == {:error, :not_found} @@ -51,6 +233,13 @@ defmodule PosthogTest do test "true if the feature flag is enabled" do stub_with(:hackney, HackneyStub) + HackneyStub.verify_capture(fn decoded -> + assert decoded["event"] == "$feature_flag_called" + assert decoded["properties"]["distinct_id"] == "user_123" + assert decoded["properties"]["$feature_flag"] == "my-awesome-flag" + assert decoded["properties"]["$feature_flag_response"] == true + end) + assert Posthog.feature_flag_enabled?("my-awesome-flag", "user_123") end @@ -66,4 +255,81 @@ defmodule PosthogTest do refute Posthog.feature_flag_enabled?("flag-does-not-exist", "user_123") end end + + describe "v3 - feature_flag/3" do + test "when feature flag exists, returns feature flag struct" do + stub_with(:hackney, HackneyStubV3) + + assert Posthog.feature_flag("my-awesome-flag", "user_123") == + {:ok, + %Posthog.FeatureFlag{ + enabled: true, + name: "my-awesome-flag", + payload: "example-payload-string" + }} + end + + test "when feature flag has a json payload, will return decoded payload" do + stub_with(:hackney, HackneyStubV3) + + assert Posthog.feature_flag("my-awesome-flag-2", "user_123") == + {:ok, + %Posthog.FeatureFlag{ + enabled: true, + name: "my-awesome-flag-2", + payload: %{"color" => "blue", "animal" => "hedgehog"} + }} + end + + test "when feature flag has an array payload, will return decoded payload" do + stub_with(:hackney, HackneyStubV3) + + assert Posthog.feature_flag("array-payload", "user_123") == + {:ok, + %Posthog.FeatureFlag{ + enabled: true, + name: "array-payload", + payload: [0, 1, 2] + }} + end + + test "when feature flag does not have a payload, will return flag value" do + stub_with(:hackney, HackneyStubV3) + + assert Posthog.feature_flag("flag-thats-not-on", "user_123") == + {:ok, + %Posthog.FeatureFlag{ + enabled: false, + name: "flag-thats-not-on", + payload: nil + }} + end + + test "when feature flag does not exist, returns not_found" do + stub_with(:hackney, HackneyStubV3) + + assert Posthog.feature_flag("does-not-exist", "user_123") == + {:error, :not_found} + end + end + + describe "v3 - feature_flag_enabled?/3" do + test "true if the feature flag is enabled" do + stub_with(:hackney, HackneyStubV3) + + assert Posthog.feature_flag_enabled?("my-awesome-flag", "user_123") + end + + test "false if the feature flag is disabled" do + stub_with(:hackney, HackneyStubV3) + + refute Posthog.feature_flag_enabled?("flag-thats-not-on", "user_123") + end + + test "false if the feature flag does not exist" do + stub_with(:hackney, HackneyStubV3) + + refute Posthog.feature_flag_enabled?("flag-does-not-exist", "user_123") + end + end end diff --git a/test/support/fixtures/decide-v3.json b/test/support/fixtures/decide-v3.json new file mode 100644 index 0000000..071b0e4 --- /dev/null +++ b/test/support/fixtures/decide-v3.json @@ -0,0 +1,24 @@ +{ + "config": { + "enable_collect_everything": true + }, + "toolbarParams": {}, + "errorsWhileComputingFlags": false, + "isAuthenticated": false, + "supportedCompression": [ + "gzip", + "lz64" + ], + "featureFlags": { + "my-awesome-flag": true, + "my-awesome-flag-2": true, + "my-multivariate-flag": "some-string-value", + "flag-thats-not-on": false, + "array-payload": true + }, + "featureFlagPayloads": { + "my-awesome-flag": "\"example-payload-string\"", + "my-awesome-flag-2": "{\"color\": \"blue\", \"animal\": \"hedgehog\"}", + "array-payload": "[0, 1, 2]" + } +} \ No newline at end of file diff --git a/test/support/fixtures/decide.json b/test/support/fixtures/decide.json index 570bb07..9717088 100644 --- a/test/support/fixtures/decide.json +++ b/test/support/fixtures/decide.json @@ -9,14 +9,82 @@ "gzip", "lz64" ], - "featureFlags": { - "my-awesome-flag": true, - "my-awesome-flag-2": true, - "my-multivariate-flag": "some-string-value", - "flag-thats-not-on": false + "flags": { + "my-awesome-flag": { + "key": "my-awesome-flag", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "condition_index": 2, + "description": "Matched condition set 3" + }, + "metadata": { + "id": 1, + "version": 23, + "payload": "\"example-payload-string\"" + } + }, + "my-awesome-flag-2": { + "key": "my-awesome-flag-2", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "condition_index": 2, + "description": "Matched condition set 3" + }, + "metadata": { + "id": 2, + "version": 1, + "payload": "{\"color\": \"blue\", \"animal\": \"hedgehog\"}" + } + }, + "my-multivariate-flag": { + "key": "my-multivariate-flag", + "enabled": true, + "variant": "some-string-value", + "reason": { + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "metadata": { + "id": 3, + "version": 1, + "payload": null + } + }, + "flag-thats-not-on": { + "key": "flag-thats-not-on", + "enabled": false, + "variant": null, + "reason": { + "code": "no_condition_match", + "condition_index": 0, + "description": "No matching condition set" + }, + "metadata": { + "id": 112102, + "payload": null, + "version": 7 + } + }, + "array-payload": { + "key": "array-payload", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "condition_index": 0, + "description": "Matched condition set 1" + }, + "metadata": { + "id": 112102, + "payload": "[0, 1, 2]", + "version": 7 + } + } }, - "featureFlagPayloads": { - "my-awesome-flag": "example-payload-string", - "my-awesome-flag-2": "{\"color\": \"blue\", \"animal\": \"hedgehog\"}" - } + "requestId": "0f801b5b-0776-42ca-b0f7-8375c95730bf" } \ No newline at end of file diff --git a/test/support/hackney_stub.ex b/test/support/hackney_stub.ex index c12a8aa..6b04924 100644 --- a/test/support/hackney_stub.ex +++ b/test/support/hackney_stub.ex @@ -1,17 +1,83 @@ -defmodule HackneyStub do - @fixtures_dir Path.join(__DIR__, "fixtures") +defmodule HackneyStub.State do + use GenServer + + def start_link(_opts) do + name = {:via, Registry, {:hackney_stub_registry, self()}} + GenServer.start_link(__MODULE__, %{verification: nil}, name: name) + end - def post("https://us.posthog.com/decide?v=3", _headers, _body, _opts) do - {:ok, 200, json_fixture!("decide.json"), "decide"} + def init(state) do + {:ok, state} end - def body("decide") do - {:ok, json_fixture!("decide.json")} + def set_verification(verification) do + name = {:via, Registry, {:hackney_stub_registry, self()}} + GenServer.cast(name, {:set_verification, verification}) end - defp json_fixture!(fixture) do - @fixtures_dir - |> Path.join(fixture) - |> File.read!() + def get_verification() do + name = {:via, Registry, {:hackney_stub_registry, self()}} + GenServer.call(name, :get_verification) + end + + def handle_cast({:set_verification, verification}, state) do + {:noreply, %{state | verification: verification}} + end + + def handle_call(:get_verification, _from, state) do + {:reply, state.verification, state} + end +end + +defmodule HackneyStub.Base do + @fixtures_dir Path.join(__DIR__, "fixtures") + + defmacro __using__(fixture) do + quote do + @fixtures_dir unquote(@fixtures_dir) + + def post("https://us.posthog.com/decide?v=4", _headers, _body, _opts) do + {:ok, 200, json_fixture!(unquote(fixture)), "decide"} + end + + def post("https://us.posthog.com/capture", _headers, body, _opts) do + case HackneyStub.State.get_verification() do + nil -> + :ok + + verification -> + decoded = Jason.decode!(body) + verification.(decoded) + end + + {:ok, 200, [], "capture"} + end + + def body("decide") do + {:ok, json_fixture!(unquote(fixture))} + end + + def body("capture") do + {:ok, "{}"} + end + + defp json_fixture!(fixture) do + @fixtures_dir + |> Path.join(fixture) + |> File.read!() + end + + def verify_capture(verification) do + HackneyStub.State.set_verification(verification) + end + end end end + +defmodule HackneyStub do + use HackneyStub.Base, "decide.json" +end + +defmodule HackneyStubV3 do + use HackneyStub.Base, "decide-v3.json" +end diff --git a/test/test_helper.exs b/test/test_helper.exs index cd474d4..8aec270 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,6 @@ Mimic.copy(:hackney) +{:ok, _} = Registry.start_link(keys: :unique, name: :hackney_stub_registry) +{:ok, _} = HackneyStub.State.start_link([]) + ExUnit.start()