From 390ff72b439f746bb2e0cc885e2225def0b02a35 Mon Sep 17 00:00:00 2001 From: Matthew Lehner Date: Wed, 28 Oct 2026 17:43:51 -0700 Subject: [PATCH 1/4] fix(flags): stop snapshot access Agent when the caller exits normally Each Evaluations snapshot starts an Agent linked to the calling process to track accessed keys. A linked process that isn't trapping exits ignores a :normal exit signal, so whenever the caller exited normally (a finished request, Task or job) the Agent was orphaned and lived for the life of the node. Trap exits in the Agent so it stops when its parent exits for any reason. Document that a snapshot is only usable while the process that created it is alive. --- lib/posthog/feature_flags/evaluations.ex | 13 +++++++++-- .../feature_flags/evaluations_test.exs | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/posthog/feature_flags/evaluations.ex b/lib/posthog/feature_flags/evaluations.ex index 5510e9f..e14b521 100644 --- a/lib/posthog/feature_flags/evaluations.ex +++ b/lib/posthog/feature_flags/evaluations.ex @@ -9,7 +9,9 @@ defmodule PostHog.FeatureFlags.Evaluations do Each snapshot owns a small `Agent` linked to the calling process that tracks which flags were accessed via `enabled?/2` and `get_flag/2`. The Agent exits - with the calling process — no manual cleanup is required. + with the calling process, whatever the exit reason — no manual cleanup is + required. A snapshot is therefore only usable while the process that created + it is alive; don't hand it to a process that outlives its creator. ## Querying @@ -274,7 +276,14 @@ defmodule PostHog.FeatureFlags.Evaluations do end defp start_accessed_agent do - {:ok, pid} = Agent.start_link(fn -> MapSet.new() end) + # Trap exits so the Agent also stops when the caller exits with :normal. + # Without it, a :normal exit signal is ignored and the Agent is orphaned. + {:ok, pid} = + Agent.start_link(fn -> + Process.flag(:trap_exit, true) + MapSet.new() + end) + pid end diff --git a/test/posthog/feature_flags/evaluations_test.exs b/test/posthog/feature_flags/evaluations_test.exs index 05473be..20dba36 100644 --- a/test/posthog/feature_flags/evaluations_test.exs +++ b/test/posthog/feature_flags/evaluations_test.exs @@ -52,6 +52,29 @@ defmodule PostHog.FeatureFlags.EvaluationsTest do } end + describe "access tracker lifetime" do + for reason <- [:normal, :shutdown, {:shutdown, :closed}] do + @reason reason + test "stops when the creating process exits with #{inspect(reason)}" do + test_pid = self() + + {creator, creator_ref} = + spawn_monitor(fn -> + send(test_pid, {:snapshot, Evaluations.empty(PostHog)}) + receive do: (:exit -> exit(@reason)) + end) + + assert_receive {:snapshot, %Evaluations{accessed_pid: agent}} + agent_ref = Process.monitor(agent) + + send(creator, :exit) + + assert_receive {:DOWN, ^creator_ref, :process, ^creator, _} + assert_receive {:DOWN, ^agent_ref, :process, ^agent, _} + end + end + end + describe "evaluate_flags/2" do test "returns a snapshot containing every evaluated flag" do expect(API.Mock, :request, fn _client, :post, "/flags", _opts -> From 730047647574988587f8a65a73f754193b0383e5 Mon Sep 17 00:00:00 2001 From: Matthew Lehner Date: Thu, 29 Oct 2026 11:15:11 -0700 Subject: [PATCH 2/4] chore: add changeset for evaluations Agent leak fix --- .sampo/changesets/evaluations-agent-normal-exit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .sampo/changesets/evaluations-agent-normal-exit.md diff --git a/.sampo/changesets/evaluations-agent-normal-exit.md b/.sampo/changesets/evaluations-agent-normal-exit.md new file mode 100644 index 0000000..b5e2051 --- /dev/null +++ b/.sampo/changesets/evaluations-agent-normal-exit.md @@ -0,0 +1,5 @@ +--- +hex/posthog: patch +--- + +Stop the feature flag evaluations access-tracking Agent when the process that created the snapshot exits normally, instead of leaving it running for the life of the node. From bfd47dab9a61e7cec83e54edf4f63bba4daeef9b Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Thu, 29 Oct 2026 16:58:29 -0400 Subject: [PATCH 3/4] fix(flags): skip exception capture for snapshot trackers --- lib/posthog/feature_flags/evaluations.ex | 1 + test/posthog/handler_test.exs | 44 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/lib/posthog/feature_flags/evaluations.ex b/lib/posthog/feature_flags/evaluations.ex index e14b521..a546377 100644 --- a/lib/posthog/feature_flags/evaluations.ex +++ b/lib/posthog/feature_flags/evaluations.ex @@ -280,6 +280,7 @@ defmodule PostHog.FeatureFlags.Evaluations do # Without it, a :normal exit signal is ignored and the Agent is orphaned. {:ok, pid} = Agent.start_link(fn -> + Logger.metadata(posthog_skip_capture: true) Process.flag(:trap_exit, true) MapSet.new() end) diff --git a/test/posthog/handler_test.exs b/test/posthog/handler_test.exs index cb81311..38842d6 100644 --- a/test/posthog/handler_test.exs +++ b/test/posthog/handler_test.exs @@ -307,6 +307,50 @@ defmodule PostHog.HandlerTest do assert [] = all_captured(supervisor_name) end + test "snapshot tracker exits do not duplicate their creator's exception", %{ + handler_id: handler_id, + handler_ref: ref, + config: %{supervisor_name: supervisor_name} + } do + test_pid = self() + + {creator, creator_ref} = + spawn_monitor(fn -> + {:ok, snapshot} = + PostHog.FeatureFlags.evaluate_flags(supervisor_name, %{ + distinct_id: "snapshot-user", + flag_keys: [] + }) + + filtered = PostHog.FeatureFlags.Evaluations.only(snapshot, []) + send(test_pid, {:trackers, [snapshot.accessed_pid, filtered.accessed_pid]}) + receive do: (:raise -> raise "snapshot creator failed") + end) + + assert_receive {:trackers, trackers} + + for pid <- [creator | trackers] do + LoggerHandlerKit.Arrange.allow(self(), pid, handler_id) + PostHog.Test.allow(supervisor_name, self(), pid) + end + + tracker_refs = for pid <- trackers, do: {pid, Process.monitor(pid)} + send(creator, :raise) + + assert_receive {:DOWN, ^creator_ref, :process, ^creator, _} + + for {pid, tracker_ref} <- tracker_refs do + assert_receive {:DOWN, ^tracker_ref, :process, ^pid, _} + end + + for _ <- 1..3, do: LoggerHandlerKit.Assert.assert_logged(ref) + + assert [%{event: "$exception", properties: properties}] = all_captured(supervisor_name) + + assert [%{type: "RuntimeError", value: "snapshot creator failed"} | _] = + properties[:"$exception_list"] + end + @tag config: [capture_level: :warning] test "logs with crash reason always captured", %{ handler_ref: ref, From 2977b4e3492229d2c52f35d7bdccc25829cd7e67 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Thu, 29 Oct 2026 17:24:35 -0400 Subject: [PATCH 4/4] ci: pin test matrix to Ubuntu 24.04 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 713d29d..12df622 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ concurrency: jobs: test: name: Build and test - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # This is a complicated set of strategies, but Elixir has a complicated set of supported versions. # See https://hexdocs.pm/elixir/compatibility-and-deprecations.html