diff --git a/.gitignore b/.gitignore index 7c288a7..41c4b58 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ posthog-*.tar # Ignore .DS_Store .DS_Store + +# Local Config +config/dev.exs +config/integration.exs diff --git a/.tool-versions b/.tool-versions index cf4dd14..3be0b2c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.18.3 +elixir 1.18.3-otp-27 erlang 27.3.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7c5d9..c629903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,10 @@ - Elixir v1.14+ is now a requirement - Feature Flags now return a key called `payload` rather than `value` to better align with the other SDKs -- PostHog now requires you to initialize `Posthog.Application` alongside your supervisor tree. This is required because of our `Cachex` system to properly track your FF usage. +- PostHog now requires you to initialize `PostHog.Application` alongside your supervisor tree. This is required because of our `Cachex` system to properly track your FF usage. - We'll also include local evaluation in the near term, which will also require a GenServer, therefore, requiring us to use a Supervisor. - Added `enabled_capture` configuration option to disable PostHog tracking in development/test environments -- `Posthog.capture` now requires `distinct_id` as a required second argument +- `PostHog.capture` now requires `distinct_id` as a required second argument ## 0.4.4 - 2025-04-14 diff --git a/MIGRATION.md b/MIGRATION.md index 2d51cd9..0082d9c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2,7 +2,11 @@ This is a migration guide for all major version bumps -## v0-v1 +## v1 -> v2 + +TODO: New library + +## v0 -> v1 When we stabilized our library, we decided to pull some breaking changes, here are they and how you can migrate: @@ -17,12 +21,12 @@ The library previously supported Elixir v1.12+. You'll need to migrate to Elixir PostHog is consistently upgrading our internal data representation so that's better for customers each and every time. We've recently launched a new version of our `/decide` endpoint called `v4`. This endpoint is slightly different, which caused a small change in behavior for our flags. -`Posthog.FeatureFlag` previously included a key `value` that to represent the internal structure of a flag. It was renamed to `payload` to: +`PostHog.FeatureFlag` previously included a key `value` that to represent the internal structure of a flag. It was renamed to `payload` to: 1. better represent the fact that it can be both an object and a boolean 2. align it more closely with our other SDKs -### Posthog.Application +### PostHog.Application This library now depends on `Cachex`, and includes a supervision tree. There are 2 options: @@ -39,7 +43,7 @@ def application do end ``` -2. Or, if you're already using an Application, you can add add `Posthog.Application` to your own supervision tree: +2. Or, if you're already using an Application, you can add add `PostHog.Application` to your own supervision tree: ```elixir # lib/my_app/application.ex @@ -49,7 +53,7 @@ defmodule MyApp.Application do def start(_type, _args) do children = [ # Your other children... - {Posthog.Application, []} + {PostHog.Application, []} ] opts = [strategy: :one_for_one, name: MyApp.Supervisor] @@ -58,32 +62,32 @@ defmodule MyApp.Application do end ``` -### `Posthog.capture` new signature +### `PostHog.capture` new signature -The signature to `Posthog.capture` has changed. `distinct_id` is now a required argument. +The signature to `PostHog.capture` has changed. `distinct_id` is now a required argument. Here are some examples on how the method is now used: ```elixir # Basic event with `event` and `distinct_id`, both required -Posthog.capture("page_view", "user_123") +PostHog.capture("page_view", "user_123") # Event with properties -Posthog.capture("purchase", "user_123", %{ +PostHog.capture("purchase", "user_123", %{ product_id: "prod_123", price: 99.99, currency: "USD" }) # Event with custom timestamp -Posthog.capture("signup_completed", "user_123", %{}, timestamp: DateTime.utc_now()) +PostHog.capture("signup_completed", "user_123", %{}, timestamp: DateTime.utc_now()) # Event with custom UUID uuid = "..." -Posthog.capture("signup_completed", "user_123", %{}, uuid: uuid) +PostHog.capture("signup_completed", "user_123", %{}, uuid: uuid) # Event with custom headers -Posthog.capture( +PostHog.capture( "login", "user_123", %{}, diff --git a/README.md b/README.md index 117eef2..d0d6bd4 100644 --- a/README.md +++ b/README.md @@ -1,293 +1,169 @@ -# PostHog Elixir Client +# PostHog Elixir SDK [![Hex.pm](https://img.shields.io/hexpm/v/posthog.svg)](https://hex.pm/packages/posthog) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/posthog) -A powerful Elixir client for [PostHog](https://posthog.com), providing seamless integration with PostHog's analytics and feature flag APIs. +A powerful Elixir SDK for [PostHog](https://posthog.com) ## Features -- Event Capture: Track user actions and custom events -- Feature Flags: Manage feature flags and multivariate tests -- Batch Processing: Send multiple events efficiently -- Custom Properties: Support for user, group, and person properties -- Flexible Configuration: Customizable JSON library and API version -- Environment Control: Disable tracking in development/test environments -- Configurable HTTP Client: Customizable timeouts, retries, and HTTP client implementation +* Analytics and Feature Flags support +* Error tracking support +* Powerful process-based context propagation +* Asynchronous event sending with built-in batching +* Overridable HTTP client +* Support for multiple PostHog projects -## Installation +## Getting Started -Add `posthog` to your list of dependencies in `mix.exs`: +Add `PostHog` to your dependencies: ```elixir def deps do [ - {:posthog, "~> 1.0"} + {:posthog, "~> 0.3"} ] end ``` -## Configuration - -Add your PostHog configuration to your application's config: - -```elixir -# config/config.exs -config :posthog, - api_url: "https://us.posthog.com", # Or `https://eu.posthog.com` or your self-hosted PostHog instance URL - api_key: "phc_your_project_api_key" - -# Optional configurations -config :posthog, - json_library: Jason, # Default JSON parser (optional) - capture_enabled: true, # Whether to enable PostHog tracking (optional, defaults to true) - http_client: Posthog.HTTPClient.Hackney, # Default HTTP client (optional) - http_client_opts: [ # HTTP client options (optional) - timeout: 5_000, # Request timeout in milliseconds (default: 5_000) - retries: 3, # Number of retries on failure (default: 3) - retry_delay: 1_000 # Delay between retries in milliseconds (default: 1_000) - ] -``` - -### HTTP Client Configuration - -The library uses Hackney as the default HTTP client, but you can configure its behavior or even swap it for a different implementation by simply implementing the `Posthog.HTTPClient` behavior: - -```elixir -# config/config.exs -config :posthog, - # Use a different HTTP client implementation - http_client: MyCustomHTTPClient, - - # Configure HTTP client options - http_client_opts: [ - timeout: 10_000, # 10 seconds timeout - retries: 5, # 5 retries - retry_delay: 2_000 # 2 seconds between retries - ] -``` - -For testing, you might want to use a mock HTTP client: +Configure the `PostHog` application environment: ```elixir -# test/support/mocks.ex -defmodule Posthog.HTTPClient.Test do - @behaviour Posthog.HTTPClient - - def post(url, body, headers, _opts) do - # Return mock responses for testing - {:ok, %{status: 200, headers: [], body: %{}}} - end -end - -# config/test.exs config :posthog, - http_client: Posthog.HTTPClient.Test + enable: true, + enable_error_tracking: true, + public_url: "https://us.i.posthog.com", + api_key: "phc_my_api_key", + in_app_otp_apps: [:my_app] ``` -### Disabling PostHog capture - -You can disable PostHog tracking by setting `enabled_capture: false` in your configuration. This is particularly useful in development or test environments where you don't want to send actual events to PostHog. - -When `enabled_capture` is set to `false`: - -- All `Posthog.capture/3` and `Posthog.batch/2` calls will succeed silently -- PostHog will still communicate with the server for Feature Flags +Optionally, enable [Plug integration](`PostHog.Integrations.Plug`) for better Error Tracking -This is useful for: +You're all set! 🎉 For more information on configuration, check the `PostHog.Config` module +documentation and the [advanced configuration guide](advanced-configuration.md). -- Development and test environments where you don't want to pollute your PostHog instance -- Situations where you need to temporarily disable tracking +## Capturing Events -Example configuration for development: +To capture an event, use `PostHog.capture/2`: ```elixir -# config/dev.exs -config :posthog, - enabled_capture: false # Disable tracking in development +iex> PostHog.capture("user_signed_up", %{distinct_id: "distinct_id_of_the_user"}) ``` -Example configuration for test: +You can pass additional properties in the last argument: ```elixir -# config/test.exs -config :posthog, - enabled_capture: false # Disable tracking in test environment +iex> PostHog.capture("user_signed_up", %{ + distinct_id: "distinct_id_of_the_user", + login_type: "email", + is_free_trial: true +}) ``` -## Usage +## Special Events -### Capturing Events +`PostHog.capture/2` is very powerful and allows you to send events that have +special meaning. For example: -Simple event capture: +### Create Alias ```elixir -# Basic event with `event` and `distinct_id`, both required -Posthog.capture("page_view", "user_123") - -# Event with properties -Posthog.capture("purchase", "user_123", %{ - product_id: "prod_123", - price: 99.99, - currency: "USD" -}) - -# Event with custom timestamp -Posthog.capture("signup_completed", "user_123", %{}, timestamp: DateTime.utc_now()) - -# Event with custom UUID -uuid = "..." -Posthog.capture("signup_completed", "user_123", %{}, uuid: uuid) - -# Event with custom headers -Posthog.capture( - "login", - "user_123", - %{}, - headers: [{"x-forwarded-for", "127.0.0.1"}] -) +iex> PostHog.capture("$create_alias", %{distinct_id: "frontend_id", alias: "backend_id"}) ``` -### Batch Processing - -Send multiple events in a single request: +### Group Analytics ```elixir -events = [ - {"page_view", "user_123", %{}}, - {"button_click", "user_123", %{button_id: "signup"}} -] - -Posthog.batch(events) +iex> PostHog.capture("$groupidentify", %{ + distinct_id: "static_string_used_for_all_group_events", + "$group_type": "company", + "$group_key": "company_id_in_your_db" +}) ``` -### Feature Flags +## Context -Get all feature flags for a user: +Carrying `distinct_id` around all the time might not be the most convenient +approach, so `PostHog` lets you store it and other properties in a _context_. +The context is stored in the `Logger` metadata, and PostHog will automatically +attach these properties to any events you capture with `PostHog.capture/3`, as long as they +happen in the same process. ```elixir -{:ok, flags} = Posthog.feature_flags("user_123") - -# Response format: -# %{ -# "featureFlags" => %{"flag-1" => true, "flag-2" => "variant-b"}, -# "featureFlagPayloads" => %{ -# "flag-1" => true, -# "flag-2" => %{"color" => "blue", "size" => "large"} -# } -# } +iex> PostHog.set_context(%{distinct_id: "distinct_id_of_the_user"}) +iex> PostHog.capture("page_opened") ``` -Check specific feature flag: +You can scope context by event name. In this case, it will only be attached to a specific event: ```elixir -# Boolean feature flag -{:ok, flag} = Posthog.feature_flag("new-dashboard", "user_123") -# 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", -# payload: %{"price" => 99, "period" => "monthly"}, -# enabled: "variant-a" -# } - -# Quick boolean check -if Posthog.feature_flag_enabled?("new-dashboard", "user_123") do - # Show new dashboard -end +iex> PostHog.set_event_context("sensitive_event", %{"$process_person_profile": false}) ``` -Feature flags with group properties: +You can always inspect the context: ```elixir -Posthog.feature_flags("user_123", - groups: %{company: "company_123"}, - group_properties: %{company: %{industry: "tech"}}, - person_properties: %{email: "user@example.com"} -) +iex> PostHog.get_context() +%{distinct_id: "distinct_id_of_the_user"} +iex> PostHog.get_event_context("sensitive_event") +%{distinct_id: "distinct_id_of_the_user", "$process_person_profile": true} ``` -#### Stop sending `$feature_flag_called` +## Feature Flags -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: +`PostHog.get_feature_flag/2` is a thin wrapper over the `/flags` API request: ```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: - -We recommend using `asdf` to manage Elixir and Erlang versions: - -```sh -# Install required versions -asdf install - -# Install dependencies -mix deps.get -mix compile -``` - -Run tests: - -```sh -bin/test -``` - -(This runs `mix test`). +# With just distinct_id +iex> PostHog.get_feature_flag("distinct_id_of_the_user") +{:ok, %Req.Response{status: 200, body: %{"flags" => %{...}}}} -Format code: - -```sh -bin/fmt +# With group id for group-based feature flags +iex> PostHog.get_feature_flag(%{distinct_id: "distinct_id_of_the_user", groups: %{group_type: "group_id"}}) +{:ok, %Req.Response{status: 200, body: %{"flags" => %{}}}} ``` -### Troubleshooting - -If you encounter WX library issues during Erlang installation: +Checking for a feature flag is not a trivial operation and comes in all shapes +and sizes, so users are encouraged to write their own helper function for that. +Here's an example of what it might look like: -```sh -# Disable WX during installation -export KERL_CONFIGURE_OPTIONS="--without-wx" +```elixir +defmodule MyApp.PostHogHelper do + def feature_flag(flag_name, distinct_id \\ nil) do + distinct_id = distinct_id || PostHog.get_context() |> Map.fetch!(:distinct_id) + + response = + case PostHog.get_feature_flag(distinct_id) do + {:ok, %{status: 200, body: %{"flags" => %{^flag_name => %{"variant" => variant}}}}} when not is_nil(variant) -> variant + {:ok, %{status: 200, body: %{"flags" => %{^flag_name => %{"enabled" => true}}}}} -> true + _ -> false + end + + PostHog.capture("$feature_flag_called", %{ + distinct_id: distinct_id, + "$feature_flag": flag_name, + "$feature_flag_response": response + }) + + PostHog.set_context(%{"$feature/#{flag_name}" => response}) + response + end +end ``` -To persist this setting, add it to your shell configuration file (`~/.bashrc`, `~/.zshrc`, or `~/.profile`). - -## Examples - -There's an example console project in `examples/feature_flag_demo` that shows how to use the client. Follow the instructions in [the README](examples/feature_flag_demo/README.md) to run it. - -## Development Tools +## Error Tracking -This project uses several development tools to maintain code quality and security: +Error Tracking is enabled by default. -### Credo +![](assets/screenshot.png) -Credo is a static code analysis tool that helps enforce coding standards and catch potential issues. Run it with: +You can always disable it by setting `enable_error_tracking` to false: -```bash -mix credo --strict -``` - -For more detailed output: - -```bash -mix credo --strict --verbose +```elixir +config :posthog, enable_error_tracking: false ``` -## Contributing - -When contributing to this project, please ensure your code passes all the development tool checks: - -1. Run Credo to ensure code style consistency -2. Run Mix Unused to identify any unused code -3. Run the test suite with `mix test` - -## License +## Multiple PostHog Projects -This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. +If your app works with multiple PostHog projects, PostHog can accommodate you. For +setup instructions, consult the [advanced configuration guide](advanced-configuration.md). diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 0000000..4477eb7 Binary files /dev/null and b/assets/screenshot.png differ diff --git a/config/config.exs b/config/config.exs index ecc53c8..d1186fe 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,5 +1,3 @@ import Config -if config_env() == :test do - config :posthog, api_key: "phc_randomrandomrandom", api_url: "https://us.posthog.com" -end +import_config "#{config_env()}.exs" diff --git a/config/integration.example.exs b/config/integration.example.exs new file mode 100644 index 0000000..1d41692 --- /dev/null +++ b/config/integration.example.exs @@ -0,0 +1,8 @@ +import Config + +config :posthog, :integration_config, + public_url: "https://us.i.posthog.com", + api_key: "phc_mykey", + metadata: [:extra], + capture_level: :info, + in_app_otp_apps: [:logger_handler_kit] diff --git a/config/integration.exs b/config/integration.exs new file mode 100644 index 0000000..5f0db85 --- /dev/null +++ b/config/integration.exs @@ -0,0 +1,8 @@ +import Config + +config :posthog, :integration_config, + public_url: "https://us.i.posthog.com", + api_key: "phc_tEQ8UGRHdPBaT42yocd714aoiW4mBkmx6nTNrxjqLpf", + metadata: [:extra], + capture_level: :info, + in_app_otp_apps: [:logger_handler_kit] diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..88d6cb8 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,5 @@ +import Config + +config :posthog, enable: false + +if File.exists?("config/integration.exs"), do: import_config("integration.exs") diff --git a/examples/.gitignore b/examples/.gitignore deleted file mode 100644 index 41b44f5..0000000 --- a/examples/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# The directory Mix will write compiled artifacts to. -/_build/ -**/_build/ - -# If you run "mix test --cover", coverage assets end up here. -**/cover/ - -# The directory Mix downloads your dependencies sources to. -**/deps/ - -# Where third-party dependencies like ExDoc output generated docs. -**/doc/ - -# Ignore .fetch files in case you like to edit your project deps locally. -**/.fetch - -# If the VM crashes, it generates a dump, let's ignore it too. -erl_crash.dump - -# Also ignore archive artifacts (built via "mix archive.build"). -*.ez - -# Ignore package tarball (built via "mix hex.build"). -**/*.tar - - -# Ignore .env used for local development -.env - -# Ignore .DS_Store -.DS_Store diff --git a/examples/feature_flag_demo/.tool-versions b/examples/feature_flag_demo/.tool-versions deleted file mode 100644 index cf4dd14..0000000 --- a/examples/feature_flag_demo/.tool-versions +++ /dev/null @@ -1,2 +0,0 @@ -elixir 1.18.3 -erlang 27.3.3 diff --git a/examples/feature_flag_demo/README.md b/examples/feature_flag_demo/README.md deleted file mode 100644 index e67bfe1..0000000 --- a/examples/feature_flag_demo/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Feature Flag Demo - -A simple console application to demonstrate PostHog feature flag functionality. - -## Setup - -1. Install dependencies: - ```bash - bin/setup - ``` - -2. Set your PostHog API key and URL: - ```bash - export POSTHOG_API_KEY="your_project_api_key" - export POSTHOG_API_URL="https://us.i.posthog.com" # Or your self-hosted instance - ``` - -## Usage - -Run the demo with: -```bash -mix run run.exs --flag FLAG_NAME --distinct-id USER_ID [options] -``` - -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 "test-flag" --distinct-id "user123" -``` - -## Example Output - -If the feature flag is enabled: - -```bash -Feature flag 'your-feature-flag' is ENABLED -Payload: true -``` - -If the feature flag is disabled: - -```bash -Feature flag 'your-feature-flag' is DISABLED -``` diff --git a/examples/feature_flag_demo/bin/setup b/examples/feature_flag_demo/bin/setup deleted file mode 100755 index 52e94ee..0000000 --- a/examples/feature_flag_demo/bin/setup +++ /dev/null @@ -1,9 +0,0 @@ -#!/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 deleted file mode 100644 index a8bc44b..0000000 --- a/examples/feature_flag_demo/config/config.exs +++ /dev/null @@ -1,9 +0,0 @@ -import Config - -# Remove trailing slash from API URL if present -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, - api_key: System.get_env("POSTHOG_API_KEY") diff --git a/examples/feature_flag_demo/lib/feature_flag_demo.ex b/examples/feature_flag_demo/lib/feature_flag_demo.ex deleted file mode 100644 index 3ee8dc5..0000000 --- a/examples/feature_flag_demo/lib/feature_flag_demo.ex +++ /dev/null @@ -1,112 +0,0 @@ -defmodule FeatureFlagDemo do - @moduledoc """ - A simple console application to demonstrate PostHog feature flag functionality. - """ - - @default_api_url "https://us.i.posthog.com" - - def main(args) do - print_info(:config) - args - |> parse_args() - |> process() - end - - defp print_info(:config) do - api_url = System.get_env("POSTHOG_API_URL", @default_api_url) - api_key = System.get_env("POSTHOG_API_KEY") - - IO.puts("Using API URL: #{api_url}") - IO.puts("Using API Key: #{String.slice(api_key || "", 0, 8)}...") - end - - defp print_info(:usage) do - IO.puts(""" - Usage: mix run run.exs --flag FLAG_NAME --distinct-id USER_ID [options] - - 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) - """) - end - - defp parse_args(args) do - {opts, _, _} = OptionParser.parse(args, - switches: [ - flag: :string, - distinct_id: :string, - groups: :string, - group_properties: :string, - person_properties: :string - ], - aliases: [ - d: :distinct_id - ] - ) - opts - end - - defp process([]), do: print_info(:usage) - - defp process([flag: flag, distinct_id: distinct_id] = opts) do - IO.puts("Checking feature flag '#{flag}' for user '#{distinct_id}'...") - - with {:ok, response} <- call_feature_flag(flag, distinct_id, opts), - {:ok, message} <- format_response(flag, response) do - IO.puts(message) - else - {:error, %{status: 403}} -> print_auth_error() - {:error, %{status: status, body: body}} -> IO.puts("Error: Received status #{status}\nResponse body: #{inspect(body)}") - {:error, reason} -> IO.puts("Error: #{inspect(reason)}") - end - end - - defp process(_), do: print_info(:usage) - - defp call_feature_flag(flag, distinct_id, opts) do - Posthog.feature_flag(flag, distinct_id, - groups: parse_json(opts[:groups]), - group_properties: parse_json(opts[:group_properties]), - person_properties: parse_json(opts[:person_properties]) - ) - end - - defp format_response(flag, %{enabled: enabled, payload: payload}) do - message = case enabled do - true -> "Feature flag '#{flag}' is ENABLED" - false -> "Feature flag '#{flag}' is DISABLED" - variant when is_binary(variant) -> "Feature flag '#{flag}' is ENABLED with variant: #{variant}" - end - - message = if payload, do: message <> "\nPayload: #{inspect(payload)}", else: message - {:ok, message} - end - - defp print_auth_error do - IO.puts(""" - Error: Authentication failed (403 Forbidden) - - Please check that: - 1. Your POSTHOG_API_KEY is set correctly - 2. Your POSTHOG_API_URL is set correctly (if using a self-hosted instance) - 3. The API key has the necessary permissions - 4. Your local PostHog instance is running and accessible - - You can set these environment variables: - export POSTHOG_API_KEY="your_project_api_key" - export POSTHOG_API_URL="http://localhost:8000" # Note: no trailing slash - """) - end - - defp parse_json(value) do - case value do - nil -> nil - "" -> nil - json when is_binary(json) -> Jason.decode!(json) - other -> other - end - end -end diff --git a/examples/feature_flag_demo/lib/feature_flag_demo/application.ex b/examples/feature_flag_demo/lib/feature_flag_demo/application.ex deleted file mode 100644 index f8a02ee..0000000 --- a/examples/feature_flag_demo/lib/feature_flag_demo/application.ex +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index ad940c1..0000000 --- a/examples/feature_flag_demo/mix.exs +++ /dev/null @@ -1,27 +0,0 @@ -defmodule FeatureFlagDemo.MixProject do - use Mix.Project - - def project do - [ - app: :feature_flag_demo, - version: "0.1.0", - elixir: "~> 1.16", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - def application do - [ - extra_applications: [:logger, :posthog], - mod: {FeatureFlagDemo.Application, []} - ] - end - - defp deps do - [ - {:posthog, path: "../.."}, - {:jason, "~> 1.4"} - ] - end -end diff --git a/examples/feature_flag_demo/mix.lock b/examples/feature_flag_demo/mix.lock deleted file mode 100644 index 62f7b4f..0000000 --- a/examples/feature_flag_demo/mix.lock +++ /dev/null @@ -1,18 +0,0 @@ -%{ - "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"}, - "uniq": {:hex, :uniq, "0.6.1", "369660ecbc19051be526df3aa85dc393af5f61f45209bce2fa6d7adb051ae03c", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "6426c34d677054b3056947125b22e0daafd10367b85f349e24ac60f44effb916"}, - "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, -} diff --git a/examples/feature_flag_demo/run.exs b/examples/feature_flag_demo/run.exs deleted file mode 100644 index 9bf00cd..0000000 --- a/examples/feature_flag_demo/run.exs +++ /dev/null @@ -1 +0,0 @@ -FeatureFlagDemo.main(System.argv()) \ No newline at end of file diff --git a/guides/advanced-configuration.md b/guides/advanced-configuration.md new file mode 100644 index 0000000..f04007e --- /dev/null +++ b/guides/advanced-configuration.md @@ -0,0 +1,85 @@ +# Advanced Configuration + +By default, PostHog starts its own supervision tree and attaches a logger handler. +This behavior is configured by the `:posthog` option in your global configuration: + +```elixir +config :posthog, + enable: true, + enable_error_tracking: true, + public_url: "https://us.i.posthog.com", + api_key: "phc_asdf" + ... +``` + +However, in certain cases you might want to run this supervision tree yourself. +You can do this by disabling the default supervisor and adding `PostHog.Supervisor` +to your application tree with its own configuration: + +```elixir +# config.exs + +config :posthog, enable: false + +config :my_app, :posthog, + public_url: "https://us.i.posthog.com", + api_key: "phc_asdf" + +# application.ex + +defmodule MyApp.Application do + use Application + + def start(_type, _args) do + posthog_config = Application.fetch_env!(:my_app, :posthog) |> PostHog.Config.validate!() + + :logger.add_handler(:posthog, PostHog.Handler, %{config: posthog_config}) + + children = [ + {PostHog.Supervisor, posthog_config} + ] + + Supervisor.start_link(children, strategy: :one_for_one) + end +end +``` + +## Multiple Instances + +In even more advanced cases, you might want to interact with more than one +PostHog project. In this case, you can run multiple PostHog supervision trees, +one of which can be the default one: + +```elixir +# config.exs +config :posthog, + public_url: "https://us.i.posthog.com", + api_key: "phc_key1" + +config :my_app, :another_posthog, + public_url: "https://us.i.posthog.com", + api_key: "phc_key2", + supervisor_name: AnotherPostHog + +# application.ex +defmodule MyApp.Application do + use Application + + def start(_type, _args) do + posthog_config = Application.fetch_env!(:my_app, :another_posthog) |> PostHog.Config.validate!() + + children = [ + {PostHog.Supervisor, posthog_config} + ] + + Supervisor.start_link(children, strategy: :one_for_one) + end +end +``` + +Then, each function in the `PostHog` module accepts an optional first argument with +the name of the PostHog supervisor tree that will process the capture: + +```elixir +iex> PostHog.capture(AnotherPostHog, "user_signed_up", %{distinct_id: "user123"}) +``` diff --git a/lib/posthog.ex b/lib/posthog.ex index de413b7..ad0f04c 100644 --- a/lib/posthog.ex +++ b/lib/posthog.ex @@ -1,251 +1,202 @@ -defmodule Posthog do - @moduledoc """ - A comprehensive Elixir client for PostHog's analytics and feature flag APIs. +defmodule PostHog do + @typedoc "Name under which an instance of PostHog supervision tree is registered." + @type supervisor_name() :: atom() - This module provides a high-level interface to PostHog's APIs, allowing you to: - - Track user events and actions - - Manage and evaluate feature flags - - Handle multivariate testing - - Process events in batch - - Work with user, group, and person properties + @typedoc "Event name, such as `\"user_signed_up\"` or `\"$create_alias\"`" + @type event() :: String.t() - ## Configuration + @typedoc "string representing distinct ID" + @type distinct_id() :: String.t() - Add your PostHog configuration to your application config: + @typedoc """ + Map representing event properties. - config :posthog, - api_url: "https://us.i.posthog.com", # Or your self-hosted instance - api_key: "phc_your_project_api_key" + Note that it __must__ be JSON-serializable. + """ + @type properties() :: %{optional(String.t()) => any(), optional(atom()) => any()} - Optional configuration: + @doc """ + Returns the configuration map for a named `PostHog` supervisor. - config :posthog, - json_library: Jason, # Default JSON parser (optional) - enabled_capture: true # Whether to enable PostHog tracking (optional, defaults to true) - # Set to false in development/test environments to disable tracking + ## Examples - ### Disabling PostHog + Retrieve the default `PostHog` instance config: - You can disable PostHog tracking by setting `enabled: false` in your configuration. - This is particularly useful in development or test environments where you don't want - to send actual events to PostHog. + %{supervisor_name: PostHog} = PostHog.config() + + Retrieve named instance config: - When `enabled_capture` is set to `false`: - - All `Posthog.capture/3` and `Posthog.batch/2` calls will succeed silently - - PostHog will still communicate with the server for Feature Flags + %{supervisor_name: MyPostHog} = PostHog.config(MyPostHog) + """ + @spec config(supervisor_name()) :: PostHog.Config.config() + def config(name \\ __MODULE__), do: PostHog.Registry.config(name) - This is useful for: - - Development and test environments where you don't want to pollute your PostHog instance - - Situations where you need to temporarily disable tracking + @doc false + def bare_capture(event, distinct_id, %{} = properties), + do: bare_capture(__MODULE__, event, distinct_id, properties) - Example configuration for development: + @doc """ + Captures a single event without retrieving properties from context. - # config/dev.exs - config :posthog, - enabled_capture: false # Disable tracking in development + Capture is a relatively lightweight operation. The event is prepared + synchronously and then sent to PostHog workers to be batched together with + other events and sent over the wire. - Example configuration for test: + ## Examples - # config/test.exs - config :posthog, - enabled_capture: false # Disable tracking in test environment + Capture a simple event: - ## Event Tracking + PostHog.bare_capture("event_captured", "user123") + + Capture an event with properties: - Events can be tracked with various levels of detail: + PostHog.bare_capture("event_captured", "user123", %{backend: "Phoenix"}) + + Capture through a named PostHog instance: - # Basic event - Posthog.capture("page_view", distinct_id: "user_123") + PostHog.bare_capture(MyPostHog, "event_captured", "user123") + """ + @spec bare_capture(supervisor_name(), event(), distinct_id(), properties()) :: :ok + def bare_capture(name \\ __MODULE__, event, distinct_id, properties \\ %{}) do + config = PostHog.Registry.config(name) + properties = Map.merge(properties, config.global_properties) + + event = %{ + event: event, + distinct_id: distinct_id, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), + properties: properties + } + + PostHog.Sender.send(event, name) + end - # Event with properties - Posthog.capture("purchase", %{ - distinct_id: "user_123", - product_id: "prod_123", - price: 99.99 - }) + @doc false + def capture(event, %{} = properties), + do: capture(__MODULE__, event, properties) - # Event with custom timestamp - Posthog.capture("signup", "user_123", %{}, timestamp: DateTime.utc_now()) + @doc """ + Captures a single event. - # Event with custom headers (e.g., for IP forwarding) - Posthog.capture("login", "user_123", %{}, headers: [{"x-forwarded-for", "127.0.0.1"}]) + Any context previously set will be included in the event properties. Note that + `distinct_id` is still required. - ## Feature Flags + ## Examples - PostHog feature flags can be used for feature management and A/B testing: + Set context and capture an event: - # Get all feature flags for a user - {:ok, flags} = Posthog.feature_flags("user_123") + PostHog.set_context(%{distinct_id: "user123", "$feature/my-feature-flag": true}) + PostHog.capture("job_started", %{job_name: "JobName"}) + + Set context and capture an event through a named PostHog instance: - # Check specific feature flag - {:ok, flag} = Posthog.feature_flag("new-dashboard", "user_123") + PostHog.set_context(MyPostHog, %{distinct_id: "user123", "$feature/my-feature-flag": true}) + PostHog.capture(MyPostHog, "job_started", %{job_name: "JobName"}) + """ + @spec capture(supervisor_name(), event(), properties()) :: :ok | {:error, :missing_distinct_id} + def capture(name \\ __MODULE__, event, properties \\ %{}) do + context = + name + |> get_event_context(event) + |> Map.merge(properties) + + case Map.pop(context, :distinct_id) do + {nil, _} -> {:error, :missing_distinct_id} + {distinct_id, properties} -> bare_capture(name, event, distinct_id, properties) + end + end - # Quick boolean check - if Posthog.feature_flag_enabled?("new-feature", "user_123") do - # Show new feature + @spec get_feature_flag(supervisor_name(), distinct_id() | map()) :: + PostHog.API.Client.response() + def get_feature_flag(name \\ __MODULE__, distinct_id_or_body) do + body = + case distinct_id_or_body do + %{} = body -> body + distinct_id -> %{distinct_id: distinct_id} end - # Feature flags with group/person properties - Posthog.feature_flags("user_123", - groups: %{company: "company_123"}, - group_properties: %{company: %{industry: "tech"}}, - person_properties: %{email: "user@example.com"} - ) - - ## Batch Processing - - Multiple events can be sent in a single request for better performance: - - events = [ - {"page_view", [distinct_id: "user_123"], nil}, - {"button_click", [distinct_id: "user_123", button: "signup"], nil} - ] - - Posthog.batch(events) - - Each event in the batch is a tuple of `{event_name, properties, timestamp}`. - """ + config = config(name) + PostHog.API.flags(config.api_client, body) + end @doc """ - Captures an event in PostHog. - - ## Parameters - - * `event` - The name of the event (string or atom) - * `params` - Required parameters including `:distinct_id` and optional properties - * `opts` - Optional parameters that can be either a timestamp or a keyword list of options - - ## Options - - * `:headers` - Additional HTTP headers for the request - * `:groups` - Group properties for the event - * `:group_properties` - Additional properties for groups - * `:person_properties` - Properties for the person - * `:timestamp` - Custom timestamp for the event + Sets context for the current process. ## Examples - # Basic event - Posthog.capture("page_view", "user_123") + Set and retrieve context for the current process: - # Event with properties - Posthog.capture("purchase", "user_123", %{ - product_id: "prod_123", - price: 99.99 - }) + > PostHog.set_context(%{foo: "bar"}) + > PostHog.get_context() + %{foo: "bar"} - # Event with timestamp - Posthog.capture("signup", "user_123", %{}, timestamp: DateTime.utc_now()) + Set and retrieve context for a named PostHog instance: - # Event with custom headers - Posthog.capture("login", "user_123", %{}, headers: [{"x-forwarded-for", "127.0.0.1"}]) + > PostHog.set_context(MyPostHog, %{foo: "bar"}) + > PostHog.get_context(MyPostHog) + %{foo: "bar"} """ - alias Posthog.{Client, FeatureFlag} - - @spec capture(Client.event(), Client.distinct_id(), Client.properties(), Client.opts()) :: - Client.result() - defdelegate capture(event, distinct_id, properties \\ %{}, opts \\ []), to: Client + @spec set_context(supervisor_name(), properties()) :: :ok + defdelegate set_context(name \\ __MODULE__, context), to: PostHog.Context, as: :set @doc """ - Sends multiple events to PostHog in a single request. - - ## Parameters - - * `events` - List of event tuples in the format `{event_name, distinct_id, properties}` - * `opts` - Optional parameters for the batch request + Sets context for the current process scoped to a specific event. ## Examples - events = [ - {"page_view", "user_123", %{}}, - {"button_click", "user_123", %{button: "signup"}} - ] - - Posthog.batch(events) - """ - @spec batch(list(tuple()), keyword()) :: Client.result() - defdelegate batch(events, opts \\ []), to: Client - - @doc """ - Retrieves all feature flags for a given distinct ID. - - ## Parameters - - * `distinct_id` - The unique identifier for the user - * `opts` - Optional parameters for the feature flag request - - ## Options + Set and retrieve context scoped to an event: - * `:groups` - Group properties for feature flag evaluation - * `:group_properties` - Additional properties for groups - * `:person_properties` - Properties for the person + > PostHog.set_event_context("$exception", %{foo: "bar"}) + > PostHog.get_event_context("$exception") + %{foo: "bar"} + + Set and retrieve context for a specific event through a named PostHog instance: - ## Examples - - # Basic feature flags request - {:ok, flags} = Posthog.feature_flags("user_123") - - # With group properties - {:ok, flags} = Posthog.feature_flags("user_123", - groups: %{company: "company_123"}, - group_properties: %{company: %{industry: "tech"}} - ) + > PostHog.set_event_context(MyPostHog, "$exception", %{foo: "bar"}) + > PostHog.get_event_context(MyPostHog, "$exception") + %{foo: "bar"} """ - @spec feature_flags(binary(), keyword()) :: Client.result() - defdelegate feature_flags(distinct_id, opts \\ []), to: Client + @spec set_event_context(supervisor_name(), event(), properties()) :: :ok + def set_event_context(name \\ __MODULE__, event, context), + do: PostHog.Context.set(name, event, context) @doc """ - Retrieves information about a specific feature flag for a given distinct ID. + Retrieves context for the current process. - ## Parameters + ## Examples - * `flag` - The name of the feature flag - * `distinct_id` - The unique identifier for the user - * `opts` - Optional parameters for the feature flag request + Set and retrieve context for current process: - ## Examples + > PostHog.set_context(%{foo: "bar"}) + > PostHog.get_context() + %{foo: "bar"} + + Set and retrieve context for a named PostHog instance: - # Boolean feature flag - {:ok, flag} = Posthog.feature_flag("new-dashboard", "user_123") - # 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", - # payload: %{"price" => 99, "period" => "monthly"}, - # enabled: "variant-a" - # } + > PostHog.set_context(MyPostHog, %{foo: "bar"}) + > PostHog.get_context(MyPostHog) + %{foo: "bar"} """ - @spec feature_flag(binary(), binary(), Client.feature_flag_opts()) :: Client.result() - defdelegate feature_flag(flag, distinct_id, opts \\ []), to: Client + @spec get_context(supervisor_name()) :: properties() + defdelegate get_context(name \\ __MODULE__), to: PostHog.Context, as: :get @doc """ - Checks if a feature flag is enabled for a given distinct ID. - - This is a convenience function that returns a boolean instead of a result tuple. - For multivariate flags, returns true if the flag has any value set. + Retrieves context for the current process scoped to a specific event. - ## Parameters + ## Examples - * `flag` - The name of the feature flag - * `distinct_id` - The unique identifier for the user - * `opts` - Optional parameters for the feature flag request + Set and retrieve context scoped to an event: - ## Examples + > PostHog.set_event_context("$exception", %{foo: "bar"}) + > PostHog.get_event_context("$exception") + %{foo: "bar"} + + Set and retrieve context for a specific event through a named PostHog instance: - if Posthog.feature_flag_enabled?("new-dashboard", "user_123") do - # Show new dashboard - end + > PostHog.set_event_context(MyPostHog, "$exception", %{foo: "bar"}) + > PostHog.get_event_context(MyPostHog, "$exception") + %{foo: "bar"} """ - @spec feature_flag_enabled?(binary(), binary(), keyword()) :: boolean() - def feature_flag_enabled?(flag, distinct_id, opts \\ []) do - flag - |> feature_flag(distinct_id, opts) - |> case do - {:ok, %FeatureFlag{enabled: false}} -> false - {:ok, %FeatureFlag{}} -> true - _ -> false - end - end + @spec get_event_context(supervisor_name()) :: properties() + def get_event_context(name \\ __MODULE__, event), do: PostHog.Context.get(name, event) end diff --git a/lib/posthog/api.ex b/lib/posthog/api.ex new file mode 100644 index 0000000..2d4e5dc --- /dev/null +++ b/lib/posthog/api.ex @@ -0,0 +1,10 @@ +defmodule PostHog.API do + @moduledoc false + def post_batch(%__MODULE__.Client{} = client, batch) do + client.module.request(client.client, :post, "/batch", json: %{batch: batch}) + end + + def flags(%__MODULE__.Client{} = client, event) do + client.module.request(client.client, :post, "/flags", json: event, params: %{v: 2}) + end +end diff --git a/lib/posthog/api/client.ex b/lib/posthog/api/client.ex new file mode 100644 index 0000000..a13c791 --- /dev/null +++ b/lib/posthog/api/client.ex @@ -0,0 +1,79 @@ +defmodule PostHog.API.Client do + @moduledoc """ + Behaviour and the default implementation of a PostHog API client. Uses `Req`. + + Users are unlikely to interact with this module directly, but here's an + example just in case: + + ## Example + + > client = PostHog.API.Client.client("phc_abcdedfgh", "https://us.i.posthog.com") + %PostHog.API.Client{ + client: %Req.Request{...}, + module: PostHog.API.Client + } + + > client.module.request(client.client, :post, "/flags", json: %{distinct_id: "user123"}, params: %{v: 2, config: true}) + {:ok, %Req.Response{status: 200, body: %{...}}} + """ + @behaviour __MODULE__ + + defstruct [:client, :module] + + @type t() :: %__MODULE__{ + client: client(), + module: atom() + } + @typedoc """ + Arbitrary term that is passed as the first argument to the `c:request/4` callback. + + For the default client, this is a `t:Req.Request.t/0` struct. + """ + @type client() :: any() + @type response() :: {:ok, %{status: non_neg_integer(), body: any()}} | {:error, Exception.t()} + + @doc """ + Creates a struct that encapsulates all information required for making requests to PostHog's public endpoints. + """ + @callback client(api_key :: String.t(), cloud :: String.t()) :: t() + + @doc """ + Sends an API request. + + Things such as the API token are expected to be baked into the `client` argument. + """ + @callback request(client :: client(), method :: atom(), url :: String.t(), opts :: keyword()) :: + response() + + @impl __MODULE__ + def client(api_key, public_url) do + client = + Req.new(base_url: public_url) + |> Req.Request.put_private(:api_key, api_key) + + %__MODULE__{client: client, module: __MODULE__} + end + + @impl __MODULE__ + def request(client, method, url, opts) do + client + |> Req.merge( + method: method, + url: url + ) + |> Req.merge(opts) + |> then(fn req -> + req + |> Req.Request.fetch_option(:json) + |> case do + {:ok, json} -> + api_key = Req.Request.get_private(req, :api_key) + Req.merge(req, json: Map.put_new(json, :api_key, api_key)) + + :error -> + req + end + end) + |> Req.request() + end +end diff --git a/lib/posthog/application.ex b/lib/posthog/application.ex index 4d27717..ac877b9 100644 --- a/lib/posthog/application.ex +++ b/lib/posthog/application.ex @@ -1,110 +1,22 @@ -defmodule Posthog.Application do - @moduledoc """ - The main application module for PostHog Elixir client. - - This module handles the application lifecycle and supervises the necessary processes - for the PostHog client to function properly. It primarily manages the Cachex instance - used for feature flag event deduplication. - - ## Features - - * Validates configuration before starting - * Manages a Cachex instance for feature flag event deduplication - * Implements LRU (Least Recently Used) caching strategy - * Automatically prunes cache entries to maintain size limits - - ## Cache Configuration - - The Cachex instance is configured with: - * Maximum of 50,000 entries (matching posthog-python's limit) - * LRU (Least Recently Used) eviction policy - * Automatic pruning every 10 seconds - * Access tracking for LRU implementation - - ## Usage - - The application is automatically started by the Elixir runtime when included - in your application's supervision tree. You don't need to start it manually. - - To access the cache name in your code: - - Posthog.Application.cache_name() - # Returns: :posthog_feature_flag_cache - """ +defmodule PostHog.Application do + @moduledoc false use Application - import Cachex.Spec - - @cache_name :posthog_feature_flag_cache - - @doc """ - Returns the name of the Cachex instance used for feature flag event deduplication. - - ## Returns - - * `atom()` - The cache name, always `:posthog_feature_flag_cache` at the moment - - ## Examples - - iex> Posthog.Application.cache_name() - :posthog_feature_flag_cache - """ - def cache_name, do: @cache_name - - @doc """ - Starts the PostHog application. - - This callback is called by the Elixir runtime when the application starts. - It performs the following tasks: - 1. Validates the PostHog configuration - 2. Sets up the Cachex instance for feature flag event deduplication - 3. Starts the supervision tree - - ## Parameters - - * `_type` - The type of start (ignored) - * `args` - Keyword list of arguments, can include: - - ## Returns - - * `{:ok, pid()}` on successful start - * `{:error, term()}` on failure - - ## Examples - - # Start with default configuration - Posthog.Application.start(:normal, []) - """ - def start(_type, args) do - # Validate configuration before starting - Posthog.Config.validate_config!() - cache_name = Keyword.get(args, :cache_name, @cache_name) + def start(_type, _args) do + children = + case PostHog.Config.read!() do + {%{enable: true, enable_error_tracking: error_tracking}, config} -> + if error_tracking do + :logger.add_handler(:posthog, PostHog.Handler, %{config: config}) + end - 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), + [{PostHog.Supervisor, config}] - # 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] - } - ) - ]} - ] + _ -> + [] + end - opts = [strategy: :one_for_one, name: Posthog.Supervisor] - Supervisor.start_link(children, opts) + Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__) end end diff --git a/lib/posthog/client.ex b/lib/posthog/client.ex deleted file mode 100644 index 2b47781..0000000 --- a/lib/posthog/client.ex +++ /dev/null @@ -1,443 +0,0 @@ -require Logger - -defmodule Posthog.Client do - @moduledoc """ - Low-level HTTP client for interacting with PostHog's API. - - This module handles the direct HTTP communication with PostHog's API endpoints. - It provides functions for: - - Sending event capture requests - - Processing batch events - - Retrieving feature flag information - - While this module can be used directly, it's recommended to use the higher-level - functions in the `Posthog` module instead. - - ## Configuration - - The client uses the following configuration from your application: - - config :posthog, - api_url: "https://us.i.posthog.com", # Required - api_key: "phc_your_project_api_key", # Required - json_library: Jason, # Optional (default: Jason) - enabled_capture: true, # Optional (default: true) - http_client: Posthog.HTTPClient.Hackney, # Optional (default: Posthog.HTTPClient.Hackney) - http_client_opts: [ # Optional - timeout: 5_000, # 5 seconds - retries: 3, # Number of retries - retry_delay: 1_000 # 1 second between retries - ] - - ### Disabling capture - - When `enabled_capture` is set to `false`: - - All `Posthog.capture/3` and `Posthog.batch/2` calls will succeed silently - - PostHog will still communicate with the server for Feature Flags - - This is useful for: - - Development and test environments where you don't want to pollute your PostHog instance - - Situations where you need to temporarily disable tracking - - Example configuration for disabling the client: - - # config/dev.exs or config/test.exs - config :posthog, - enabled_capture: false - - ## API Endpoints - - The client interacts with the following PostHog API endpoints: - - `/capture` - For sending individual and batch events - - `/decide` - For retrieving feature flag information - - ## Error Handling - - All functions return a result tuple: - - `{:ok, response}` for successful requests - - `{:error, response}` for failed requests with a response - - `{:error, term()}` for other errors (network issues, etc.) - - ## Examples - - # Capture an event - Posthog.Client.capture("page_view", "user_123") - - # Send batch events - events = [ - {"page_view", "user_123", %{}}, - {"click", "user_123", %{}} - ] - Posthog.Client.batch(events, timestamp: DateTime.utc_now()) - - # Get feature flags - Posthog.Client.feature_flags("user_123", groups: %{team: "engineering"}) - """ - - alias Posthog.FeatureFlag - - @typedoc """ - Result of a PostHog operation. - """ - @type result() :: {:ok, response()} | {:error, response() | term()} - - @typedoc """ - HTTP headers in the format expected by :hackney. - """ - @type headers :: [{binary(), binary()}] - - @typedoc """ - Response from the PostHog API. - Contains the status code, headers, and parsed JSON body (if any). - """ - @type response :: %{status: pos_integer(), headers: headers(), body: map() | nil} - - @typedoc """ - Event name, can be either an atom or a binary string. - """ - @type event :: atom() | binary() - - @typedoc """ - Distinct ID for the person or group. - """ - @type distinct_id :: binary() - - @typedoc """ - Properties that can be attached to events or feature flag requests. - """ - @type properties :: %{optional(atom() | String.t()) => term()} - - @typedoc """ - Timestamp for events. Can be a DateTime, NaiveDateTime, or ISO8601 string. - """ - @type timestamp :: String.t() - - @typedoc """ - Options that can be passed to API requests. - - * `:headers` - Additional HTTP headers to include in the request - * `:groups` - Group properties for feature flag evaluation - * `:group_properties` - Additional properties for groups - * `:person_properties` - Properties for the person - * `:timestamp` - Custom timestamp for events - * `:uuid` - Custom UUID for the event - """ - @type opts :: [ - headers: headers(), - groups: map(), - group_properties: map(), - person_properties: map(), - timestamp: timestamp(), - uuid: Uniq.UUID.t() - ] - - @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()] - - @typedoc """ - Cache key for the `$feature_flag_called` event. - """ - @type 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 - } - - # Adds default headers to the request. - # - # ## Parameters - # - # * `additional_headers` - List of additional headers to include - # - # ## Examples - # - # headers([{"x-forwarded-for", "127.0.0.1"}]) - @spec headers(headers()) :: headers() - defp headers(additional_headers) do - Enum.concat(additional_headers || [], [{"content-type", "application/json"}]) - end - - @doc """ - Captures a single event in PostHog. - - ## Parameters - - * `event` - The name of the event (string or atom) - * `params` - Event properties including `:distinct_id` - * `opts` - Additional options (see `t:opts/0`) - - ## Examples - - # Basic event - Posthog.Client.capture("page_view", "user_123") - - # Event with properties and timestamp - Posthog.Client.capture("purchase", "user_123", %{ - product_id: "123", - price: 99.99 - }, timestamp: DateTime.utc_now()) - - # Event with custom headers - Posthog.Client.capture("login", "user_123", %{}, headers: [{"x-forwarded-for", "127.0.0.1"}]) - """ - @spec capture(event(), distinct_id(), properties(), opts()) :: result() - def capture(event, distinct_id, properties \\ %{}, opts \\ []) when is_list(opts) do - if Posthog.Config.enabled_capture?() do - posthog_event = Posthog.Event.new(event, distinct_id, properties, opts) - post!("/capture", Posthog.Event.to_api_payload(posthog_event), headers(opts[:headers])) - else - disabled_capture_response() - end - end - - @doc """ - Sends multiple events to PostHog in a single request. - - ## Parameters - - * `events` - List of event tuples in the format `{event_name, distinct_id, properties}` - * `opts` - Additional options (see `t:opts/0`) - * `headers` - Additional HTTP headers - - ## Examples - - events = [ - {"page_view", "user_123", %{}}, - {"click", "user_123", %{button: "signup"}} - ] - - Posthog.Client.batch(events, %{timestamp: DateTime.utc_now()}) - """ - @spec batch([{event(), distinct_id(), properties()}], opts(), headers()) :: result() - def batch(events, opts) when is_list(opts) do - batch(events, opts, headers(opts[:headers])) - end - - def batch(events, opts, headers) do - if Posthog.Config.enabled_capture?() do - timestamp = Keyword.get_lazy(opts, :timestamp, fn -> DateTime.utc_now() end) - - posthog_events = - for {event, distinct_id, properties} <- events do - Posthog.Event.new(event, distinct_id, properties, timestamp: timestamp) - end - - post!("/capture", Posthog.Event.batch_payload(posthog_events), headers) - else - disabled_capture_response() - end - end - - @doc """ - Retrieves feature flags for a given distinct ID. - - ## Parameters - - * `distinct_id` - The unique identifier for the user - * `opts` - Additional options (see `t:opts/0`) - - ## Examples - - # Basic feature flags request - Posthog.Client.feature_flags("user_123") - - # With group properties - Posthog.Client.feature_flags("user_123", - groups: %{company: "company_123"}, - group_properties: %{company: %{industry: "tech"}} - ) - """ - @spec feature_flags(binary(), opts()) :: result() - 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 """ - Retrieves information about a specific feature flag for a given distinct ID. - - ## Parameters - - * `flag` - The name of the feature flag - * `distinct_id` - The unique identifier for the user - * `opts` - Optional parameters for the feature flag request - - ## Examples - - # Boolean feature flag - {:ok, flag} = Posthog.feature_flag("new-dashboard", "user_123") - # 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", - # payload: %{"price" => 99, "period" => "monthly"}, - # enabled: "variant-a" - # } - """ - @spec feature_flag(binary(), binary(), feature_flag_opts()) :: result() - def feature_flag(flag, distinct_id, opts \\ []) do - with {:ok, response} <- _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, - %{ - "$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( - distinct_id(), - feature_flag_called_event_properties(), - map() - ) :: - :ok - defp capture_feature_flag_called_event(distinct_id, properties, response) do - # Create a unique key for this distinct_id and flag combination - cache_key = {:feature_flag_called, 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, distinct_id, 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, distinct_id, 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(), - distinct_id(), - feature_flag_called_event_properties(), - map() - ) :: :ok - defp do_capture_feature_flag_called_event(cache_key, distinct_id, 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 - # NOTE: Calling this with `Posthog.Client.capture/4` rather than `capture/4` - # because mocks won't work properly unless we use the fully defined function - Posthog.Client.capture("$feature_flag_called", distinct_id, properties, []) - - # Add new entry to cache using Cachex - Cachex.put(Posthog.Application.cache_name(), cache_key, true) - - :ok - 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}, acc -> Map.put(acc, k, v) end) - - case post!("/decide?v=4", body, headers(opts[:headers])) do - {:ok, %{body: body}} -> {:ok, Posthog.FeatureFlag.process_response(body)} - err -> err - end - end - - @doc false - @spec post!(binary(), map(), headers()) :: {:ok, response()} | {:error, response() | term()} - defp post!(path, %{} = body, headers) do - body = - body - |> Map.put(:api_key, Posthog.Config.api_key()) - |> encode(Posthog.Config.json_library()) - - url = Posthog.Config.api_url() <> path - - Posthog.Config.http_client().post( - url, - body, - headers, - Posthog.Config.http_client_opts() - ) - end - - @doc false - defp disabled_capture_response do - {:ok, %{status: 200, headers: [], body: nil}} - end - - @doc false - @spec encode(term(), module()) :: iodata() - defp encode(data, Jason), do: Jason.encode_to_iodata!(data) - defp encode(data, library), do: library.encode!(data) -end diff --git a/lib/posthog/config.ex b/lib/posthog/config.ex index 057eaf0..4388d85 100644 --- a/lib/posthog/config.ex +++ b/lib/posthog/config.ex @@ -1,153 +1,138 @@ -defmodule Posthog.Config do +defmodule PostHog.Config do + @configuration_schema [ + public_url: [ + type: :string, + required: true, + doc: "`https://us.i.posthog.com` for US cloud or `https://eu.i.posthog.com` for EU cloud" + ], + api_key: [ + type: :string, + required: true, + doc: """ + Your PostHog Project API key. Find it in your project's settings under the Project ID section. + """ + ], + api_client_module: [ + type: :atom, + default: PostHog.API.Client, + doc: "API client to use" + ], + supervisor_name: [ + type: :atom, + default: PostHog, + doc: "Name of the supervisor process running PostHog" + ], + metadata: [ + type: {:list, :atom}, + default: [], + doc: "List of metadata keys to include in event properties" + ], + capture_level: [ + type: {:or, [{:in, Logger.levels()}, nil]}, + default: :error, + doc: + "Minimum level for logs that should be captured as errors. Errors with `crash_reason` are always captured." + ], + in_app_otp_apps: [ + type: {:list, :atom}, + default: [], + doc: + "List of OTP app names of your applications. Stacktrace entries that belong to these apps will be marked as \"in_app\"." + ] + ] + + @convenience_schema [ + enable: [ + type: :boolean, + default: true, + doc: "Automatically start PostHog?" + ], + enable_error_tracking: [ + type: :boolean, + default: true, + doc: "Automatically start the logger handler for error tracking?" + ] + ] + + @compiled_configuration_schema NimbleOptions.new!(@configuration_schema) + @compiled_convenience_schema NimbleOptions.new!(@convenience_schema) + @moduledoc """ - Handles configuration validation and defaults for the PostHog client. + PostHog configuration - This module validates the configuration at compile time and provides - sensible defaults for optional values. - """ + ## Configuration Schema - @app :posthog + ### Application Configuration - @doc """ - Validates and returns the API URL from the configuration. + These are convenience options that only affect how PostHog's own application behaves. - Raises a helpful error message if the URL is missing or invalid. - """ - def api_url do - case Application.get_env(@app, :api_url) do - url when is_binary(url) and url != "" -> - url - - nil -> - raise """ - PostHog API URL is not configured. Please add it to your config: - - config :posthog, - api_url: "https://us.i.posthog.com" # or your self-hosted instance - """ - - url -> - raise """ - Invalid PostHog API URL: #{inspect(url)} - - Expected a non-empty string URL, for example: - config :posthog, - api_url: "https://us.i.posthog.com" # or your self-hosted instance - """ - end - end + #{NimbleOptions.docs(@compiled_convenience_schema)} - @doc """ - Validates and returns the API key from the configuration. + ### Supervisor Configuration + + This is the main options block that configures each supervision tree instance. - Raises a helpful error message if the key is missing or invalid. + #{NimbleOptions.docs(@compiled_configuration_schema)} """ - def api_key do - case Application.get_env(@app, :api_key) do - key when is_binary(key) and key != "" -> - key - - nil -> - raise """ - PostHog API key is not configured. Please add it to your config: - - config :posthog, - api_key: "phc_your_project_api_key" - """ - - key -> - raise """ - Invalid PostHog API key: #{inspect(key)} - - Expected a non-empty string API key, for example: - config :posthog, - api_key: "phc_your_project_api_key" - """ - end - end - @doc """ - Returns whether event capture is enabled. + @typedoc """ + Map containing valid configuration. - Defaults to true if not configured. + It mostly follows `t:options/0`, but the internal structure shouldn't be relied upon. """ - def enabled_capture? do - Application.get_env(@app, :enabled_capture, true) - end + @opaque config() :: map() - @doc """ - Returns the JSON library to use for encoding/decoding. + @type options() :: unquote(NimbleOptions.option_typespec(@compiled_configuration_schema)) - Defaults to Jason if not configured. - """ - def json_library do - Application.get_env(@app, :json_library, Jason) - end + @doc false + def read!() do + configuration_options = + Application.get_all_env(:posthog) |> Keyword.take(Keyword.keys(@configuration_schema)) - @doc """ - Returns the HTTP client module to use. + convenience_options = + Application.get_all_env(:posthog) |> Keyword.take(Keyword.keys(@convenience_schema)) - Defaults to Posthog.HTTPClient.Hackney if not configured. - """ - def http_client do - Application.get_env(@app, :http_client, Posthog.HTTPClient.Hackney) + with %{enable: true} = conv <- + convenience_options + |> NimbleOptions.validate!(@compiled_convenience_schema) + |> Map.new() do + config = validate!(configuration_options) + {conv, config} + end end @doc """ - Returns the HTTP client options. - - Defaults to: - - timeout: 5_000 (5 seconds) - - retries: 3 - - retry_delay: 1_000 (1 second) + See `validate/1`. """ - def http_client_opts do - Application.get_env(@app, :http_client_opts, - timeout: 5_000, - retries: 3, - retry_delay: 1_000 - ) + @spec validate!(options()) :: config() + def validate!(options) do + {:ok, config} = validate(options) + config end @doc """ - Validates the entire PostHog configuration at compile time. - - This ensures that all required configuration is present and valid - before the application starts. + Validates configuration against the schema. """ - def validate_config! do - # Validate required config - api_url() - api_key() - - # Validate optional config - if json_library() != Jason do - unless Code.ensure_loaded?(json_library()) do - raise """ - Configured JSON library #{inspect(json_library())} is not available. - - Make sure to add it to your dependencies in mix.exs: - defp deps do - [{#{inspect(json_library())}, "~> x.x"}] - end - """ - end + @spec validate(options()) :: + {:ok, config()} | {:error, NimbleOptions.ValidationError.t()} + def validate(options) do + with {:ok, validated} <- NimbleOptions.validate(options, @compiled_configuration_schema) do + config = Map.new(validated) + client = config.api_client_module.client(config.api_key, config.public_url) + + final_config = + config + |> Map.put(:api_client, client) + |> Map.put( + :in_app_modules, + config.in_app_otp_apps |> Enum.flat_map(&Application.spec(&1, :modules)) |> MapSet.new() + ) + |> Map.put(:global_properties, %{ + "$lib": "posthog-elixir", + "$lib_version": Application.spec(:posthog, :vsn) |> to_string() + }) + + {:ok, final_config} end - - # Validate HTTP client - http_client = http_client() - - unless Code.ensure_loaded?(http_client) do - raise """ - Configured HTTP client #{inspect(http_client)} is not available. - - Make sure to add it to your dependencies in mix.exs: - defp deps do - [{:hackney, "~> x.x"}] - end - """ - end - - :ok end end diff --git a/lib/posthog/context.ex b/lib/posthog/context.ex new file mode 100644 index 0000000..d83c8ac --- /dev/null +++ b/lib/posthog/context.ex @@ -0,0 +1,45 @@ +defmodule PostHog.Context do + @moduledoc false + + @logger_metadata_key :__posthog__ + + def set(name_scope, event_scope \\ :all, context) do + metadata = + with :undefined <- :logger.get_process_metadata(), do: %{} + + current_context = Map.get(metadata, @logger_metadata_key, %{}) + + new_value = + case get_in(current_context, [name_scope, event_scope]) do + %{} = existing -> Map.merge(existing, context) + nil -> context + end + + updated_context = + put_in( + current_context, + [Access.key(name_scope, %{}), Access.key(event_scope, %{})], + new_value + ) + + :logger.update_process_metadata(%{@logger_metadata_key => updated_context}) + end + + def get(name_scope, event_scope \\ :all) do + case :logger.get_process_metadata() do + %{@logger_metadata_key => context} -> + get_in(context, [key_and_all(name_scope), key_and_all(event_scope)]) |> Map.new() + + _ -> + %{} + end + end + + defp key_and_all(key) do + fn :get, data, next -> + scoped = Map.get(data, key, %{}) + all = Map.get(data, :all, %{}) + Enum.flat_map([all, scoped], next) + end + end +end diff --git a/lib/posthog/event.ex b/lib/posthog/event.ex deleted file mode 100644 index 403a1ed..0000000 --- a/lib/posthog/event.ex +++ /dev/null @@ -1,178 +0,0 @@ -defmodule Posthog.Event do - @moduledoc """ - Represents a PostHog event with all its properties and metadata. - - This struct encapsulates all the information needed to send an event to PostHog, - including the event name, distinct ID, properties, timestamp, and UUID. - - ## Examples - - # Create a basic event - iex> event = Posthog.Event.new("page_view", "user_123") - iex> event.event - "page_view" - iex> event.distinct_id - "user_123" - iex> event.properties - %{} - iex> is_binary(event.uuid) - true - iex> is_binary(event.timestamp) - true - - # Create an event with properties - iex> event = Posthog.Event.new("purchase", "user_123", %{price: 99.99}) - iex> event.properties - %{price: 99.99} - - # Create an event with custom timestamp - iex> timestamp = "2023-01-01T00:00:00Z" - iex> event = Posthog.Event.new("login", "user_123", %{}, timestamp: timestamp) - iex> event.timestamp - "2023-01-01T00:00:00Z" - - # Create an event with custom UUID - iex> uuid = "123e4567-e89b-12d3-a456-426614174000" - iex> event = Posthog.Event.new("signup", "user_123", %{}, uuid: uuid) - iex> event.uuid - "123e4567-e89b-12d3-a456-426614174000" - - # Convert event to API payload - iex> event = Posthog.Event.new("page_view", "user_123", %{page: "home"}) - iex> payload = Posthog.Event.to_api_payload(event) - iex> payload.event - "page_view" - iex> payload.distinct_id - "user_123" - iex> payload.properties["page"] - "home" - iex> payload.properties["$lib"] - "posthog-elixir" - iex> is_binary(payload.uuid) - true - iex> is_binary(payload.timestamp) - true - - # Create batch payload - iex> events = [ - ...> Posthog.Event.new("page_view", "user_123", %{page: "home"}), - ...> Posthog.Event.new("click", "user_123", %{button: "signup"}) - ...> ] - iex> batch = Posthog.Event.batch_payload(events) - iex> length(batch.batch) - 2 - iex> [first, second] = batch.batch - iex> first.event - "page_view" - iex> second.event - "click" - """ - - @type t :: %__MODULE__{ - event: String.t(), - distinct_id: String.t(), - properties: map(), - uuid: String.t(), - timestamp: String.t() - } - - @type event_name :: atom() | String.t() - @type distinct_id :: String.t() - @type properties :: map() - @type timestamp :: String.t() | DateTime.t() | NaiveDateTime.t() - - defstruct [:event, :distinct_id, :properties, :uuid, :timestamp] - - @lib_name "posthog-elixir" - @lib_version Mix.Project.config()[:version] - - @doc """ - Creates a new PostHog event. - - ## Parameters - - * `event` - The name of the event (string or atom) - * `distinct_id` - The distinct ID for the person or group - * `properties` - Event properties (optional, defaults to empty map) - * `timestamp` - Optional timestamp for the event (defaults to current UTC time) - * `uuid` - Optional UUID for the event (defaults to a new UUID7) - - ## Examples - - # Basic event - Posthog.Event.new("page_view", "user_123") - - # Event with properties - Posthog.Event.new("purchase", "user_123", %{price: 99.99}) - - # Event with custom timestamp - Posthog.Event.new("login", "user_123", %{}, timestamp: DateTime.utc_now()) - """ - @spec new(event_name(), distinct_id(), properties(), keyword()) :: t() - def new(event, distinct_id, properties \\ %{}, opts \\ []) do - timestamp = - Keyword.get_lazy(opts, :timestamp, fn -> - DateTime.utc_now() |> DateTime.to_iso8601() - end) - - uuid = Keyword.get(opts, :uuid) || Uniq.UUID.uuid7() - - %__MODULE__{ - event: to_string(event), - distinct_id: distinct_id, - properties: properties, - uuid: uuid, - timestamp: timestamp - } - end - - @doc """ - Converts the event struct to a map suitable for sending to the PostHog API. - """ - @spec to_api_payload(t()) :: map() - def to_api_payload(%__MODULE__{} = event) do - %{ - event: event.event, - distinct_id: event.distinct_id, - properties: deep_stringify_keys(Map.merge(lib_properties(), Map.new(event.properties))), - uuid: event.uuid, - timestamp: event.timestamp - } - end - - @doc """ - Creates a batch payload from a list of events. - """ - @spec batch_payload([t()]) :: map() - def batch_payload(events) do - %{batch: Enum.map(events, &to_api_payload/1)} - end - - @doc false - defp deep_stringify_keys(term) when is_struct(term) do - term |> Map.from_struct() |> deep_stringify_keys - end - - defp deep_stringify_keys(term) when is_map(term) do - term - |> Enum.map(fn {k, v} -> {to_string(k), deep_stringify_keys(v)} end) - |> Enum.into(%{}) - end - - defp deep_stringify_keys([{key, _value} | _] = term) when is_atom(key) do - term - |> Enum.map(fn {k, v} -> {to_string(k), deep_stringify_keys(v)} end) - |> Enum.into(%{}) - end - - defp deep_stringify_keys(term) when is_list(term), do: Enum.map(term, &deep_stringify_keys/1) - defp deep_stringify_keys(term), do: term - - @doc false - defp lib_properties do - %{ - "$lib" => @lib_name, - "$lib_version" => @lib_version - } - end -end diff --git a/lib/posthog/feature_flag.ex b/lib/posthog/feature_flag.ex deleted file mode 100644 index b6a81d7..0000000 --- a/lib/posthog/feature_flag.ex +++ /dev/null @@ -1,243 +0,0 @@ -defmodule Posthog.FeatureFlag do - @moduledoc """ - Represents a PostHog feature flag with its evaluation state. - - This module provides a struct and helper functions for working with PostHog feature flags. - Feature flags can be either boolean (on/off) or multivariate (multiple variants). - - ## Structure - - The `FeatureFlag` struct contains: - * `name` - The name of the feature flag - * `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 - - # Boolean feature flag - %Posthog.FeatureFlag{ - name: "new-dashboard", - payload: true, - enabled: true - } - - # Multivariate feature flag - %Posthog.FeatureFlag{ - name: "pricing-test", - payload: %{"price" => 99, "period" => "monthly"}, - enabled: "variant-a" - } - """ - defstruct [:name, :payload, :enabled] - - @typedoc """ - Represents the enabled state of a feature flag. - Can be either a boolean for on/off flags or a string for multivariate flags. - """ - @type variant :: binary() | boolean() - - @typedoc """ - A map of properties that can be associated with a feature flag. - """ - @type properties :: %{optional(binary()) => term()} - - @typedoc """ - The response format from PostHog's feature flag API. - Contains both the flag states and their associated payloads. - """ - @type flag_response :: %{ - feature_flags: %{optional(binary()) => variant()}, - feature_flag_payloads: %{optional(binary()) => term()} - } - - @typedoc """ - The FeatureFlag struct type. - - Fields: - * `name` - The name of the feature flag (string) - * `payload` - The payload value associated with the flag (any term) - * `enabled` - The evaluation result (boolean or string) - """ - @type t :: %__MODULE__{ - name: binary(), - payload: term(), - enabled: variant() - } - - @doc """ - Creates a new FeatureFlag struct. - - ## Parameters - - * `name` - The name of the feature flag - * `enabled` - The evaluation result (boolean or string) - * `payload` - The payload value associated with the flag - - ## Examples - - # Create a boolean feature flag - Posthog.FeatureFlag.new("new-dashboard", true, true) - - # Create a multivariate feature flag - Posthog.FeatureFlag.new("pricing-test", "variant-a", - %{"price" => 99, "period" => "monthly"}) - """ - @spec new(binary(), variant(), term()) :: t() - def new(name, enabled, payload) do - struct!(__MODULE__, name: name, enabled: enabled, payload: payload) - end - - @doc """ - Checks if a feature flag is a boolean flag. - - Returns `true` if the flag is a boolean (on/off) flag, `false` if it's a multivariate flag. - - ## Examples - - flag = Posthog.FeatureFlag.new("new-dashboard", true, true) - Posthog.FeatureFlag.boolean?(flag) - # Returns: true - - flag = Posthog.FeatureFlag.new("pricing-test", "variant-a", %{}) - Posthog.FeatureFlag.boolean?(flag) - # Returns: false - """ - @spec boolean?(t()) :: boolean() - def boolean?(%__MODULE__{enabled: value}), do: is_boolean(value) - - @doc """ - Processes a feature flag response from the PostHog API. - Handles both v3 and v4 API response formats. - - ## Parameters - - * `response` - The raw response from the API - - ## Examples - - # v4 API response - response = %{ - "flags" => %{ - "my-flag" => %{ - "enabled" => true, - "variant" => nil, - "metadata" => %{"payload" => "{\"color\": \"blue\"}"} - } - }, - "requestId" => "123" - } - Posthog.FeatureFlag.process_response(response) - # Returns: %{ - # flags: %{"my-flag" => %{"enabled" => true, "variant" => nil, "metadata" => %{"payload" => "{\"color\": \"blue\"}"}}}, - # feature_flags: %{"my-flag" => true}, - # feature_flag_payloads: %{"my-flag" => %{"color" => "blue"}}, - # request_id: "123" - # } - - # v3 API response - response = %{ - "featureFlags" => %{"my-flag" => true}, - "featureFlagPayloads" => %{"my-flag" => "{\"color\": \"blue\"}"}, - "requestId" => "123" - } - Posthog.FeatureFlag.process_response(response) - # Returns: %{ - # feature_flags: %{"my-flag" => true}, - # feature_flag_payloads: %{"my-flag" => %{"color" => "blue"}}, - # request_id: "123" - # } - """ - @spec process_response(map()) :: %{ - flags: map() | nil, - feature_flags: %{optional(binary()) => variant()}, - feature_flag_payloads: %{optional(binary()) => term()}, - request_id: binary() | nil - } - def process_response(%{"flags" => flags} = response) do - 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_payload(v["metadata"]["payload"]), - else: nil - )} - end) - - %{ - flags: flags, - feature_flags: feature_flags, - feature_flag_payloads: feature_flag_payloads, - request_id: response["requestId"] - } - end - - def process_response(response) do - %{ - flags: nil, - feature_flags: Map.get(response, "featureFlags", %{}), - feature_flag_payloads: decode_payloads(Map.get(response, "featureFlagPayloads", %{})), - request_id: response["requestId"] - } - end - - @doc """ - Decodes a map of feature flag payloads. - - ## Parameters - - * `payloads` - Map of feature flag names to their payload values - - ## Examples - - payloads = %{ - "my-flag" => "{\"color\": \"blue\"}", - "other-flag" => "plain-text" - } - Posthog.FeatureFlag.decode_payloads(payloads) - # Returns: %{ - # "my-flag" => %{"color" => "blue"}, - # "other-flag" => "plain-text" - # } - """ - @spec decode_payloads(%{optional(binary()) => term()}) :: %{optional(binary()) => term()} - def decode_payloads(payloads) do - Enum.reduce(payloads, %{}, fn {k, v}, map -> - Map.put(map, k, decode_payload(v)) - end) - end - - @doc """ - Decodes a feature flag payload from JSON string to Elixir term. - Returns the original payload if it's not a valid JSON string. - - ## Examples - - # JSON string payload - Posthog.FeatureFlag.decode_payload("{\"color\": \"blue\"}") - # Returns: %{"color" => "blue"} - - # Non-JSON string payload - Posthog.FeatureFlag.decode_payload("plain-text") - # Returns: "plain-text" - - # Nil payload - Posthog.FeatureFlag.decode_payload(nil) - # Returns: nil - """ - @spec decode_payload(term()) :: term() - def decode_payload(nil), do: nil - - def decode_payload(payload) when is_binary(payload) do - case Posthog.Config.json_library().decode(payload) do - {:ok, decoded} -> decoded - {:error, _} -> payload - end - end - - def decode_payload(payload), do: payload -end diff --git a/lib/posthog/handler.ex b/lib/posthog/handler.ex new file mode 100644 index 0000000..85e22b9 --- /dev/null +++ b/lib/posthog/handler.ex @@ -0,0 +1,157 @@ +defmodule PostHog.Handler do + @moduledoc """ + A [`logger handler`](https://www.erlang.org/doc/apps/kernel/logger_chapter.html#handlers). + """ + @behaviour :logger_handler + + alias PostHog.Context + + @impl :logger_handler + def log(log_event, %{config: config}) do + maybe_properties = + cond do + get_in(log_event, [:meta, :crash_reason]) -> + properties(log_event, config) + + is_nil(config.capture_level) -> + nil + + Logger.compare_levels(log_event.level, config.capture_level) in [:gt, :eq] -> + properties(log_event, config) + + true -> + nil + end + + with %{} = properties <- maybe_properties do + PostHog.bare_capture( + config.supervisor_name, + "$exception", + Map.get(properties, :distinct_id, "unknown"), + properties + ) + end + + :ok + end + + defp properties(log_event, config) do + exception = + Enum.reduce( + [&type/1, &value/1, &stacktrace(&1, config.in_app_modules)], + %{ + mechanism: %{handled: not Map.has_key?(log_event.meta, :crash_reason), type: "generic"} + }, + fn fun, acc -> + Map.merge(acc, fun.(log_event)) + end + ) + + metadata = + log_event.meta + |> Map.take([:distinct_id | config.metadata]) + |> Map.drop(["$exception_list"]) + |> LoggerJSON.Formatter.RedactorEncoder.encode([]) + + Context.get(config.supervisor_name, "$exception") + |> enrich_context(log_event) + |> Map.put(:"$exception_list", [exception]) + |> Map.merge(metadata) + end + + defp type(log_event) do + log_event + |> do_type() + |> String.split("\n") + |> then(fn [type | _] -> %{type: type} end) + end + + defp do_type(%{meta: %{crash_reason: {reason, _}}}) when is_exception(reason), + do: inspect(reason.__struct__) + + defp do_type(%{meta: %{crash_reason: {{:nocatch, throw}, _}}}), + do: Exception.format_banner(:throw, throw) + + defp do_type(%{meta: %{crash_reason: {reason, _}}}), + do: Exception.format_banner(:exit, reason) + + defp do_type(%{msg: {:string, chardata}}), do: IO.iodata_to_binary(chardata) + + defp do_type(%{msg: {:report, report}, meta: %{report_cb: report_cb}}) + when is_function(report_cb, 1) do + {io_format, data} = report_cb.(report) + + io_format + |> :io_lib.format(data) + |> IO.iodata_to_binary() + end + + defp do_type(%{msg: {:report, report}}), do: inspect(report) + + defp do_type(%{msg: {io_format, data}}), + do: io_format |> :io_lib.format(data) |> IO.iodata_to_binary() + + defp value(%{meta: %{crash_reason: {reason, stacktrace}}}) when is_exception(reason), + do: %{value: Exception.format_banner(:error, reason, stacktrace)} + + defp value(%{meta: %{crash_reason: {{:nocatch, throw}, stacktrace}}}), + do: %{value: Exception.format_banner(:throw, throw, stacktrace)} + + defp value(%{meta: %{crash_reason: {reason, stacktrace}}}), + do: %{value: Exception.format_banner(:exit, reason, stacktrace)} + + defp value(%{msg: {:string, chardata}}), do: %{value: IO.iodata_to_binary(chardata)} + + defp value(%{msg: {:report, report}, meta: %{report_cb: report_cb}}) + when is_function(report_cb, 1) do + {io_format, data} = report_cb.(report) + io_format |> :io_lib.format(data) |> IO.iodata_to_binary() |> then(&%{value: &1}) + end + + defp value(%{msg: {:report, report}}), do: %{value: inspect(report)} + + defp value(%{msg: {io_format, data}}), + do: io_format |> :io_lib.format(data) |> IO.iodata_to_binary() |> then(&%{value: &1}) + + defp stacktrace(%{meta: %{crash_reason: {_reason, [_ | _] = stacktrace}}}, in_app_modules) do + frames = + for {module, function, arity_or_args, location} <- stacktrace do + in_app = module in in_app_modules + + %{ + platform: "custom", + lang: "elixir", + function: Exception.format_mfa(module, function, arity_or_args), + filename: Keyword.get(location, :file, []) |> IO.chardata_to_string(), + lineno: Keyword.get(location, :line), + module: inspect(module), + in_app: in_app, + resolved: true + } + end + + %{ + stacktrace: %{ + type: "raw", + frames: frames + } + } + end + + defp stacktrace(_event, _), do: %{} + + defp enrich_context(context, %{meta: %{conn: conn}}) when is_struct(conn, Plug.Conn) do + case context do + # Context was set and survived + %{"$current_url" => _} -> + context + + _ -> + conn + |> PostHog.Integrations.Plug.conn_to_context() + |> Map.merge(context) + end + end + + defp enrich_context(context, _log_event), do: context +end diff --git a/lib/posthog/http_client.ex b/lib/posthog/http_client.ex deleted file mode 100644 index f4784ac..0000000 --- a/lib/posthog/http_client.ex +++ /dev/null @@ -1,68 +0,0 @@ -defmodule Posthog.HTTPClient do - @moduledoc """ - Behaviour for HTTP client implementations. - - This allows for easy swapping of HTTP clients and better testability. - """ - - @type headers :: [{binary(), binary()}] - @type response :: %{status: pos_integer(), headers: headers(), body: map() | nil} - @type body :: iodata() | binary() - @type url :: binary() - - @doc """ - Makes a POST request to the given URL with the specified body and headers. - - Returns `{:ok, response}` on success or `{:error, reason}` on failure. - """ - @callback post(url(), body(), headers(), keyword()) :: {:ok, response()} | {:error, term()} -end - -defmodule Posthog.HTTPClient.Hackney do - @moduledoc """ - Hackney-based implementation of the Posthog.HTTPClient behaviour. - """ - - @behaviour Posthog.HTTPClient - - @default_timeout 5_000 - @default_retries 3 - @default_retry_delay 1_000 - - @impl true - def post(url, body, headers, opts \\ []) do - timeout = Keyword.get(opts, :timeout, @default_timeout) - retries = Keyword.get(opts, :retries, @default_retries) - retry_delay = Keyword.get(opts, :retry_delay, @default_retry_delay) - - do_post(url, body, headers, timeout, retries, retry_delay) - end - - defp do_post(url, body, headers, timeout, retries, retry_delay) do - case :hackney.post(url, headers, body, []) do - {:ok, status, _headers, _ref} = resp when div(status, 100) == 2 -> - {:ok, to_response(resp)} - - {:ok, _status, _headers, _ref} = resp -> - {:error, to_response(resp)} - - {:error, _reason} when retries > 0 -> - Process.sleep(retry_delay) - do_post(url, body, headers, timeout, retries - 1, retry_delay) - - {:error, _reason} = error -> - error - end - end - - defp to_response({_, status, headers, ref}) do - response = %{status: status, headers: headers, body: nil} - - with {:ok, body} <- :hackney.body(ref), - {:ok, json} <- Posthog.Config.json_library().decode(body) do - %{response | body: json} - else - _ -> response - end - end -end diff --git a/lib/posthog/integrations/plug.ex b/lib/posthog/integrations/plug.ex new file mode 100644 index 0000000..b930dc6 --- /dev/null +++ b/lib/posthog/integrations/plug.ex @@ -0,0 +1,69 @@ +defmodule PostHog.Integrations.Plug do + @moduledoc """ + Provides a plug that automatically extracts and sets relevant metadata from + `Plug.Conn`. + + For Phoenix apps, add it to your `endpoint.ex` somewhere before your router: + + plug PostHog.Integrations.Plug + + For Plug apps, add it directly to your router: + + defmodule MyRouterPlug do + use Plug.Router + + plug PostHog.Integrations.Plug + plug :match + plug :dispatch + + ... + end + """ + + @doc false + def init(opts), do: opts + + @doc false + def call(conn, _opts) do + context = conn_to_context(conn) + PostHog.Context.set(:all, "$exception", context) + + conn + end + + @doc false + def conn_to_context(conn) when is_struct(conn, Plug.Conn) do + query_string = if conn.query_string == "", do: nil, else: conn.query_string + + %{ + "$current_url": + %URI{ + scheme: to_string(conn.scheme), + host: conn.host, + path: conn.request_path, + query: query_string + } + |> URI.to_string(), + "$host": conn.host, + "$pathname": conn.request_path, + "$ip": remote_ip(conn) + } + end + + defp remote_ip(conn) when is_struct(conn, Plug.Conn) do + # Avoid compilation warnings for cases where Plug isn't available + remote_ip = + case apply(Plug.Conn, :get_req_header, [conn, "x-forwarded-for"]) do + [x_forwarded_for | _] -> + x_forwarded_for |> String.split(",", parts: 2) |> List.first() + + [] -> + case :inet.ntoa(conn.remote_ip) do + {:error, _} -> "" + address -> to_string(address) + end + end + + String.trim(remote_ip) + end +end diff --git a/lib/posthog/registry.ex b/lib/posthog/registry.ex new file mode 100644 index 0000000..4e8bad9 --- /dev/null +++ b/lib/posthog/registry.ex @@ -0,0 +1,16 @@ +defmodule PostHog.Registry do + @moduledoc false + def config(supervisor_name) do + {:ok, config} = + supervisor_name + |> registry_name() + |> Registry.meta(:config) + + config + end + + def registry_name(supervisor_name), do: Module.concat(supervisor_name, Registry) + + def via(supervisor_name, server_name), + do: {:via, Registry, {registry_name(supervisor_name), server_name}} +end diff --git a/lib/posthog/sender.ex b/lib/posthog/sender.ex new file mode 100644 index 0000000..bcc6372 --- /dev/null +++ b/lib/posthog/sender.ex @@ -0,0 +1,86 @@ +defmodule PostHog.Sender do + @moduledoc false + use GenServer + + defstruct [ + :registry, + :api_client, + :max_batch_time_ms, + :max_batch_events, + events: [], + num_events: 0 + ] + + def start_link(opts) do + name = + opts + |> Keyword.fetch!(:supervisor_name) + |> PostHog.Registry.via(__MODULE__) + + callers = Process.get(:"$callers", []) + Process.flag(:trap_exit, true) + + GenServer.start_link(__MODULE__, {opts, callers}, name: name) + end + + # Client + + def send(event, supervisor_name) do + supervisor_name + |> PostHog.Registry.via(__MODULE__) + |> GenServer.cast({:event, event}) + end + + # Callbacks + + @impl GenServer + def init({opts, callers}) do + state = %__MODULE__{ + registry: PostHog.Registry.registry_name(opts[:supervisor_name]), + api_client: Keyword.fetch!(opts, :api_client), + max_batch_time_ms: Keyword.fetch!(opts, :max_batch_time_ms), + max_batch_events: Keyword.fetch!(opts, :max_batch_events), + events: [], + num_events: 0 + } + + Process.put(:"$callers", callers) + + {:ok, state} + end + + @impl GenServer + def handle_cast({:event, event}, state) do + case state do + %{num_events: n, events: events} when n + 1 >= state.max_batch_events -> + {:noreply, %{state | events: [event | events], num_events: n + 1}, + {:continue, :send_batch}} + + %{num_events: 0, events: events} -> + Process.send_after(self(), :batch_time_reached, state.max_batch_time_ms) + + {:noreply, %{state | events: [event | events], num_events: 1}} + + %{num_events: n, events: events} -> + {:noreply, %{state | events: [event | events], num_events: n + 1}} + end + end + + @impl GenServer + def handle_info(:batch_time_reached, state) do + {:noreply, state, {:continue, :send_batch}} + end + + @impl GenServer + def handle_continue(:send_batch, state) do + PostHog.API.post_batch(state.api_client, state.events) + {:noreply, %{state | events: [], num_events: 0}} + end + + @impl GenServer + def terminate(_reason, %{num_events: n} = state) when n > 0 do + PostHog.API.post_batch(state.api_client, state.events) + end + + def terminate(_reason, _state), do: :ok +end diff --git a/lib/posthog/supervisor.ex b/lib/posthog/supervisor.ex new file mode 100644 index 0000000..7076048 --- /dev/null +++ b/lib/posthog/supervisor.ex @@ -0,0 +1,46 @@ +defmodule PostHog.Supervisor do + @moduledoc """ + Supervisor that manages all processes required for logging. By default, + `PostHog` starts it automatically. + """ + use Supervisor + + def child_spec(config) do + Supervisor.child_spec( + %{ + id: config.supervisor_name, + start: {__MODULE__, :start_link, [config]}, + type: :supervisor + }, + [] + ) + end + + @spec start_link(PostHog.Config.config()) :: Supervisor.on_start() + def start_link(config) do + callers = Process.get(:"$callers", []) + Supervisor.start_link(__MODULE__, {config, callers}, name: config.supervisor_name) + end + + @impl Supervisor + def init({config, callers}) do + children = + [ + {Registry, + keys: :unique, + name: PostHog.Registry.registry_name(config.supervisor_name), + meta: [config: config]}, + {PostHog.Sender, + [ + api_client: config.api_client, + supervisor_name: config.supervisor_name, + max_batch_time_ms: Map.get(config, :max_batch_time_ms, to_timeout(second: 10)), + max_batch_events: Map.get(config, :max_batch_events, 100) + ]} + ] + + Process.put(:"$callers", callers) + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/mix.exs b/mix.exs index 3108304..a4eeac2 100644 --- a/mix.exs +++ b/mix.exs @@ -1,69 +1,70 @@ -defmodule Posthog.MixProject do +defmodule PostHog.MixProject do use Mix.Project - @version "1.1.0" + @version "2.0.0" + @source_url "https://github.com/posthog/posthog-elixir" def project do [ app: :posthog, - deps: deps(), - description: description(), - elixir: "~> 1.14", + version: @version, + elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), - package: package(), - docs: docs(), start_permanent: Mix.env() == :prod, - version: @version + deps: deps(), + docs: docs(), + package: package() ] end def application do [ - extra_applications: [:logger], - mod: {Posthog.Application, []} + mod: {PostHog.Application, []}, + extra_applications: [:logger] ] end - defp description do - """ - Official PostHog Elixir HTTP client. - """ - end - defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] defp package do [ name: :posthog, - files: ["lib", "mix.exs", "README.md", "LICENSE"], maintainers: ["PostHog"], licenses: ["MIT"], - links: %{"GitHub" => "https://github.com/posthog/posthog-elixir"} + description: "Official PostHog Elixir SDK", + links: %{"GitHub" => @source_url} ] end defp docs do [ + main: "readme", favicon: "docs/favicon.svg", logo: "docs/favicon.svg", source_ref: "v#{@version}", - source_url: "https://github.com/posthog/posthog-elixir", - extras: ["README.md", "CHANGELOG.md", "MIGRATION.md"] + source_url: @source_url, + assets: %{ + "assets" => "assets" + }, + extras: ["README.md", "CHANGELOG.md", "MIGRATION.md", "guides/advanced-configuration.md"], + groups_for_modules: [ + Integrations: [PostHog.Integrations.Plug] + ], + skip_undefined_reference_warnings_on: ["CHANGELOG.md", "MIGRATION.md"] ] end defp deps do [ - {:dialyxir, "~> 1.3", only: [:dev, :test], runtime: false}, - {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, - {:hackney, "~> 1.23"}, - {:uniq, "~> 0.6.1"}, - {:jason, "~> 1.4", optional: true}, - {:mimic, "~> 1.11", only: :test}, - {:cachex, "~> 4.0.4"}, + {:nimble_options, "~> 1.1"}, + {:req, "~> 0.5.10"}, + {:logger_json, "~> 7.0"}, # Development tools - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:ex_doc, "~> 0.37", only: :dev, runtime: false}, + {:logger_handler_kit, "~> 0.3", only: :test}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:mox, "~> 1.1", only: :test} ] end end diff --git a/mix.lock b/mix.lock index 174fd6f..23ac263 100644 --- a/mix.lock +++ b/mix.lock @@ -1,31 +1,34 @@ %{ + "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "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"}, + "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, - "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"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, - "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"}, - "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"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "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"}, + "logger_handler_kit": {:hex, :logger_handler_kit, "0.3.0", "15b3a2e260bfbb58036ff3bae3b346ca2287df61ce2e8cf3cbcde3f9041d75a0", [:mix], [{:bandit, "~> 1.7", [hex: :bandit, repo: "hexpm", optional: false]}, {:mint, "~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: false]}], "hexpm", "66d3e0215db2a8a33a1abc0b94a5b201612d3f9262e0f05f49d754ac2bab1da9"}, + "logger_json": {:hex, :logger_json, "7.0.3", "0f202788d743154796bd208e1078d878bb4fccf0adc4ed9c83cba821732d326c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9d1ca7dfe77eb7eac4664edfd6f874168d4707aedbedea09fba8eaa6ed2e2f97"}, "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"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, - "mimic": {:hex, :mimic, "1.11.0", "49b126687520b6e179acab305068ad7d72bfea8abe94908a6c0c8ca0a5b7bdc7", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "8b16b1809ca947cffbaede146cd42da8c1c326af67a84b59b01c204d54e4f1a2"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "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"}, - "uniq": {:hex, :uniq, "0.6.1", "369660ecbc19051be526df3aa85dc393af5f61f45209bce2fa6d7adb051ae03c", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "6426c34d677054b3056947125b22e0daafd10367b85f349e24ac60f44effb916"}, - "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "req": {:hex, :req, "0.5.14", "521b449fa0bf275e6d034c05f29bec21789a0d6cd6f7a1c326c7bee642bf6e07", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b7b15692071d556c73432c7797aa7e96b51d1a2db76f746b976edef95c930021"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, } diff --git a/test/integration_test.exs b/test/integration_test.exs new file mode 100644 index 0000000..0cf7207 --- /dev/null +++ b/test/integration_test.exs @@ -0,0 +1,90 @@ +defmodule PostHog.IntegrationTest do + require Config + use ExUnit.Case, async: false + + require Logger + + @moduletag integration: true + + setup_all do + {:ok, config} = + Application.fetch_env!(:posthog, :integration_config) |> PostHog.Config.validate() + + start_link_supervised!({PostHog.Supervisor, config}) + + wait = fn -> + sender_pid = + config.supervisor_name + |> PostHog.Registry.via(PostHog.Sender) + |> GenServer.whereis() + + send(sender_pid, :batch_time_reached) + :sys.get_status(sender_pid) + end + + :logger.add_handler(:posthog, PostHog.Handler, %{config: config}) + + %{wait_fun: wait} + end + + describe "error tracking" do + setup %{test: test} do + Logger.metadata(distinct_id: test) + end + + test "log message", %{wait_fun: wait} do + Logger.info("Hello World!") + wait.() + end + + test "genserver crash exception", %{wait_fun: wait} do + LoggerHandlerKit.Act.genserver_crash(:exception) + wait.() + end + + test "task exception", %{wait_fun: wait} do + LoggerHandlerKit.Act.task_error(:exception) + wait.() + end + + test "task throw", %{wait_fun: wait} do + LoggerHandlerKit.Act.task_error(:throw) + wait.() + end + + test "task exit", %{wait_fun: wait} do + LoggerHandlerKit.Act.task_error(:exit) + wait.() + end + + test "exports metadata", %{wait_fun: wait} do + LoggerHandlerKit.Act.metadata_serialization(:all) + Logger.error("Error with metadata") + wait.() + end + + test "supervisor report", %{wait_fun: wait} do + Application.stop(:logger) + Application.put_env(:logger, :handle_sasl_reports, true) + Application.put_env(:logger, :level, :info) + Application.start(:logger) + + on_exit(fn -> + Application.stop(:logger) + Application.put_env(:logger, :handle_sasl_reports, false) + Application.delete_env(:logger, :level) + Application.start(:logger) + end) + + LoggerHandlerKit.Act.supervisor_progress_report(:failed_to_start_child) + wait.() + end + end + + describe "event capture" do + test "captures event", %{test: test, wait_fun: wait} do + PostHog.capture("case tested", test, %{number: 1}) + wait.() + end + end +end diff --git a/test/posthog/client_test.exs b/test/posthog/client_test.exs deleted file mode 100644 index f196f4e..0000000 --- a/test/posthog/client_test.exs +++ /dev/null @@ -1,323 +0,0 @@ -defmodule Posthog.ClientTest do - # Async tests are not supported in this file - # because we're using the process state to track the number of times - # a function is called. - use ExUnit.Case, async: false - import Mimic - - # Make private functions testable - @moduletag :capture_log - import Posthog.Client, only: [], warn: false - alias Posthog.Client - - 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 and captures event" do - stub_with(:hackney, HackneyStub) - - HackneyStub.verify_capture(fn decoded -> - assert decoded["event"] == "$feature_flag_called" - assert decoded["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" - }} = Client.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["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 - }} = Client.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 - # Initialize the counter in the process dictionary - Process.put(:capture_count, 0) - - stub_with(:hackney, HackneyStub) - copy(Client) - - stub(Client, :capture, fn "$feature_flag_called", _distinct_id, properties, _opts -> - # Increment the counter in the process dictionary - Process.put(:capture_count, Process.get(:capture_count) + 1) - - 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 - }} = Client.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 - }} = Client.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 - # Initialize the counter in the process dictionary - Process.put(:capture_count, 0) - - # Keep track of seen combinations - Process.put(:seen_combinations, MapSet.new()) - - stub_with(:hackney, HackneyStub) - copy(Client) - - stub(Client, :capture, fn "$feature_flag_called", distinct_id, properties, _opts -> - # Increment the counter in the process dictionary - Process.put(:capture_count, Process.get(:capture_count) + 1) - - # Add this combination to seen combinations - Process.put( - :seen_combinations, - MapSet.put(Process.get(:seen_combinations), { - distinct_id, - properties["$feature_flag"], - properties["$feature_flag_response"] - }) - ) - - # Verify properties are correct regardless of order - assert 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 - }} = Client.feature_flag("my-multivariate-flag", "user_123") - - assert {:ok, - %Posthog.FeatureFlag{ - name: "my-multivariate-flag", - enabled: "some-string-value", - payload: nil - }} = Client.feature_flag("my-multivariate-flag", "user_456") - - assert {:ok, - %Posthog.FeatureFlag{ - name: "my-awesome-flag", - enabled: true, - payload: "example-payload-string" - }} = Client.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(Client) - reject(&Client.capture/3) - - assert {:ok, - %Posthog.FeatureFlag{ - name: "my-multivariate-flag", - enabled: "some-string-value", - payload: nil - }} = - Client.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 Client.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, HackneyStub) - - assert Client.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, HackneyStub) - - assert Client.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 Client.feature_flag("does-not-exist", "user_123") == - {:error, :not_found} - 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["distinct_id"] == "user_123" - {:ok, 200, [], "ref"} - end) - - stub(:hackney, :body, fn "ref" -> {:ok, "{}"} end) - - assert {:ok, %{status: 200}} = Client.capture("test_event", "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", "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", "user_123", %{}, - headers: [{"x-forwarded-for", "127.0.0.1"}] - ) - end - end - - describe "enabled_capture" do - test "when enabled_capture is false, capture returns success without making request" do - Application.put_env(:posthog, :enabled_capture, false) - on_exit(fn -> Application.delete_env(:posthog, :enabled_capture) end) - - assert Client.capture("test_event", "user_123") == - {:ok, %{status: 200, headers: [], body: nil}} - end - - test "when enabled_capture is false, batch returns success without making request" do - Application.put_env(:posthog, :enabled_capture, false) - on_exit(fn -> Application.delete_env(:posthog, :enabled_capture) end) - - events = [ - {"test_event", %{distinct_id: "user_123"}, nil}, - {"another_event", %{distinct_id: "user_123"}, nil} - ] - - assert Client.batch(events, []) == - {:ok, %{status: 200, headers: [], body: nil}} - end - - test "when enabled_capture is false, feature_flags still works" do - Application.put_env(:posthog, :enabled_capture, false) - on_exit(fn -> Application.delete_env(:posthog, :enabled_capture) end) - - # Stub FF HTTP request - stub_with(:hackney, HackneyStub) - - assert {:ok, %{feature_flags: flags}} = Client.feature_flags("user_123", []) - assert flags["my-awesome-flag"] == true - end - end -end diff --git a/test/posthog/context_test.exs b/test/posthog/context_test.exs new file mode 100644 index 0000000..af5f313 --- /dev/null +++ b/test/posthog/context_test.exs @@ -0,0 +1,62 @@ +defmodule PostHog.ContextTest do + use ExUnit.Case, async: true + + alias PostHog.Context + + test "sets context for specific scope" do + assert :ok = Context.set(PostHog, "$exception", %{foo: "bar"}) + assert [__posthog__: %{PostHog => %{"$exception" => %{foo: "bar"}}}] = Logger.metadata() + end + + test "context is merged" do + Context.set(PostHog, "$exception", %{foo: "bar"}) + Context.set(PostHog, "$exception", %{foo: "baz", eggs: "spam"}) + + assert [__posthog__: %{PostHog => %{"$exception" => %{eggs: "spam", foo: "baz"}}}] = + Logger.metadata() + end + + test "but not deep merged" do + Context.set(PostHog, "$exception", %{foo: %{eggs: "spam"}}) + Context.set(PostHog, "$exception", %{foo: %{bar: "baz"}}) + + assert [__posthog__: %{PostHog => %{"$exception" => %{foo: %{bar: "baz"}}}}] = + Logger.metadata() + end + + test "multiple scopes" do + Context.set(PostHog, :all, %{foo: "bar"}) + Context.set(MyPostHog, "$exception", %{foo: "baz"}) + Context.set(:all, :all, %{hello: "world"}) + + assert [ + __posthog__: %{ + PostHog => %{all: %{foo: "bar"}}, + MyPostHog => %{"$exception" => %{foo: "baz"}}, + all: %{all: %{hello: "world"}} + } + ] = Logger.metadata() + end + + test "get/0 retrieves context with scope + all" do + Context.set(PostHog, :all, %{foo: "bar"}) + Context.set(MyPostHog, "$exception", %{foo: "baz"}) + Context.set(:all, :all, %{hello: "world"}) + Logger.metadata(foo: "baz") + + assert %{foo: "bar", hello: "world"} = Context.get(PostHog, "$exception") + assert %{foo: "baz", hello: "world"} = Context.get(MyPostHog, "$exception") + assert %{hello: "world"} = Context.get(MyPostHog, "$exception_list") + assert %{hello: "world"} = Context.get(FooBar, "$exception") + end + + test "in case of overlapping keys prefer more specific scope" do + Context.set(PostHog, :all, %{foo: 1}) + Context.set(PostHog, "$exception", %{foo: 2}) + Context.set(:all, :all, %{foo: 3}) + Context.set(:all, "$exception", %{foo: 4}) + + assert %{foo: 2} = Context.get(PostHog, "$exception") + assert %{foo: 4} = Context.get(:all, "$exception") + end +end diff --git a/test/posthog/event_test.exs b/test/posthog/event_test.exs deleted file mode 100644 index 7c696bc..0000000 --- a/test/posthog/event_test.exs +++ /dev/null @@ -1,132 +0,0 @@ -defmodule Posthog.EventTest do - use ExUnit.Case, async: true - doctest Posthog.Event - - alias Posthog.Event - - defstruct [:name] - - describe "new/4" do - test "creates an event with default values" do - event = Event.new("test_event", "user_123") - - assert event.event == "test_event" - assert event.distinct_id == "user_123" - assert event.properties == %{} - assert is_binary(event.timestamp) - - # Generated valid UUID - assert event.uuid - assert is_binary(event.uuid) - assert String.length(event.uuid) == 36 - end - - test "creates an event with properties" do - properties = %{price: 99.99, quantity: 2} - event = Event.new("purchase", "user_123", properties) - - assert event.event == "purchase" - assert event.distinct_id == "user_123" - assert event.properties == properties - end - - test "creates an event with custom timestamp" do - timestamp = "2023-01-01T00:00:00Z" - event = Event.new("login", "user_123", %{}, timestamp: timestamp) - - assert event.timestamp == timestamp - end - - test "creates an event with custom UUID" do - uuid = "123e4567-e89b-12d3-a456-426614174000" - event = Event.new("signup", "user_123", %{}, uuid: uuid) - - assert event.uuid == uuid - end - - test "converts atom event name to string" do - event = Event.new(:page_view, "user_123") - - assert event.event == "page_view" - end - end - - describe "to_api_payload/1" do - test "converts event to API payload" do - event = Event.new("page_view", "user_123", %{page: "home"}) - payload = Event.to_api_payload(event) - - assert payload.event == "page_view" - assert payload.distinct_id == "user_123" - assert payload.properties["page"] == "home" - assert payload.properties["$lib"] == "posthog-elixir" - assert payload.properties["$lib_version"] == Mix.Project.config()[:version] - assert is_binary(payload.uuid) - assert is_binary(payload.timestamp) - end - - test "overrides library properties with custom properties" do - event = - Event.new( - "page_view", - "user_123", - %{"$lib" => "custom", "$lib_version" => "1.0.0"} - ) - - payload = Event.to_api_payload(event) - - assert payload.properties["$lib"] == "custom" - assert payload.properties["$lib_version"] == "1.0.0" - end - - test "deep stringifies property keys" do - event = Event.new("test", "user_123", %{user: %{firstName: "John", lastName: "Doe"}}) - payload = Event.to_api_payload(event) - - assert payload.properties["user"]["firstName"] == "John" - assert payload.properties["user"]["lastName"] == "Doe" - end - - test "handles nested lists in properties" do - event = Event.new("test", "user_123", %{tags: ["elixir", "posthog"]}) - payload = Event.to_api_payload(event) - - assert payload.properties["tags"] == ["elixir", "posthog"] - end - - test "handles structs in properties" do - event = - Event.new("test", "user_123", %{ - tags: ["elixir", "posthog"], - event: %__MODULE__{name: "test"} - }) - - payload = Event.to_api_payload(event) - - assert payload.properties["tags"] == ["elixir", "posthog"] - assert payload.properties["event"]["name"] == "test" - end - end - - describe "batch_payload/1" do - test "creates a batch payload from multiple events" do - events = [ - Event.new("page_view", "user_123", %{page: "home"}), - Event.new("click", "user_123", %{button: "signup"}) - ] - - batch = Event.batch_payload(events) - - assert length(batch.batch) == 2 - [first, second] = batch.batch - assert first.event == "page_view" - assert second.event == "click" - end - - test "handles empty list" do - batch = Event.batch_payload([]) - - assert batch.batch == [] - end - end -end diff --git a/test/posthog/handler_test.exs b/test/posthog/handler_test.exs new file mode 100644 index 0000000..5bf7734 --- /dev/null +++ b/test/posthog/handler_test.exs @@ -0,0 +1,1004 @@ +defmodule PostHog.HandlerTest do + use PostHog.Case, async: true + + require Logger + + @moduletag capture_log: true + + setup {LoggerHandlerKit.Arrange, :ensure_per_handler_translation} + setup :setup_supervisor + setup :setup_logger_handler + + test "takes distinct_id from metadata", %{handler_ref: ref, sender_pid: sender_pid} do + Logger.info("Hello World", distinct_id: "foo") + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + distinct_id: "foo", + properties: %{ + "$exception_list": [ + %{ + type: "Hello World", + value: "Hello World", + mechanism: %{handled: true, type: "generic"} + } + ] + } + } = event + end + + test "always exports global context", %{handler_ref: ref, sender_pid: sender_pid} do + Logger.info("Hello World") + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$lib": "posthog-elixir", + "$lib_version": _, + "$exception_list": [ + %{ + type: "Hello World", + value: "Hello World", + mechanism: %{handled: true, type: "generic"} + } + ] + } + } = event + end + + @tag config: [capture_level: :warning] + test "ignores messages lower than capture_level", %{handler_ref: ref, sender_pid: sender_pid} do + Logger.info("Hello World") + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: []} = :sys.get_state(sender_pid) + end + + @tag config: [capture_level: :warning] + test "logs with crash reason always captured", %{handler_ref: ref, sender_pid: sender_pid} do + Logger.debug("Hello World", crash_reason: {"exit reason", []}) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "** (exit) \"exit reason\"", + value: "** (exit) \"exit reason\"", + mechanism: %{handled: false, type: "generic"} + } + ] + } + } = event + end + + test "string message", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.string_message() + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "Hello World", + value: "Hello World", + mechanism: %{handled: true} + } + ] + } + } = event + end + + test "charlist message", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.charlist_message() + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "Hello World", + value: "Hello World", + mechanism: %{handled: true} + } + ] + } + } = event + end + + test "chardata message", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.chardata_message(:proper) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "Hello World", + value: "Hello World", + mechanism: %{handled: true} + } + ] + } + } = event + end + + test "chardata message - improper", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.chardata_message(:improper) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "Hello World", + value: "Hello World", + mechanism: %{handled: true} + } + ] + } + } = event + end + + test "io format", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.io_format() + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "Hello World", + value: "Hello World", + mechanism: %{handled: true} + } + ] + } + } = event + end + + test "keyword report", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.keyword_report() + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "[hello: \"world\"]", + value: "[hello: \"world\"]", + mechanism: %{handled: true} + } + ] + } + } = event + end + + test "map report", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.map_report() + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "%{hello: \"world\"}", + value: "%{hello: \"world\"}", + mechanism: %{handled: true} + } + ] + } + } = event + end + + test "struct report", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.struct_report() + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "%LoggerHandlerKit.FakeStruct{hello: \"world\"}", + value: "%LoggerHandlerKit.FakeStruct{hello: \"world\"}", + mechanism: %{handled: true} + } + ] + } + } = event + end + + test "task error exception", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.task_error(:exception) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "RuntimeError", + value: "** (RuntimeError) oops", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + type: "raw", + frames: [ + %{ + in_app: false, + filename: "lib/logger_handler_kit/act.ex", + function: "anonymous fn/0 in LoggerHandlerKit.Act.task_error/1", + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + }, + %{ + in_app: false, + filename: "lib/task/supervised.ex", + function: "Task.Supervised.invoke_mfa/2", + lineno: _, + module: "Task.Supervised", + platform: "custom", + lang: "elixir" + } + ] + } + } + ] + } + } = event + end + + test "task error throw", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.task_error(:throw) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "** (throw) \"catch!\"", + value: "** (throw) \"catch!\"", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + type: "raw", + frames: [ + %{ + in_app: false, + filename: "lib/logger_handler_kit/act.ex", + function: "anonymous fn/0 in LoggerHandlerKit.Act.task_error/1", + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + }, + %{ + in_app: false, + filename: "lib/task/supervised.ex", + function: "Task.Supervised.invoke_mfa/2", + lineno: _, + module: "Task.Supervised", + platform: "custom", + lang: "elixir" + } + ] + } + } + ] + } + } = event + end + + test "task error exit", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.task_error(:exit) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "** (exit) \"i quit\"", + value: "** (exit) \"i quit\"", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + type: "raw", + frames: [ + %{ + in_app: false, + filename: "lib/logger_handler_kit/act.ex", + function: "anonymous fn/0 in LoggerHandlerKit.Act.task_error/1", + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + }, + %{ + in_app: false, + filename: "lib/task/supervised.ex", + function: "Task.Supervised.invoke_mfa/2", + lineno: _, + module: "Task.Supervised", + platform: "custom", + lang: "elixir" + } + ] + } + } + ] + } + } = event + end + + test "genserver crash exception", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.genserver_crash(:exception) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "RuntimeError", + value: "** (RuntimeError) oops", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + type: "raw", + frames: [ + %{ + in_app: false, + filename: "lib/logger_handler_kit/act.ex", + function: "anonymous fn/0 in LoggerHandlerKit.Act.genserver_crash/1", + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + }, + %{ + filename: "gen_server.erl", + function: ":gen_server.try_handle_call/4", + in_app: false, + lineno: _, + module: ":gen_server", + platform: "custom", + lang: "elixir" + }, + %{ + filename: "gen_server.erl", + function: ":gen_server.handle_msg" <> _, + in_app: false, + lineno: _, + module: ":gen_server", + platform: "custom", + lang: "elixir" + }, + %{ + filename: "proc_lib.erl", + function: ":proc_lib.init_p_do_apply/3", + in_app: false, + lineno: _, + module: ":proc_lib", + platform: "custom", + lang: "elixir" + } + ] + } + } + ] + } + } = event + end + + test "genserver crash exit", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.genserver_crash(:exit) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "** (exit) \"i quit\"", + value: "** (exit) \"i quit\"", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + type: "raw", + frames: [ + %{ + in_app: false, + filename: "lib/logger_handler_kit/act.ex", + function: "anonymous fn/0 in LoggerHandlerKit.Act.genserver_crash/1", + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + }, + %{ + filename: "gen_server.erl", + function: ":gen_server.try_handle_call/4", + lineno: _, + module: ":gen_server", + platform: "custom", + lang: "elixir", + in_app: false + }, + %{ + filename: "gen_server.erl", + function: ":gen_server.handle_msg" <> _, + lineno: _, + module: ":gen_server", + platform: "custom", + lang: "elixir", + in_app: false + }, + %{ + filename: "proc_lib.erl", + function: ":proc_lib.init_p_do_apply/3", + in_app: false, + lineno: _, + module: ":proc_lib", + platform: "custom", + lang: "elixir" + } + ] + } + } + ] + } + } = event + end + + test "genserver crash exit with struct", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.genserver_crash(:exit_with_struct) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "** (exit) %LoggerHandlerKit.FakeStruct{hello: \"world\"}", + value: "** (exit) %LoggerHandlerKit.FakeStruct{hello: \"world\"}", + mechanism: %{handled: false, type: "generic"} + } + ] + } + } = event + end + + test "genserver crash throw", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.genserver_crash(:throw) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "** (exit) bad return value: \"catch!\"", + value: "** (exit) bad return value: \"catch!\"", + mechanism: %{handled: false, type: "generic"} + } + ] + } + } = event + end + + test "gen_state_m crash exception", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.gen_statem_crash(:exception) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "RuntimeError", + value: "** (RuntimeError) oops", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + frames: [ + %{ + function: "anonymous fn/0 in LoggerHandlerKit.Act.gen_statem_crash/1", + module: "LoggerHandlerKit.Act", + filename: "lib/logger_handler_kit/act.ex", + in_app: false, + lineno: _, + platform: "custom", + lang: "elixir" + }, + %{ + function: ":gen_statem.loop_state_callback/11", + module: ":gen_statem", + filename: "gen_statem.erl", + in_app: false, + lineno: _, + platform: "custom", + lang: "elixir" + }, + %{ + function: ":proc_lib.init_p_do_apply/3", + module: ":proc_lib", + filename: "proc_lib.erl", + in_app: false, + lineno: _, + platform: "custom", + lang: "elixir" + } + ], + type: "raw" + } + } + ] + } + } = event + end + + test "bare process crash exception", %{ + handler_id: handler_id, + handler_ref: ref, + sender_pid: sender_pid + } do + LoggerHandlerKit.Act.bare_process_crash(handler_id, :exception) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "RuntimeError", + value: "** (RuntimeError) oops", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + frames: [ + %{ + filename: "lib/logger_handler_kit/act.ex", + function: "anonymous fn/0 in LoggerHandlerKit.Act.bare_process_crash/2", + in_app: false, + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + } + ], + type: "raw" + } + } + ] + } + } = event + end + + test "bare process crash throw", %{ + handler_id: handler_id, + handler_ref: ref, + sender_pid: sender_pid + } do + LoggerHandlerKit.Act.bare_process_crash(handler_id, :throw) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "ErlangError", + value: "** (ErlangError) Erlang error: {:nocatch, \"catch!\"}", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + frames: [ + %{ + filename: "lib/logger_handler_kit/act.ex", + function: "anonymous fn/0 in LoggerHandlerKit.Act.bare_process_crash/2", + in_app: false, + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + } + ], + type: "raw" + } + } + ] + } + } = event + end + + @tag handle_sasl_reports: true + test "genserver init crash", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.genserver_init_crash() + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "RuntimeError", + value: "** (RuntimeError) oops", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + frames: [ + %{ + filename: "lib/logger_handler_kit/act.ex", + function: + "anonymous fn/0 in LoggerHandlerKit.Act.genserver_init_crash/0", + in_app: false, + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + }, + %{ + filename: "gen_server.erl", + function: ":gen_server.init_it/2", + in_app: false, + lineno: _, + module: ":gen_server", + platform: "custom", + lang: "elixir" + }, + %{ + filename: "gen_server.erl", + function: ":gen_server.init_it/6", + in_app: false, + lineno: _, + module: ":gen_server", + platform: "custom", + lang: "elixir" + }, + %{ + filename: "proc_lib.erl", + function: ":proc_lib.init_p_do_apply/3", + in_app: false, + lineno: _, + module: ":proc_lib", + platform: "custom", + lang: "elixir" + } + ], + type: "raw" + } + } + ] + } + } = event + end + + @tag handle_sasl_reports: true + test "proc_lib crash exception", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.proc_lib_crash(:exception) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "RuntimeError", + value: "** (RuntimeError) oops", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{ + frames: [ + %{ + filename: "lib/logger_handler_kit/act.ex", + function: "anonymous fn/1 in LoggerHandlerKit.Act.proc_lib_crash/1", + in_app: false, + lineno: _, + module: "LoggerHandlerKit.Act", + platform: "custom", + lang: "elixir" + }, + %{ + filename: "proc_lib.erl", + function: ":proc_lib.init_p/3", + in_app: false, + lineno: _, + module: ":proc_lib", + platform: "custom", + lang: "elixir" + } + ], + type: "raw" + } + } + ] + } + } = event + end + + @tag handle_sasl_reports: true + test "supervisor progress report failed to start child", %{ + handler_ref: ref, + sender_pid: sender_pid + } do + LoggerHandlerKit.Act.supervisor_progress_report(:failed_to_start_child) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "Child :task of Supervisor" <> type_end, + value: "Child :task of Supervisor" <> value_end, + mechanism: %{handled: true, type: "generic"} + } + ] + } + } = event + + assert String.ends_with?(type_end, "failed to start") + assert String.ends_with?(value_end, "\nType: :worker") + end + + @tag handle_sasl_reports: true, config: [capture_level: :debug] + test "supervisor progress report child started", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.supervisor_progress_report(:child_started) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "Child :task of Supervisor" <> type_end, + value: "Child :task of Supervisor" <> value_end, + mechanism: %{handled: true, type: "generic"} + } + ] + } + } = event + + assert String.ends_with?(type_end, "started") + assert String.ends_with?(value_end, "\nType: :worker") + end + + @tag handle_sasl_reports: true + test "supervisor progress report child terminated", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.supervisor_progress_report(:child_terminated) + LoggerHandlerKit.Assert.assert_logged(ref) + LoggerHandlerKit.Assert.assert_logged(ref) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [_, _, _] = events} = :sys.get_state(sender_pid) + + for event <- events do + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "Child :task of Supervisor" <> type_end, + mechanism: %{handled: true, type: "generic"} + } + ] + } + } = event + + assert Enum.any?([ + String.ends_with?(type_end, "started"), + String.ends_with?(type_end, "terminated"), + String.ends_with?(type_end, "caused shutdown") + ]) + end + end + + @tag config: [metadata: [:extra]] + test "exports metadata if configured", %{handler_ref: ref, sender_pid: sender_pid} do + Logger.error("Error with metadata", extra: "Foo", hello: "world") + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + extra: "Foo", + "$exception_list": [ + %{ + type: "Error with metadata", + value: "Error with metadata", + mechanism: %{handled: true} + } + ] + } + } = event + end + + @tag config: [metadata: [:extra]] + test "ensures metadata is serializable", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.metadata_serialization(:all) + LoggerHandlerKit.Act.string_message() + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{extra: maybe_encoded} + } = event + + assert %{ + boolean: true, + string: "hello world", + binary: "<<1, 2, 3>>", + atom: :foo, + integer: 42, + datetime: ~U[2025-06-01 12:34:56.000Z], + struct: %{hello: "world"}, + tuple: [:ok, "hello"], + keyword: %{hello: "world"}, + improper_keyword: "[[:a, 1] | {:b, 2}]", + fake_keyword: [[:a, 1], [:b, 2, :c]], + list: [1, 2, 3], + improper_list: "[1, 2 | 3]", + map: %{:hello => "world", "foo" => "bar"}, + function: "&LoggerHandlerKit.Act.metadata_serialization/1", + anonymous_function: "#Function<" <> _, + pid: "#PID<" <> _, + ref: "#Reference<" <> _, + port: "#Port<" <> _ + } = maybe_encoded + + JSON.encode!(maybe_encoded) + end + + @tag config: [metadata: [:extra]] + test "purposefully set context is always exported", %{ + config: config, + handler_ref: ref, + sender_pid: sender_pid + } do + PostHog.set_context(config.supervisor_name, %{foo: "bar"}) + Logger.error("Error with metadata", hello: "world") + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + foo: "bar", + "$exception_list": [ + %{ + type: "Error with metadata", + value: "Error with metadata", + mechanism: %{handled: true, type: "generic"} + } + ] + } + } = event + end + + test "erlang frames in stacktrace", %{handler_ref: ref, sender_pid: sender_pid} do + {:ok, _pid} = Task.start(fn -> :erlang.system_time(:foo) end) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + type: "ArgumentError", + value: + "** (ArgumentError) errors were found at the given arguments:\n\n * 1st argument: invalid time unit\n", + stacktrace: %{ + type: "raw", + frames: [ + %{ + filename: "", + function: ":erlang.system_time(:foo)", + in_app: false, + lineno: nil, + module: ":erlang", + platform: "custom", + lang: "elixir" + } + | _ + ] + }, + mechanism: %{type: "generic", handled: false} + } + ] + } + } = event + end + + @tag config: [in_app_otp_apps: [:logger_handler_kit]] + test "marks in_app frames as such", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.task_error(:exception) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: %{ + "$exception_list": [ + %{ + stacktrace: %{ + type: "raw", + frames: [ + %{ + in_app: true, + module: "LoggerHandlerKit.Act" + }, + %{ + in_app: false, + module: "Task.Supervised" + } + ] + } + } + ] + } + } = event + end +end diff --git a/test/posthog/integrations/plug_test.exs b/test/posthog/integrations/plug_test.exs new file mode 100644 index 0000000..87bd069 --- /dev/null +++ b/test/posthog/integrations/plug_test.exs @@ -0,0 +1,213 @@ +defmodule PostHog.Integrations.PlugTest do + # This unfortunately will be flaky in async mode until + # https://github.com/erlang/otp/issues/9997 is fixed + use PostHog.Case, async: false + + @moduletag capture_log: true, config: [capture_level: :error] + + setup {LoggerHandlerKit.Arrange, :ensure_per_handler_translation} + setup :setup_supervisor + setup :setup_logger_handler + + defmodule MyRouter do + use Plug.Router + require Logger + + plug(PostHog.Integrations.Plug) + plug(:match) + plug(:dispatch) + + forward("/", to: LoggerHandlerKit.Plug) + end + + test "sets relevant context" do + conn = Plug.Test.conn(:get, "https://posthog.com/foo?bar=10") + assert PostHog.Integrations.Plug.call(conn, nil) + + assert PostHog.Context.get(:all, "$exception") == %{ + "$current_url": "https://posthog.com/foo?bar=10", + "$host": "posthog.com", + "$ip": "127.0.0.1", + "$pathname": "/foo" + } + end + + describe "Bandit" do + test "context is attached to exceptions", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.plug_error(:exception, Bandit, MyRouter) + LoggerHandlerKit.Assert.assert_logged(ref) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: properties + } = event + + assert %{ + "$current_url": "http://localhost/exception", + "$host": "localhost", + "$ip": "127.0.0.1", + "$pathname": "/exception", + "$lib": "posthog-elixir", + "$lib_version": _, + "$exception_list": [ + %{ + type: "RuntimeError", + value: "** (RuntimeError) oops", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{type: "raw", frames: _frames} + } + ] + } = properties + end + + test "context is attached to throws", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.plug_error(:throw, Bandit, MyRouter) + LoggerHandlerKit.Assert.assert_logged(ref) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: properties + } = event + + assert %{ + "$current_url": "http://localhost/throw", + "$host": "localhost", + "$ip": "127.0.0.1", + "$pathname": "/throw", + "$lib": "posthog-elixir", + "$lib_version": _, + "$exception_list": [ + %{ + type: "** (throw) \"catch!\"", + value: "** (throw) \"catch!\"", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{type: "raw", frames: _frames} + } + ] + } = properties + end + + test "context is attached to exit", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.plug_error(:exit, Bandit, MyRouter) + LoggerHandlerKit.Assert.assert_logged(ref) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: properties + } = event + + assert %{ + "$current_url": "http://localhost/exit", + "$host": "localhost", + "$ip": "127.0.0.1", + "$pathname": "/exit", + "$lib": "posthog-elixir", + "$lib_version": _, + "$exception_list": [ + %{ + type: "** (exit) \"i quit\"", + value: "** (exit) \"i quit\"", + mechanism: %{handled: false, type: "generic"} + } + ] + } = properties + end + end + + describe "Cowboy" do + test "context is attached to exceptions", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.plug_error(:exception, Plug.Cowboy, MyRouter) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: properties + } = event + + assert %{ + "$current_url": "http://localhost/exception", + "$host": "localhost", + "$ip": "127.0.0.1", + "$pathname": "/exception", + "$lib": "posthog-elixir", + "$lib_version": _, + "$exception_list": [ + %{ + type: "RuntimeError", + value: "** (RuntimeError) oops", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{type: "raw", frames: _frames} + } + ] + } = properties + end + + test "context is attached to throws", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.plug_error(:throw, Plug.Cowboy, MyRouter) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: properties + } = event + + assert %{ + "$current_url": "http://localhost/throw", + "$host": "localhost", + "$ip": "127.0.0.1", + "$pathname": "/throw", + "$lib": "posthog-elixir", + "$lib_version": _, + "$exception_list": [ + %{ + type: "** (throw) \"catch!\"", + value: "** (throw) \"catch!\"", + mechanism: %{handled: false, type: "generic"}, + stacktrace: %{type: "raw", frames: _frames} + } + ] + } = properties + end + + test "context is attached to exit", %{handler_ref: ref, sender_pid: sender_pid} do + LoggerHandlerKit.Act.plug_error(:exit, Plug.Cowboy, MyRouter) + LoggerHandlerKit.Assert.assert_logged(ref) + + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "$exception", + properties: properties + } = event + + assert %{ + "$current_url": "http://localhost/exit", + "$host": "localhost", + "$ip": "127.0.0.1", + "$pathname": "/exit", + "$lib": "posthog-elixir", + "$lib_version": _, + "$exception_list": [ + %{ + type: "** (exit) \"i quit\"", + value: "** (exit) \"i quit\"", + mechanism: %{handled: false, type: "generic"} + } + ] + } = properties + end + end +end diff --git a/test/posthog/sender_test.exs b/test/posthog/sender_test.exs new file mode 100644 index 0000000..9c37e33 --- /dev/null +++ b/test/posthog/sender_test.exs @@ -0,0 +1,112 @@ +defmodule PostHog.SenderTest do + use ExUnit.Case, async: true + + import Mox + + alias PostHog.Sender + alias PostHog.API + + @supervisor_name __MODULE__ + + setup_all do + start_link_supervised!( + {Registry, keys: :unique, name: PostHog.Registry.registry_name(@supervisor_name)} + ) + + %{api_client: %API.Client{client: :fake_client, module: API.Mock}} + end + + setup :verify_on_exit! + + test "puts events into state", %{api_client: api_client} do + pid = + start_link_supervised!( + {Sender, + supervisor_name: @supervisor_name, + api_client: api_client, + max_batch_time_ms: 60_000, + max_batch_events: 100} + ) + + Sender.send("my_event", @supervisor_name) + + assert %{events: ["my_event"]} = :sys.get_state(pid) + end + + test "immediately sends after reaching max_batch_events", %{api_client: api_client} do + pid = + start_link_supervised!( + {Sender, + supervisor_name: @supervisor_name, + api_client: api_client, + max_batch_time_ms: 60_000, + max_batch_events: 2} + ) + + expect(API.Mock, :request, fn _client, method, url, opts -> + assert method == :post + assert url == "/batch" + + assert opts[:json] == %{ + batch: ["bar", "foo"] + } + end) + + Sender.send("foo", @supervisor_name) + Sender.send("bar", @supervisor_name) + + assert %{events: []} = :sys.get_state(pid) + end + + test "immediately sends after reaching max_batch_time_ms", %{ + api_client: api_client, + test_pid: test_pid + } do + start_link_supervised!( + {Sender, + supervisor_name: @supervisor_name, + api_client: api_client, + max_batch_time_ms: 0, + max_batch_events: 100} + ) + + expect(API.Mock, :request, fn _client, method, url, opts -> + assert method == :post + assert url == "/batch" + + assert opts[:json] == %{ + batch: ["foo"] + } + + send(test_pid, :ready) + end) + + Sender.send("foo", @supervisor_name) + + assert_receive :ready + end + + test "sends leftovers on shutdown", %{api_client: api_client} do + pid = + start_supervised!( + {Sender, + supervisor_name: @supervisor_name, + api_client: api_client, + max_batch_time_ms: 60_000, + max_batch_events: 100} + ) + + expect(API.Mock, :request, fn _client, method, url, opts -> + assert method == :post + assert url == "/batch" + + assert opts[:json] == %{ + batch: ["foo"] + } + end) + + Sender.send("foo", @supervisor_name) + + assert :ok = GenServer.stop(pid) + end +end diff --git a/test/posthog_test.exs b/test/posthog_test.exs index 230e049..b181bd2 100644 --- a/test/posthog_test.exs +++ b/test/posthog_test.exs @@ -1,116 +1,264 @@ -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 +defmodule PostHogTest do + use PostHog.Case, async: true + + @moduletag config: [supervisor_name: PostHog] + + import Mox + + alias PostHog.API + + setup :setup_supervisor + setup :verify_on_exit! + + describe "config/0" do + test "fetches from PostHog by default" do + assert %{supervisor_name: PostHog} = PostHog.config() + end + + @tag config: [supervisor_name: CustomPostHog] + test "uses custom supervisor name" do + assert %{supervisor_name: CustomPostHog} = PostHog.config(CustomPostHog) + end end - describe "feature_flag_enabled?/3" do - test "true if the feature flag is enabled" do - stub_with(:hackney, HackneyStub) + describe "bare_capture/4" do + test "simple call", %{sender_pid: sender_pid} do + PostHog.bare_capture("case tested", "distinct_id") - HackneyStub.verify_capture(fn decoded -> - assert decoded["event"] == "$feature_flag_called" - assert decoded["distinct_id"] == "user_123" - assert decoded["properties"]["$feature_flag"] == "my-awesome-flag" - assert decoded["properties"]["$feature_flag_response"] == true - end) + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{}, + timestamp: _ + } = event + end + + test "with properties", %{sender_pid: sender_pid} do + PostHog.bare_capture("case tested", "distinct_id", %{foo: "bar"}) + + assert %{events: [event]} = :sys.get_state(sender_pid) - assert Posthog.feature_flag_enabled?("my-awesome-flag", "user_123") + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{foo: "bar"}, + timestamp: _ + } = event end - test "false if the feature flag is disabled" do - stub_with(:hackney, HackneyStub) + @tag config: [supervisor_name: CustomPostHog] + test "simple call for custom supervisor", %{sender_pid: sender_pid} do + PostHog.bare_capture(CustomPostHog, "case tested", "distinct_id") - refute Posthog.feature_flag_enabled?("flag-thats-not-on", "user_123") + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{}, + timestamp: _ + } = event end - test "false if the feature flag does not exist" do - stub_with(:hackney, HackneyStub) + @tag config: [supervisor_name: CustomPostHog] + test "with properties for custom supervisor", %{sender_pid: sender_pid} do + PostHog.bare_capture(CustomPostHog, "case tested", "distinct_id", %{foo: "bar"}) + + assert %{events: [event]} = :sys.get_state(sender_pid) - refute Posthog.feature_flag_enabled?("flag-does-not-exist", "user_123") + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{foo: "bar"}, + timestamp: _ + } = event + end + + test "ignores set context but uses global one from the config", %{sender_pid: sender_pid} do + PostHog.set_context(%{hello: "world"}) + PostHog.bare_capture("case tested", "distinct_id", %{foo: "bar"}) + + assert %{events: [%{properties: properties}]} = :sys.get_state(sender_pid) + + assert %{foo: "bar", "$lib": "posthog-elixir", "$lib_version": _} = properties + refute properties[:hello] end end - describe "v3 - feature_flag/3" do - test "when feature flag exists, returns feature flag struct" do - stub_with(:hackney, HackneyStubV3) + describe "capture/4" do + test "simple call", %{sender_pid: sender_pid} do + PostHog.capture("case tested", %{distinct_id: "distinct_id"}) + + assert %{events: [event]} = :sys.get_state(sender_pid) - assert Posthog.feature_flag("my-awesome-flag", "user_123") == - {:ok, - %Posthog.FeatureFlag{ - enabled: true, - name: "my-awesome-flag", - payload: "example-payload-string" - }} + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{}, + timestamp: _ + } = event end - test "when feature flag has a json payload, will return decoded payload" do - stub_with(:hackney, HackneyStubV3) + test "distinct_id is required" do + assert {:error, :missing_distinct_id} = PostHog.capture("case tested") + end + + test "with properties", %{sender_pid: sender_pid} do + PostHog.capture("case tested", %{distinct_id: "distinct_id", foo: "bar"}) + + assert %{events: [event]} = :sys.get_state(sender_pid) - 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"} - }} + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{foo: "bar"}, + timestamp: _ + } = event end - test "when feature flag has an array payload, will return decoded payload" do - stub_with(:hackney, HackneyStubV3) + @tag config: [supervisor_name: CustomPostHog] + test "simple call for custom supervisor", %{sender_pid: sender_pid} do + PostHog.capture(CustomPostHog, "case tested", %{distinct_id: "distinct_id"}) - assert Posthog.feature_flag("array-payload", "user_123") == - {:ok, - %Posthog.FeatureFlag{ - enabled: true, - name: "array-payload", - payload: [0, 1, 2] - }} + assert %{events: [event]} = :sys.get_state(sender_pid) + + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{}, + timestamp: _ + } = event end - test "when feature flag does not have a payload, will return flag value" do - stub_with(:hackney, HackneyStubV3) + @tag config: [supervisor_name: CustomPostHog] + test "with properties for custom supervisor", %{sender_pid: sender_pid} do + PostHog.capture(CustomPostHog, "case tested", %{distinct_id: "distinct_id", foo: "bar"}) + + assert %{events: [event]} = :sys.get_state(sender_pid) - assert Posthog.feature_flag("flag-thats-not-on", "user_123") == - {:ok, - %Posthog.FeatureFlag{ - enabled: false, - name: "flag-thats-not-on", - payload: nil - }} + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{foo: "bar"}, + timestamp: _ + } = event end - test "when feature flag does not exist, returns not_found" do - stub_with(:hackney, HackneyStubV3) + test "includes relevant event context", %{sender_pid: sender_pid} do + PostHog.set_context(%{hello: "world", distinct_id: "distinct_id"}) + PostHog.set_event_context("case tested", %{foo: "bar"}) + PostHog.set_context(MyPostHog, %{spam: "eggs"}) + PostHog.capture("case tested", %{final: "override"}) + + assert %{events: [event]} = :sys.get_state(sender_pid) - assert Posthog.feature_flag("does-not-exist", "user_123") == - {:error, :not_found} + assert %{ + event: "case tested", + distinct_id: "distinct_id", + properties: %{ + hello: "world", + foo: "bar", + final: "override" + }, + timestamp: _ + } = event end end - describe "v3 - feature_flag_enabled?/3" do - test "true if the feature flag is enabled" do - stub_with(:hackney, HackneyStubV3) + describe "get_feature_flag/2" do + test "returns body on success" do + expect(API.Mock, :request, fn _client, method, url, opts -> + assert method == :post + assert url == "/flags" + assert opts[:params] == %{v: 2} - assert Posthog.feature_flag_enabled?("my-awesome-flag", "user_123") + assert opts[:json] == %{ + distinct_id: "foo" + } + + {:ok, %{status: 200, body: "body"}} + end) + + assert {:ok, %{status: 200, body: "body"}} = PostHog.get_feature_flag("foo") end - test "false if the feature flag is disabled" do - stub_with(:hackney, HackneyStubV3) + test "sophisticated body" do + expect(API.Mock, :request, fn client, method, url, opts -> + assert opts[:json] == %{ + distinct_id: "foo", + groups: %{group_type: "group_id"} + } + + API.Stub.request(client, method, url, opts) + end) + + assert {:ok, %{}} = + PostHog.get_feature_flag(%{distinct_id: "foo", groups: %{group_type: "group_id"}}) + end + + test "errors passed as is" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:error, :transport_error} + end) + + assert {:error, :transport_error} = PostHog.get_feature_flag("foo") + end + + test "non-200 is wrapped in error" do + expect(API.Mock, :request, fn _client, _method, _url, _opts -> + {:ok, %{status: 503}} + end) - refute Posthog.feature_flag_enabled?("flag-thats-not-on", "user_123") + assert {:ok, %{status: 503}} = PostHog.get_feature_flag("foo") end - test "false if the feature flag does not exist" do - stub_with(:hackney, HackneyStubV3) + @tag config: [supervisor_name: MyPostHog] + test "custom PostHog instance" do + expect(API.Mock, :request, fn client, method, url, opts -> + assert opts[:json] == %{distinct_id: "foo"} + + API.Stub.request(client, method, url, opts) + end) + + assert {:ok, %{}} = PostHog.get_feature_flag(MyPostHog, "foo") + end + end + + describe "set_context/2 + get_context/2" do + test "default scope" do + PostHog.set_context(%{foo: "bar"}) + assert PostHog.get_context() == %{foo: "bar"} + assert PostHog.get_context(PostHog) == %{foo: "bar"} + assert PostHog.get_event_context("$exception") == %{foo: "bar"} + assert PostHog.get_event_context(PostHog, "$exception") == %{foo: "bar"} + end + + test "named scope, all events" do + PostHog.set_context(MyPostHog, %{foo: "bar"}) + assert PostHog.get_context() == %{} + assert PostHog.get_event_context("$exception") == %{} + assert PostHog.get_context(MyPostHog) == %{foo: "bar"} + assert PostHog.get_event_context(MyPostHog, "$exception") == %{foo: "bar"} + end + end + + describe "set_event_context/2 + get_event_context/2" do + test "default scope" do + PostHog.set_event_context("$exception", %{foo: "bar"}) + assert PostHog.get_context() == %{} + assert PostHog.get_event_context("$exception") == %{foo: "bar"} + assert PostHog.get_context(PostHog) == %{} + assert PostHog.get_event_context(PostHog, "$exception") == %{foo: "bar"} + end - refute Posthog.feature_flag_enabled?("flag-does-not-exist", "user_123") + test "named scope" do + PostHog.set_event_context(MyPostHog, "$exception", %{foo: "bar"}) + assert PostHog.get_context() == %{} + assert PostHog.get_event_context("$exception") == %{} + assert PostHog.get_context(MyPostHog) == %{} + assert PostHog.get_event_context(MyPostHog, "$exception") == %{foo: "bar"} end end end diff --git a/test/support/api/mocks.ex b/test/support/api/mocks.ex new file mode 100644 index 0000000..2f06d14 --- /dev/null +++ b/test/support/api/mocks.ex @@ -0,0 +1 @@ +Mox.defmock(PostHog.API.Mock, for: PostHog.API.Client) diff --git a/test/support/api/stub.ex b/test/support/api/stub.ex new file mode 100644 index 0000000..b537f09 --- /dev/null +++ b/test/support/api/stub.ex @@ -0,0 +1,42 @@ +defmodule PostHog.API.Stub do + @behaviour PostHog.API.Client + + @impl PostHog.API.Client + def client(_api_key, _public_url) do + %PostHog.API.Client{client: :stub_client, module: PostHog.API.Mock} + end + + @impl PostHog.API.Client + def request(_client, :post, "/batch", _opts) do + {:ok, %{status: 200, body: %{"status" => "Ok"}}} + end + + def request(_client, :post, "/flags", _opts) do + {:ok, + %{ + status: 200, + body: %{ + "errorsWhileComputingFlags" => false, + "flags" => %{ + "example-feature-flag-1" => %{ + "enabled" => true, + "key" => "example-feature-flag-1", + "metadata" => %{ + "description" => nil, + "id" => 154_429, + "payload" => nil, + "version" => 4 + }, + "reason" => %{ + "code" => "condition_match", + "condition_index" => 0, + "description" => "Matched condition set 1" + }, + "variant" => nil + } + }, + "requestId" => "0d23f243-399a-4904-b1a8-ec2037834b72" + } + }} + end +end diff --git a/test/support/case.ex b/test/support/case.ex new file mode 100644 index 0000000..d304298 --- /dev/null +++ b/test/support/case.ex @@ -0,0 +1,50 @@ +defmodule PostHog.Case do + use ExUnit.CaseTemplate + + using do + quote do + import PostHog.Case + end + end + + def setup_supervisor(context) do + Mox.stub_with(PostHog.API.Mock, PostHog.API.Stub) + + config = + [ + public_url: "https://us.i.posthog.com", + api_key: "my_api_key", + api_client_module: PostHog.API.Mock, + supervisor_name: context.test, + capture_level: :info + ] + |> Keyword.merge(context[:config] || []) + |> PostHog.Config.validate!() + |> Map.put(:max_batch_time_ms, to_timeout(60_000)) + |> Map.put(:max_batch_events, 100) + + start_link_supervised!({PostHog.Supervisor, config}) + + sender_pid = + config.supervisor_name |> PostHog.Registry.via(PostHog.Sender) |> GenServer.whereis() + + context + |> Map.put(:config, config) + |> Map.put(:sender_pid, sender_pid) + end + + def setup_logger_handler(%{config: config} = context) do + big_config_override = Map.take(context, [:handle_otp_reports, :handle_sasl_reports, :level]) + + {context, on_exit} = + LoggerHandlerKit.Arrange.add_handler( + context.test, + PostHog.Handler, + config, + big_config_override + ) + + on_exit(on_exit) + context + end +end diff --git a/test/support/fixtures/decide-v3.json b/test/support/fixtures/decide-v3.json deleted file mode 100644 index 071b0e4..0000000 --- a/test/support/fixtures/decide-v3.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index eb674ad..0000000 --- a/test/support/fixtures/decide.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "config": { - "enable_collect_everything": true - }, - "toolbarParams": {}, - "errorsWhileComputingFlags": false, - "isAuthenticated": false, - "supportedCompression": [ - "gzip", - "lz64" - ], - "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": null, - "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 - } - } - }, - "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 deleted file mode 100644 index d5b7cc4..0000000 --- a/test/support/hackney_stub.ex +++ /dev/null @@ -1,95 +0,0 @@ -defmodule HackneyStub.State do - @moduledoc """ - A GenServer module that manages the state for HackneyStub, allowing verification of HTTP requests. - """ - use GenServer - - def start_link(_opts) do - name = {:via, Registry, {:hackney_stub_registry, self()}} - GenServer.start_link(__MODULE__, %{verification: nil}, name: name) - end - - def init(state) do - {:ok, state} - end - - def set_verification(verification) do - name = {:via, Registry, {:hackney_stub_registry, self()}} - GenServer.cast(name, {:set_verification, verification}) - end - - 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 - @moduledoc """ - A base module for creating Hackney stubs with predefined responses from fixture files. - """ - @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 - @moduledoc """ - A stub implementation of Hackney that returns predefined responses from fixture files. - """ - use HackneyStub.Base, "decide.json" -end - -defmodule HackneyStubV3 do - @moduledoc """ - A stub implementation of Hackney that returns predefined responses from fixture files for v3 API. - """ - use HackneyStub.Base, "decide-v3.json" -end diff --git a/test/test_helper.exs b/test/test_helper.exs index 8aec270..f8d0f1c 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,4 @@ -Mimic.copy(:hackney) - -{:ok, _} = Registry.start_link(keys: :unique, name: :hackney_stub_registry) -{:ok, _} = HackneyStub.State.start_link([]) +# Exclude the unmocked tests by default +ExUnit.configure(exclude: :integration, assert_receive_timeout: 1000) ExUnit.start()