From cbb1e1a2d1b9d8657a3122651b82db8c6b5a6f60 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 17 Jun 2026 16:36:00 +0200 Subject: [PATCH 01/23] Extract replay data to request struct --- lib/plausible/ingestion/request.ex | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/lib/plausible/ingestion/request.ex b/lib/plausible/ingestion/request.ex index e8b15a97f152..853ad899e3bf 100644 --- a/lib/plausible/ingestion/request.ex +++ b/lib/plausible/ingestion/request.ex @@ -59,6 +59,10 @@ defmodule Plausible.Ingestion.Request do on_ee do field :revenue_source, :map + + # fields meant for replayed events only + field :replay_id, :string + field :replay_session_id, :string end field :query_params, :map @@ -91,6 +95,7 @@ defmodule Plausible.Ingestion.Request do |> put_uri(request_body) |> put_hostname() |> put_user_agent(conn) + |> put_replay_data(conn) |> put_request_params(request_body) |> put_referrer(request_body) |> put_pathname() @@ -131,6 +136,41 @@ defmodule Plausible.Ingestion.Request do defp put_revenue_source(changeset, _request_body), do: changeset end + on_ee do + @replay_id_header "x-replay-event-id" + @replay_session_id_header "x-replay-session-id" + @replay_time_header "x-replay-time" + + defp put_replay_data(changeset, conn) do + id = + conn + |> Plug.Conn.get_req_header(@replay_id_header) + |> List.first() + + if id do + session_id = + conn + |> Plug.Conn.get_req_header(@replay_session_id_header) + |> List.first() + + time = + conn + |> Plug.Conn.get_req_header(@replay_time_header) + |> List.first() + |> NaiveDateTime.from_iso8601!() + + changeset + |> Changeset.put_change(:replay_id, id) + |> Changeset.put_change(:replay_session_id, session_id) + |> Changeset.put_change(:timestamp, time) + else + changeset + end + end + else + defp put_replay_data(changeset, _conn), do: changeset + end + defp put_remote_ip(changeset, conn) do Changeset.put_change(changeset, :remote_ip, PlausibleWeb.RemoteIP.get(conn)) end From cb498f9bd5674ef77f673a890aff2ab2fa535aa7 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 17 Jun 2026 19:43:10 +0200 Subject: [PATCH 02/23] Add virtual `replay_session_id` field to CH event and session structs --- lib/plausible/clickhouse_event_v2.ex | 6 +++++- lib/plausible/clickhouse_session_v2.ex | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/plausible/clickhouse_event_v2.ex b/lib/plausible/clickhouse_event_v2.ex index 7e2d8861a22f..79b2b2571176 100644 --- a/lib/plausible/clickhouse_event_v2.ex +++ b/lib/plausible/clickhouse_event_v2.ex @@ -50,6 +50,9 @@ defmodule Plausible.ClickhouseEventV2 do # Virtual field used during event processing field :interactive?, :boolean, default: true, virtual: true, writable: :never + + # Virtual field used during event replay + field :replay_session_id, :string, default: nil, virtual: true, writable: :never end def new(attrs) do @@ -71,7 +74,8 @@ defmodule Plausible.ClickhouseEventV2 do :revenue_source_currency, :revenue_reporting_amount, :revenue_reporting_currency, - :interactive? + :interactive?, + :replay_session_id ] ) |> validate_required([:name, :site_id, :hostname, :pathname, :user_id, :timestamp]) diff --git a/lib/plausible/clickhouse_session_v2.ex b/lib/plausible/clickhouse_session_v2.ex index e9614df5b9bb..e7fc405b96b2 100644 --- a/lib/plausible/clickhouse_session_v2.ex +++ b/lib/plausible/clickhouse_session_v2.ex @@ -75,6 +75,9 @@ defmodule Plausible.ClickhouseSessionV2 do field :transferred_from, :string field :acquisition_channel, Ch, type: "LowCardinality(String)", writable: :never + + # Virtual field used during event replay + field :replay_session_id, :string, default: nil, virtual: true, writable: :never end def random_uint64() do From 982360c957772b437c92c4b87882bbf83031a303 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 17 Jun 2026 19:44:22 +0200 Subject: [PATCH 03/23] Merge replay_session_id from request into event attrs --- lib/plausible/ingestion/event.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex index ee4d99e1f8c8..7a1a7ec1b24b 100644 --- a/lib/plausible/ingestion/event.ex +++ b/lib/plausible/ingestion/event.ex @@ -285,7 +285,8 @@ defmodule Plausible.Ingestion.Event do pathname: event.request.pathname, scroll_depth: event.request.scroll_depth, engagement_time: event.request.engagement_time, - interactive?: event.request.interactive? + interactive?: event.request.interactive?, + replay_session_id: event.request.replay_session_id }) end From ba4b39b2acc3cc847557f70b1670dae90d80360f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 17 Jun 2026 16:36:48 +0200 Subject: [PATCH 04/23] Put session-dependent salt for replayed event --- lib/plausible/ingestion/event.ex | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex index 7a1a7ec1b24b..5493683ed0c1 100644 --- a/lib/plausible/ingestion/event.ex +++ b/lib/plausible/ingestion/event.ex @@ -381,8 +381,21 @@ defmodule Plausible.Ingestion.Event do end end - defp put_salts(%__MODULE__{} = event, _context) do - %{event | salts: Plausible.Session.Salts.fetch()} + on_ee do + defp put_salts(%__MODULE__{} = event, _context) do + if session_id = event.request.replay_session_id do + computed_salt = :crypto.hash(:sha, [secret_key_base(), session_id]) |> binary_part(0, 16) + + salts = %{previous: nil, current: computed_salt} + %{event | salts: salts} + else + %{event | salts: Plausible.Session.Salts.fetch()} + end + end + else + defp put_salts(%__MODULE__{} = event, _context) do + %{event | salts: Plausible.Session.Salts.fetch()} + end end defp put_user_id(%__MODULE__{} = event, _context) do @@ -586,4 +599,11 @@ defmodule Plausible.Ingestion.Event do end defp spam_referrer?(_), do: false + + on_ee do + defp secret_key_base() do + Application.get_env(:plausible, PlausibleWeb.Endpoint) + |> Keyword.fetch!(:secret_key_base) + end + end end From e57134845024dba7bc338cb2e6843930cac8fe3b Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 17 Jun 2026 19:45:09 +0200 Subject: [PATCH 05/23] Add a separate cache for replayed sessions --- config/runtime.exs | 4 +++- lib/plausible/application.ex | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/config/runtime.exs b/config/runtime.exs index eafdd42b5670..8b8ba685a3c1 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1096,4 +1096,6 @@ unless s3_disabled? do imports_bucket: s3_env_value.("S3_IMPORTS_BUCKET") end -config :plausible, Plausible.Cache.Adapter, sessions: [partitions: 100] +config :plausible, Plausible.Cache.Adapter, + sessions: [partitions: 100], + replay_sessions: [partitions: 20] diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 20191fc70189..885e5becb5dc 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -58,6 +58,14 @@ defmodule Plausible.Application do n_lock_partitions: 1, ets_options: [read_concurrency: true, write_concurrency: true] ), + on_ee do + Plausible.Cache.Adapter.child_specs(:replay_sessions, :cache_replay_sessions, + ttl_check_interval: :timer.seconds(10), + global_ttl: :timer.minutes(30), + n_lock_partitions: 1, + ets_options: [read_concurrency: true, write_concurrency: true] + ) + end, {Plausible.Session.Transfer, base_path: Application.get_env(:plausible, :session_transfer_dir)}, warmed_cache(Plausible.Site.Cache, From de9f58a0425a6fa8d8f22243287ed9cd93fe1a32 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 17 Jun 2026 19:46:00 +0200 Subject: [PATCH 06/23] Use different cache and key for replayed events in `CacheStore` --- lib/plausible/session/cache_store.ex | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/plausible/session/cache_store.ex b/lib/plausible/session/cache_store.ex index 05b3998e06ce..f1bb38b60259 100644 --- a/lib/plausible/session/cache_store.ex +++ b/lib/plausible/session/cache_store.ex @@ -70,7 +70,14 @@ defmodule Plausible.Session.CacheStore do defp find_session(_domain, nil), do: nil defp find_session(event, user_id) do - from_cache = Plausible.Cache.Adapter.get(:sessions, {event.site_id, user_id}) + {cache_name, key} = + if event.replay_session_id do + {:replay_sessions, {event.site_id, user_id, event.replay_session_id}} + else + {:sessions, {event.site_id, user_id}} + end + + from_cache = Plausible.Cache.Adapter.get(cache_name, key) case from_cache do nil -> @@ -84,8 +91,14 @@ defmodule Plausible.Session.CacheStore do end defp update_session_cache(session) do - key = {session.site_id, session.user_id} - Plausible.Cache.Adapter.put(:sessions, key, session, dirty?: true) + {cache_name, key} = + if session.replay_session_id do + {:replay_sessions, {session.site_id, session.user_id, session.replay_session_id}} + else + {:sessions, {session.site_id, session.user_id}} + end + + Plausible.Cache.Adapter.put(cache_name, key, session, dirty?: true) session end @@ -158,6 +171,7 @@ defmodule Plausible.Session.CacheStore do browser_version: Map.get(session_attributes, :browser_version), timestamp: event.timestamp, start: event.timestamp, + replay_session_id: event.replay_session_id, "entry_meta.key": Map.get(event, :"meta.key"), "entry_meta.value": Map.get(event, :"meta.value") } From 31f2cf285f68fe568da2f42adc63a98087f29c2c Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 25 Jun 2026 10:30:13 +0200 Subject: [PATCH 07/23] Add migration renaming `recovery_id` CH columns to `replay_session_id` --- ...ename_recovery_id_to_replay_session_id.exs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 priv/ingest_repo/migrations/20260625082529_rename_recovery_id_to_replay_session_id.exs diff --git a/priv/ingest_repo/migrations/20260625082529_rename_recovery_id_to_replay_session_id.exs b/priv/ingest_repo/migrations/20260625082529_rename_recovery_id_to_replay_session_id.exs new file mode 100644 index 000000000000..fecf8375cd0e --- /dev/null +++ b/priv/ingest_repo/migrations/20260625082529_rename_recovery_id_to_replay_session_id.exs @@ -0,0 +1,40 @@ +defmodule Plausible.IngestRepo.Migrations.RenameRecoveryIdToReplaySessionId do + use Ecto.Migration + + import Plausible.MigrationUtils + + @on_cluster_sessions on_cluster_statement("sessions_v2") + @on_cluster_events on_cluster_statement("events_v2") + + def up do + if enterprise_edition?() do + execute """ + ALTER TABLE sessions_v2 + #{@on_cluster_sessions} + RENAME COLUMN IF EXISTS recovery_id TO replay_session_id + """ + + execute """ + ALTER TABLE events_v2 + #{@on_cluster_events} + RENAME COLUMN IF EXISTS recovery_id TO replay_session_id + """ + end + end + + def down do + if enterprise_edition?() do + execute """ + ALTER TABLE sessions_v2 + #{@on_cluster_sessions} + RENAME COLUMN IF EXISTS replay_session_id TO recovery_id + """ + + execute """ + ALTER TABLE events_v2 + #{@on_cluster_events} + RENAME COLUMN IF EXISTS replay_session_id TO recovery_id + """ + end + end +end From 7dcda4707bda64f422b5893f94da0b30623ccaeb Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 25 Jun 2026 10:54:54 +0200 Subject: [PATCH 08/23] Deserialize and persist replay session ID as integer --- lib/plausible/clickhouse_event_v2.ex | 6 +++--- lib/plausible/clickhouse_session_v2.ex | 4 ++-- lib/plausible/ingestion/event.ex | 4 +++- lib/plausible/ingestion/request.ex | 18 ++++++++++++++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/plausible/clickhouse_event_v2.ex b/lib/plausible/clickhouse_event_v2.ex index 79b2b2571176..3624789c7c39 100644 --- a/lib/plausible/clickhouse_event_v2.ex +++ b/lib/plausible/clickhouse_event_v2.ex @@ -48,11 +48,11 @@ defmodule Plausible.ClickhouseEventV2 do field :acquisition_channel, Ch, type: "LowCardinality(String)", writable: :never + # Field used during event replay + field :replay_session_id, Ch, type: "UInt64" + # Virtual field used during event processing field :interactive?, :boolean, default: true, virtual: true, writable: :never - - # Virtual field used during event replay - field :replay_session_id, :string, default: nil, virtual: true, writable: :never end def new(attrs) do diff --git a/lib/plausible/clickhouse_session_v2.ex b/lib/plausible/clickhouse_session_v2.ex index e7fc405b96b2..25d5fde1e1f9 100644 --- a/lib/plausible/clickhouse_session_v2.ex +++ b/lib/plausible/clickhouse_session_v2.ex @@ -76,8 +76,8 @@ defmodule Plausible.ClickhouseSessionV2 do field :acquisition_channel, Ch, type: "LowCardinality(String)", writable: :never - # Virtual field used during event replay - field :replay_session_id, :string, default: nil, virtual: true, writable: :never + # Field used during event replay + field :replay_session_id, Ch, type: "UInt64" end def random_uint64() do diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex index 5493683ed0c1..2c28a6cef3de 100644 --- a/lib/plausible/ingestion/event.ex +++ b/lib/plausible/ingestion/event.ex @@ -384,7 +384,9 @@ defmodule Plausible.Ingestion.Event do on_ee do defp put_salts(%__MODULE__{} = event, _context) do if session_id = event.request.replay_session_id do - computed_salt = :crypto.hash(:sha, [secret_key_base(), session_id]) |> binary_part(0, 16) + computed_salt = + :crypto.hash(:sha, [secret_key_base(), :binary.encode_unsigned(session_id)]) + |> binary_part(0, 16) salts = %{previous: nil, current: computed_salt} %{event | salts: salts} diff --git a/lib/plausible/ingestion/request.ex b/lib/plausible/ingestion/request.ex index 853ad899e3bf..3638a6bb7a66 100644 --- a/lib/plausible/ingestion/request.ex +++ b/lib/plausible/ingestion/request.ex @@ -61,8 +61,8 @@ defmodule Plausible.Ingestion.Request do field :revenue_source, :map # fields meant for replayed events only - field :replay_id, :string - field :replay_session_id, :string + field :replay_id, :integer + field :replay_session_id, :integer end field :query_params, :map @@ -146,12 +146,14 @@ defmodule Plausible.Ingestion.Request do conn |> Plug.Conn.get_req_header(@replay_id_header) |> List.first() + |> to_integer() if id do session_id = conn |> Plug.Conn.get_req_header(@replay_session_id_header) |> List.first() + |> to_integer() time = conn @@ -167,6 +169,18 @@ defmodule Plausible.Ingestion.Request do changeset end end + + defp to_integer(s) when is_binary(s) do + case Integer.parse(s) do + {n, ""} when n > 0 -> + n + + _ -> + nil + end + end + + defp to_integer(_), do: nil else defp put_replay_data(changeset, _conn), do: changeset end From 95bf8f52a5407c4a02a5b84e10cd6fe5c4d90ee4 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 25 Jun 2026 11:29:51 +0200 Subject: [PATCH 09/23] Add tests for Request --- test/plausible/ingestion/request_test.exs | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/plausible/ingestion/request_test.exs b/test/plausible/ingestion/request_test.exs index 7426926a2546..5fb6129f1e77 100644 --- a/test/plausible/ingestion/request_test.exs +++ b/test/plausible/ingestion/request_test.exs @@ -173,6 +173,42 @@ defmodule Plausible.Ingestion.RequestTest do assert request.tracker_script_version == 137 end + @tag :ee_only + test "parses replay headers if present" do + payload = %{ + name: "pageview", + domain: "dummy.site", + url: "http://dummy.site/index.html" + } + + conn = + build_conn(:post, "/api/events", payload) + |> put_req_header("x-replay-event-id", "123") + |> put_req_header("x-replay-session-id", "456") + |> put_req_header("x-replay-time", "2026-06-02 12:43:00") + + assert {:ok, request, _conn} = Request.build(conn) + assert request.replay_id == 123 + assert request.replay_session_id == 456 + assert NaiveDateTime.compare(request.timestamp, ~N[2026-06-02 12:43:00]) == :eq + end + + @tag :ee_only + test "leaves replay fields empty and timestamp intact if headers not present" do + payload = %{ + name: "pageview", + domain: "dummy.site", + url: "http://dummy.site/index.html" + } + + conn = build_conn(:post, "/api/events", payload) + + assert {:ok, request, _conn} = Request.build(conn) + assert is_nil(request.replay_id) + assert is_nil(request.replay_session_id) + assert %NaiveDateTime{} = request.timestamp + end + @tag :ee_only test "parses revenue source field from a json string" do payload = %{ From 44b0005a719285686067b4a7489a644cdbf6a6b3 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 25 Jun 2026 15:31:46 +0200 Subject: [PATCH 10/23] Add ingest event tests --- test/plausible/ingestion/event_test.exs | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/plausible/ingestion/event_test.exs b/test/plausible/ingestion/event_test.exs index 43a0a06e2d53..1f0ec94aff1b 100644 --- a/test/plausible/ingestion/event_test.exs +++ b/test/plausible/ingestion/event_test.exs @@ -475,6 +475,47 @@ defmodule Plausible.Ingestion.EventTest do assert Decimal.eq?(event.clickhouse_event.revenue_source_amount, Decimal.new("10.2")) end + @tag :ee_only + test "saves replay session id when passed in headers" do + site = new_site() + + payload = %{ + name: "pageview", + url: "http://#{site.domain}" + } + + conn = + build_conn(:post, "/api/events", payload) + |> Plug.Conn.put_req_header("x-replay-event-id", "123") + |> Plug.Conn.put_req_header("x-replay-session-id", "456") + |> Plug.Conn.put_req_header("x-replay-time", "2026-06-02 12:43:00") + + assert {:ok, request, _conn} = Request.build(conn) + + assert {:ok, %{buffered: [event], dropped: []}} = Event.build_and_buffer(request) + assert event.clickhouse_event.replay_session_id == 456 + assert event.clickhouse_event.timestamp == ~N[2026-06-02 12:43:00] + end + + @tag :ee_only + test "leaves replay session id empty when not passed in headers" do + site = new_site() + + payload = %{ + name: "pageview", + url: "http://#{site.domain}" + } + + conn = + build_conn(:post, "/api/events", payload) + + assert {:ok, request, _conn} = Request.build(conn) + + assert {:ok, %{buffered: [event], dropped: []}} = Event.build_and_buffer(request) + assert is_nil(event.clickhouse_event.replay_session_id) + assert %NaiveDateTime{} = event.clickhouse_event.timestamp + end + test "does not save revenue amount when there is no revenue goal" do site = new_site() From 79a549022be46fa9e20e3beea7208ae71528512c Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 25 Jun 2026 17:12:56 +0200 Subject: [PATCH 11/23] Add test for session-dependent salt generation --- test/plausible/ingestion/event_test.exs | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/test/plausible/ingestion/event_test.exs b/test/plausible/ingestion/event_test.exs index 1f0ec94aff1b..8e6f95d43b45 100644 --- a/test/plausible/ingestion/event_test.exs +++ b/test/plausible/ingestion/event_test.exs @@ -516,6 +516,67 @@ defmodule Plausible.Ingestion.EventTest do assert %NaiveDateTime{} = event.clickhouse_event.timestamp end + @tag :ee_only + test "replayed events have a distinct user id due to reply session-dependent salt" do + site = new_site() + + payload = %{ + name: "pageview", + url: "http://#{site.domain}" + } + + conn1 = + build_conn(:post, "/api/events", payload) + |> Plug.Conn.put_req_header("user-agent", "Mozilla") + |> Plug.Conn.put_req_header("x-plausible-ip", "1.2.3.4") + + assert {:ok, request1, _conn} = Request.build(conn1) + assert {:ok, %{buffered: [event1], dropped: []}} = Event.build_and_buffer(request1) + + conn2 = + build_conn(:post, "/api/events", payload) + |> Plug.Conn.put_req_header("user-agent", "Mozilla") + |> Plug.Conn.put_req_header("x-plausible-ip", "1.2.3.4") + |> Plug.Conn.put_req_header("x-replay-event-id", "123") + |> Plug.Conn.put_req_header("x-replay-session-id", "456") + |> Plug.Conn.put_req_header("x-replay-time", "2026-06-02 12:43:00") + + assert {:ok, request2, _conn} = Request.build(conn2) + assert {:ok, %{buffered: [event2], dropped: []}} = Event.build_and_buffer(request2) + + conn3 = + build_conn(:post, "/api/events", payload) + |> Plug.Conn.put_req_header("user-agent", "Mozilla") + |> Plug.Conn.put_req_header("x-plausible-ip", "1.2.3.4") + |> Plug.Conn.put_req_header("x-replay-event-id", "124") + |> Plug.Conn.put_req_header("x-replay-session-id", "456") + |> Plug.Conn.put_req_header("x-replay-time", "2026-06-02 12:44:01") + + assert {:ok, request3, _conn} = Request.build(conn3) + assert {:ok, %{buffered: [event3], dropped: []}} = Event.build_and_buffer(request3) + + conn4 = + build_conn(:post, "/api/events", payload) + |> Plug.Conn.put_req_header("user-agent", "Mozilla") + |> Plug.Conn.put_req_header("x-plausible-ip", "1.2.3.4") + |> Plug.Conn.put_req_header("x-replay-event-id", "125") + |> Plug.Conn.put_req_header("x-replay-session-id", "789") + |> Plug.Conn.put_req_header("x-replay-time", "2026-06-02 12:44:01") + + assert {:ok, request4, _conn} = Request.build(conn4) + assert {:ok, %{buffered: [event4], dropped: []}} = Event.build_and_buffer(request4) + + # non-replayed event user id different from replayed event user despite + # identical fingerprint + assert event1.clickhouse_event.user_id != event2.clickhouse_event.user_id + # replayed events with the same session for the same fingerprint have the + # same useer id + assert event2.clickhouse_event.user_id == event3.clickhouse_event.user_id + # replayed event from different sessions for the same fingerprint + # have different users + assert event3.clickhouse_event.user_id != event4.clickhouse_event.user_id + end + test "does not save revenue amount when there is no revenue goal" do site = new_site() From 33c49b8118c93c2f95f70954f1cb643308badaed Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 25 Jun 2026 17:13:13 +0200 Subject: [PATCH 12/23] Add test for cache store --- test/plausible/session/cache_store_test.exs | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/plausible/session/cache_store_test.exs b/test/plausible/session/cache_store_test.exs index 8efb8c90cb28..1dad650df61d 100644 --- a/test/plausible/session/cache_store_test.exs +++ b/test/plausible/session/cache_store_test.exs @@ -246,6 +246,50 @@ defmodule Plausible.Session.CacheStoreTest do # assert Map.get(session, :"entry.meta.value") == ["true", "false"] end + @tag :ee_only + test "creates a session from a replayed event", %{buffer: buffer} do + event = + build(:event, + name: "pageview", + replay_session_id: 456, + "meta.key": ["logged_in", "darkmode"], + "meta.value": ["true", "false"] + ) + + CacheStore.on_event(event, @session_params, nil, buffer_insert: buffer) + + assert_receive({:buffer, :insert, [sessions]}) + assert [session] = sessions + assert session.hostname == event.hostname + + assert session.site_id == event.site_id + + assert session.replay_session_id == 456 + + assert session.user_id == event.user_id + assert session.entry_page == event.pathname + assert session.exit_page == event.pathname + assert session.is_bounce == true + assert session.duration == 0 + assert session.pageviews == 1 + assert session.events == 1 + assert session.referrer == Map.get(@session_params, :referrer) + assert session.referrer_source == Map.get(@session_params, :referrer_source) + assert session.utm_medium == Map.get(@session_params, :utm_medium) + assert session.utm_source == Map.get(@session_params, :utm_source) + assert session.utm_campaign == Map.get(@session_params, :utm_campaign) + assert session.utm_content == Map.get(@session_params, :utm_content) + assert session.utm_term == Map.get(@session_params, :utm_term) + assert session.country_code == Map.get(@session_params, :country_code) + assert session.screen_size == Map.get(@session_params, :screen_size) + assert session.operating_system == Map.get(@session_params, :operating_system) + assert session.operating_system_version == Map.get(@session_params, :operating_system_version) + assert session.browser == Map.get(@session_params, :browser) + assert session.browser_version == Map.get(@session_params, :browser_version) + assert session.timestamp == event.timestamp + assert session.start === event.timestamp + end + test "updates session counters", %{buffer: buffer} do timestamp = DateTime.utc_now() From cb99689b7609be9eff6cbdc0120da89f21888984 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 25 Jun 2026 17:19:38 +0200 Subject: [PATCH 13/23] Revert "Add a separate cache for replayed sessions" This reverts commit fd2df8b3bf2e1ef04a87f6e1705371c8f6ef5574. --- config/runtime.exs | 4 +--- lib/plausible/application.ex | 8 -------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 8b8ba685a3c1..eafdd42b5670 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1096,6 +1096,4 @@ unless s3_disabled? do imports_bucket: s3_env_value.("S3_IMPORTS_BUCKET") end -config :plausible, Plausible.Cache.Adapter, - sessions: [partitions: 100], - replay_sessions: [partitions: 20] +config :plausible, Plausible.Cache.Adapter, sessions: [partitions: 100] diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 885e5becb5dc..20191fc70189 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -58,14 +58,6 @@ defmodule Plausible.Application do n_lock_partitions: 1, ets_options: [read_concurrency: true, write_concurrency: true] ), - on_ee do - Plausible.Cache.Adapter.child_specs(:replay_sessions, :cache_replay_sessions, - ttl_check_interval: :timer.seconds(10), - global_ttl: :timer.minutes(30), - n_lock_partitions: 1, - ets_options: [read_concurrency: true, write_concurrency: true] - ) - end, {Plausible.Session.Transfer, base_path: Application.get_env(:plausible, :session_transfer_dir)}, warmed_cache(Plausible.Site.Cache, From 40b91321b9e292d314d8989f8929809af2c7920a Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 25 Jun 2026 17:19:43 +0200 Subject: [PATCH 14/23] Revert "Use different cache and key for replayed events in `CacheStore`" This reverts commit f72a8120cd19a7fbda5e94732539dd50079ee984. --- lib/plausible/session/cache_store.ex | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/lib/plausible/session/cache_store.ex b/lib/plausible/session/cache_store.ex index f1bb38b60259..05b3998e06ce 100644 --- a/lib/plausible/session/cache_store.ex +++ b/lib/plausible/session/cache_store.ex @@ -70,14 +70,7 @@ defmodule Plausible.Session.CacheStore do defp find_session(_domain, nil), do: nil defp find_session(event, user_id) do - {cache_name, key} = - if event.replay_session_id do - {:replay_sessions, {event.site_id, user_id, event.replay_session_id}} - else - {:sessions, {event.site_id, user_id}} - end - - from_cache = Plausible.Cache.Adapter.get(cache_name, key) + from_cache = Plausible.Cache.Adapter.get(:sessions, {event.site_id, user_id}) case from_cache do nil -> @@ -91,14 +84,8 @@ defmodule Plausible.Session.CacheStore do end defp update_session_cache(session) do - {cache_name, key} = - if session.replay_session_id do - {:replay_sessions, {session.site_id, session.user_id, session.replay_session_id}} - else - {:sessions, {session.site_id, session.user_id}} - end - - Plausible.Cache.Adapter.put(cache_name, key, session, dirty?: true) + key = {session.site_id, session.user_id} + Plausible.Cache.Adapter.put(:sessions, key, session, dirty?: true) session end @@ -171,7 +158,6 @@ defmodule Plausible.Session.CacheStore do browser_version: Map.get(session_attributes, :browser_version), timestamp: event.timestamp, start: event.timestamp, - replay_session_id: event.replay_session_id, "entry_meta.key": Map.get(event, :"meta.key"), "entry_meta.value": Map.get(event, :"meta.value") } From 6dbefd213ab791abee24b1b2b4de93b6d79bae4e Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 7 Jul 2026 21:52:46 +0200 Subject: [PATCH 15/23] Don't replay events with timestamp in the future --- lib/plausible/ingestion/request.ex | 14 ++++++++++---- test/plausible/ingestion/request_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/plausible/ingestion/request.ex b/lib/plausible/ingestion/request.ex index 3638a6bb7a66..21e9e6f7bf1e 100644 --- a/lib/plausible/ingestion/request.ex +++ b/lib/plausible/ingestion/request.ex @@ -142,6 +142,8 @@ defmodule Plausible.Ingestion.Request do @replay_time_header "x-replay-time" defp put_replay_data(changeset, conn) do + now = NaiveDateTime.utc_now(:second) + id = conn |> Plug.Conn.get_req_header(@replay_id_header) @@ -161,10 +163,14 @@ defmodule Plausible.Ingestion.Request do |> List.first() |> NaiveDateTime.from_iso8601!() - changeset - |> Changeset.put_change(:replay_id, id) - |> Changeset.put_change(:replay_session_id, session_id) - |> Changeset.put_change(:timestamp, time) + if NaiveDateTime.compare(time, now) in [:lt, :eq] do + changeset + |> Changeset.put_change(:replay_id, id) + |> Changeset.put_change(:replay_session_id, session_id) + |> Changeset.put_change(:timestamp, time) + else + changeset + end else changeset end diff --git a/test/plausible/ingestion/request_test.exs b/test/plausible/ingestion/request_test.exs index 5fb6129f1e77..bb84c51562a3 100644 --- a/test/plausible/ingestion/request_test.exs +++ b/test/plausible/ingestion/request_test.exs @@ -209,6 +209,29 @@ defmodule Plausible.Ingestion.RequestTest do assert %NaiveDateTime{} = request.timestamp end + @tag :ee_only + test "leaves replay fields empty and timestamp intact if replay time in the future" do + payload = %{ + name: "pageview", + domain: "dummy.site", + url: "http://dummy.site/index.html" + } + + now = NaiveDateTime.utc_now(:second) + time = NaiveDateTime.add(now, 10, :second) + + conn = + build_conn(:post, "/api/events", payload) + |> put_req_header("x-replay-event-id", "123") + |> put_req_header("x-replay-session-id", "456") + |> put_req_header("x-replay-time", NaiveDateTime.to_iso8601(time)) + + assert {:ok, request, _conn} = Request.build(conn) + assert is_nil(request.replay_id) + assert is_nil(request.replay_session_id) + assert NaiveDateTime.compare(request.timestamp, time) == :lt + end + @tag :ee_only test "parses revenue source field from a json string" do payload = %{ From ba874eb59752a9b3d2640c1c4dc0a0a8e9973bcc Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 7 Jul 2026 21:56:11 +0200 Subject: [PATCH 16/23] Fix request encoding test --- test/plausible/ingestion/request_test.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/plausible/ingestion/request_test.exs b/test/plausible/ingestion/request_test.exs index bb84c51562a3..3e91a7441d93 100644 --- a/test/plausible/ingestion/request_test.exs +++ b/test/plausible/ingestion/request_test.exs @@ -662,7 +662,9 @@ defmodule Plausible.Ingestion.RequestTest do "scroll_depth" => nil, "engagement_time" => nil, "tracker_script_version" => 0, - "interactive?" => true + "interactive?" => true, + "replay_id" => nil, + "replay_session_id" => nil } assert %NaiveDateTime{} = NaiveDateTime.from_iso8601!(request["timestamp"]) From 89d69916f9269d5fef78cd31195fe70ee3fbd622 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 8 Jul 2026 10:14:07 +0200 Subject: [PATCH 17/23] Bring back logic using different cache key for replayed sessions --- lib/plausible/session/cache_store.ex | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/plausible/session/cache_store.ex b/lib/plausible/session/cache_store.ex index 05b3998e06ce..2d0101bff3ba 100644 --- a/lib/plausible/session/cache_store.ex +++ b/lib/plausible/session/cache_store.ex @@ -70,7 +70,14 @@ defmodule Plausible.Session.CacheStore do defp find_session(_domain, nil), do: nil defp find_session(event, user_id) do - from_cache = Plausible.Cache.Adapter.get(:sessions, {event.site_id, user_id}) + key = + if event.replay_session_id do + {event.site_id, user_id, event.replay_session_id} + else + {event.site_id, user_id} + end + + from_cache = Plausible.Cache.Adapter.get(:sessions, key) case from_cache do nil -> @@ -84,7 +91,13 @@ defmodule Plausible.Session.CacheStore do end defp update_session_cache(session) do - key = {session.site_id, session.user_id} + key = + if session.replay_session_id do + {session.site_id, session.user_id, session.replay_session_id} + else + {session.site_id, session.user_id} + end + Plausible.Cache.Adapter.put(:sessions, key, session, dirty?: true) session end @@ -158,6 +171,7 @@ defmodule Plausible.Session.CacheStore do browser_version: Map.get(session_attributes, :browser_version), timestamp: event.timestamp, start: event.timestamp, + replay_session_id: event.replay_session_id, "entry_meta.key": Map.get(event, :"meta.key"), "entry_meta.value": Map.get(event, :"meta.value") } From 306653087948392682c1754f8fa1f58f7cc7cb7e Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 8 Jul 2026 10:25:41 +0200 Subject: [PATCH 18/23] Add more cache store tests --- test/plausible/session/cache_store_test.exs | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/plausible/session/cache_store_test.exs b/test/plausible/session/cache_store_test.exs index 1dad650df61d..0f0c85d9fb26 100644 --- a/test/plausible/session/cache_store_test.exs +++ b/test/plausible/session/cache_store_test.exs @@ -293,6 +293,31 @@ defmodule Plausible.Session.CacheStoreTest do test "updates session counters", %{buffer: buffer} do timestamp = DateTime.utc_now() + event1 = + build(:event, + name: "pageview", + replay_session_id: 456, + timestamp: timestamp |> NaiveDateTime.shift(second: -10) + ) + + event2 = %{ + event1 + | timestamp: timestamp + } + + CacheStore.on_event(event1, %{}, nil, buffer_insert: buffer) + CacheStore.on_event(event2, %{}, nil, buffer_insert: buffer) + assert_receive({:buffer, :insert, [[_negative_record, session]]}) + assert session.is_bounce == false + assert session.duration == 10 + assert session.pageviews == 2 + assert session.events == 2 + assert session.replay_session_id == 456 + end + + test "updates session counters for replayed event", %{buffer: buffer} do + timestamp = DateTime.utc_now() + event1 = build(:event, name: "pageview", timestamp: timestamp |> NaiveDateTime.shift(second: -10)) @@ -310,6 +335,32 @@ defmodule Plausible.Session.CacheStoreTest do assert session.events == 2 end + test "treats replayed and normal events as belonging to distinct sessions even if user_id and site_id match", + %{buffer: buffer} do + timestamp = DateTime.utc_now() + + event1 = + build(:event, + name: "pageview", + replay_session_id: 456, + timestamp: timestamp |> NaiveDateTime.shift(second: -10) + ) + + event2 = %{ + event1 + | replay_session_id: nil, + timestamp: timestamp + } + + CacheStore.on_event(event1, %{}, nil, buffer_insert: buffer) + assert_receive({:buffer, :insert, [[session1]]}) + CacheStore.on_event(event2, %{}, nil, buffer_insert: buffer) + assert_receive({:buffer, :insert, [[session2]]}) + assert session1.session_id != session2.session_id + assert session1.replay_session_id == 456 + assert is_nil(session2.replay_session_id) + end + test "does not update session counters on engagement event", %{buffer: buffer} do now = NaiveDateTime.utc_now(:second) pageview = build(:pageview, timestamp: NaiveDateTime.shift(now, second: -10)) From 50b48ecbc2e7498462a382f0266a871102954c57 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 8 Jul 2026 11:52:41 +0200 Subject: [PATCH 19/23] Compute session salts more safely and cache them --- extra/lib/plausible/session/computed_salts.ex | 66 +++++++++++++++++++ lib/plausible/application.ex | 3 + lib/plausible/ingestion/event.ex | 16 +---- .../plausible/session/computed_salts_test.exs | 52 +++++++++++++++ 4 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 extra/lib/plausible/session/computed_salts.ex create mode 100644 test/plausible/session/computed_salts_test.exs diff --git a/extra/lib/plausible/session/computed_salts.ex b/extra/lib/plausible/session/computed_salts.ex new file mode 100644 index 000000000000..ed3835ed1ce6 --- /dev/null +++ b/extra/lib/plausible/session/computed_salts.ex @@ -0,0 +1,66 @@ +defmodule Plausible.Session.ComputedSalts do + @moduledoc """ + Cache for computed salts used for replayed events. + + It's not cleaned, however the range of possible values is limited + to the number of replayed sessions over the lifetime of the node + and sessions IDs can't be provided externally by users. + + Despite that, the cache is still purged every couple hours. + """ + + @purge_interval :timer.seconds(7200) + + use GenServer + + alias Plug.Crypto.KeyGenerator + + @spec start_link(Keyword.t()) :: GenServer.on_start() + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: opts[:name] || __MODULE__) + end + + @impl true + def init(opts) do + name = opts[:name] || __MODULE__ + + ^name = + :ets.new(name, [ + :named_table, + :set, + :public, + {:read_concurrency, true} + ]) + + schedule_purge() + + {:ok, name} + end + + @spec fetch(module() | atom(), pos_integer()) :: %{previous: nil, current: binary()} + def fetch(name \\ __MODULE__, session_id) do + computed_salt = + secret_key_base() + |> KeyGenerator.generate(:binary.encode_unsigned(session_id), cache: name) + |> binary_part(0, 16) + + %{previous: nil, current: computed_salt} + end + + @impl true + def handle_info(:purge, name) do + :ets.delete_all_objects(name) + schedule_purge() + + {:noreply, name} + end + + defp schedule_purge() do + Process.send_after(self(), :purge, @purge_interval) + end + + defp secret_key_base() do + Application.get_env(:plausible, PlausibleWeb.Endpoint) + |> Keyword.fetch!(:secret_key_base) + end +end diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 20191fc70189..4d67e729b75e 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -194,6 +194,9 @@ defmodule Plausible.Application do end, Plausible.Ingestion.Counters, Plausible.Session.Salts, + on_ee do + Plausible.Session.ComputedSalts + end, Supervisor.child_spec(Plausible.Event.WriteBuffer, id: Plausible.Event.WriteBuffer), Supervisor.child_spec(Plausible.Session.WriteBuffer, id: Plausible.Session.WriteBuffer), ReferrerBlocklist, diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex index 2c28a6cef3de..d21e5cb4ae48 100644 --- a/lib/plausible/ingestion/event.ex +++ b/lib/plausible/ingestion/event.ex @@ -383,13 +383,8 @@ defmodule Plausible.Ingestion.Event do on_ee do defp put_salts(%__MODULE__{} = event, _context) do - if session_id = event.request.replay_session_id do - computed_salt = - :crypto.hash(:sha, [secret_key_base(), :binary.encode_unsigned(session_id)]) - |> binary_part(0, 16) - - salts = %{previous: nil, current: computed_salt} - %{event | salts: salts} + if session_id = event.clickhouse_event_attrs.replay_session_id do + %{event | salts: Plausible.Session.ComputedSalts.fetch(session_id)} else %{event | salts: Plausible.Session.Salts.fetch()} end @@ -601,11 +596,4 @@ defmodule Plausible.Ingestion.Event do end defp spam_referrer?(_), do: false - - on_ee do - defp secret_key_base() do - Application.get_env(:plausible, PlausibleWeb.Endpoint) - |> Keyword.fetch!(:secret_key_base) - end - end end diff --git a/test/plausible/session/computed_salts_test.exs b/test/plausible/session/computed_salts_test.exs new file mode 100644 index 000000000000..86bf303f62a9 --- /dev/null +++ b/test/plausible/session/computed_salts_test.exs @@ -0,0 +1,52 @@ +defmodule Plausible.Session.ComputedSaltsTest do + use Plausible.DataCase, async: false + use Plausible + + on_ee do + alias Plausible.Session.ComputedSalts + + test "agent starts and responds with salt based on provided session id" do + {:ok, _} = ComputedSalts.start_link(name: __MODULE__) + %{current: current, previous: nil} = ComputedSalts.fetch(__MODULE__, 123_123_123) + assert is_binary(current) + end + + test "agent starts and responds with the same salt for the same session id" do + {:ok, _} = ComputedSalts.start_link(name: __MODULE__) + %{current: current1, previous: nil} = ComputedSalts.fetch(__MODULE__, 123_123_123) + %{current: current2, previous: nil} = ComputedSalts.fetch(__MODULE__, 123_123_123) + assert is_binary(current1) + assert current1 == current2 + end + + test "agent starts and responds with a different salt for different session ids" do + {:ok, _} = ComputedSalts.start_link(name: __MODULE__) + %{current: current1, previous: nil} = ComputedSalts.fetch(__MODULE__, 123_123_123) + %{current: current2, previous: nil} = ComputedSalts.fetch(__MODULE__, 123_123_124) + assert is_binary(current1) + assert is_binary(current2) + assert current1 != current2 + end + + test "salts are cached and cache can be purged" do + {:ok, _} = ComputedSalts.start_link(name: __MODULE__) + %{current: salt_before_purge, previous: nil} = ComputedSalts.fetch(__MODULE__, 123_123_123) + %{current: _, previous: nil} = ComputedSalts.fetch(__MODULE__, 123_123_124) + + assert [_, _] = :ets.tab2list(__MODULE__) + + send(__MODULE__, :purge) + + assert eventually(fn -> + table = :ets.tab2list(__MODULE__) + {table == [], table} + end) + + %{current: salt_after_purge, previous: nil} = ComputedSalts.fetch(__MODULE__, 123_123_123) + + assert [_] = :ets.tab2list(__MODULE__) + + assert salt_before_purge == salt_after_purge + end + end +end From f689d1261220be98479ce98bd693b5b916bb7871 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Jul 2026 11:34:13 +0200 Subject: [PATCH 20/23] Fix logic and tests for CE --- lib/plausible/clickhouse_event_v2.ex | 12 +++--- lib/plausible/clickhouse_session_v2.ex | 7 ++- lib/plausible/ingestion/event.ex | 13 ++++-- lib/plausible/session/cache_store.ex | 27 +++++++++--- test/plausible/ingestion/request_test.exs | 47 ++++++++++++--------- test/plausible/session/cache_store_test.exs | 3 ++ 6 files changed, 71 insertions(+), 38 deletions(-) diff --git a/lib/plausible/clickhouse_event_v2.ex b/lib/plausible/clickhouse_event_v2.ex index 3624789c7c39..ee90a04b1c10 100644 --- a/lib/plausible/clickhouse_event_v2.ex +++ b/lib/plausible/clickhouse_event_v2.ex @@ -3,6 +3,7 @@ defmodule Plausible.ClickhouseEventV2 do Event schema for when NumericIDs migration is complete """ use Ecto.Schema + use Plausible import Ecto.Changeset @primary_key false @@ -48,8 +49,10 @@ defmodule Plausible.ClickhouseEventV2 do field :acquisition_channel, Ch, type: "LowCardinality(String)", writable: :never - # Field used during event replay - field :replay_session_id, Ch, type: "UInt64" + on_ee do + # Field used during event replay + field :replay_session_id, Ch, type: "UInt64" + end # Virtual field used during event processing field :interactive?, :boolean, default: true, virtual: true, writable: :never @@ -74,9 +77,8 @@ defmodule Plausible.ClickhouseEventV2 do :revenue_source_currency, :revenue_reporting_amount, :revenue_reporting_currency, - :interactive?, - :replay_session_id - ] + :interactive? + ] ++ on_ee(do: [:replay_session_id], else: []) ) |> validate_required([:name, :site_id, :hostname, :pathname, :user_id, :timestamp]) end diff --git a/lib/plausible/clickhouse_session_v2.ex b/lib/plausible/clickhouse_session_v2.ex index 25d5fde1e1f9..379247dce362 100644 --- a/lib/plausible/clickhouse_session_v2.ex +++ b/lib/plausible/clickhouse_session_v2.ex @@ -3,6 +3,7 @@ defmodule Plausible.ClickhouseSessionV2 do Session schema for when NumericIDs migration is complete """ use Ecto.Schema + use Plausible defmodule BoolUInt8 do @moduledoc """ @@ -76,8 +77,10 @@ defmodule Plausible.ClickhouseSessionV2 do field :acquisition_channel, Ch, type: "LowCardinality(String)", writable: :never - # Field used during event replay - field :replay_session_id, Ch, type: "UInt64" + on_ee do + # Field used during event replay + field :replay_session_id, Ch, type: "UInt64" + end end def random_uint64() do diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex index d21e5cb4ae48..04e16e51bce6 100644 --- a/lib/plausible/ingestion/event.ex +++ b/lib/plausible/ingestion/event.ex @@ -276,7 +276,7 @@ defmodule Plausible.Ingestion.Event do end defp put_basic_info(%__MODULE__{} = event, _context) do - update_event_attrs(event, %{ + attrs = %{ domain: event.domain, site_id: event.site.id, timestamp: event.request.timestamp, @@ -285,9 +285,14 @@ defmodule Plausible.Ingestion.Event do pathname: event.request.pathname, scroll_depth: event.request.scroll_depth, engagement_time: event.request.engagement_time, - interactive?: event.request.interactive?, - replay_session_id: event.request.replay_session_id - }) + interactive?: event.request.interactive? + } + + on_ee do + attrs = Map.put(attrs, :replay_session_id, event.request.replay_session_id) + end + + update_event_attrs(event, attrs) end defp put_source_info(%__MODULE__{} = event, _context) do diff --git a/lib/plausible/session/cache_store.ex b/lib/plausible/session/cache_store.ex index 2d0101bff3ba..a40814d4e7a0 100644 --- a/lib/plausible/session/cache_store.ex +++ b/lib/plausible/session/cache_store.ex @@ -3,6 +3,8 @@ defmodule Plausible.Session.CacheStore do Session management on the basis of incoming events. """ + use Plausible + alias Plausible.Session.WriteBuffer @lock_timeout 1000 @@ -71,8 +73,12 @@ defmodule Plausible.Session.CacheStore do defp find_session(event, user_id) do key = - if event.replay_session_id do - {event.site_id, user_id, event.replay_session_id} + on_ee do + if event.replay_session_id do + {event.site_id, user_id, event.replay_session_id} + else + {event.site_id, user_id} + end else {event.site_id, user_id} end @@ -92,8 +98,12 @@ defmodule Plausible.Session.CacheStore do defp update_session_cache(session) do key = - if session.replay_session_id do - {session.site_id, session.user_id, session.replay_session_id} + on_ee do + if session.replay_session_id do + {session.site_id, session.user_id, session.replay_session_id} + else + {session.site_id, session.user_id} + end else {session.site_id, session.user_id} end @@ -139,7 +149,7 @@ defmodule Plausible.Session.CacheStore do end defp new_session_from_event(event, session_attributes) do - %Plausible.ClickhouseSessionV2{ + new_session = %Plausible.ClickhouseSessionV2{ sign: 1, session_id: Plausible.ClickhouseSessionV2.random_uint64(), hostname: if(event.name == "pageview", do: event.hostname, else: ""), @@ -171,9 +181,14 @@ defmodule Plausible.Session.CacheStore do browser_version: Map.get(session_attributes, :browser_version), timestamp: event.timestamp, start: event.timestamp, - replay_session_id: event.replay_session_id, "entry_meta.key": Map.get(event, :"meta.key"), "entry_meta.value": Map.get(event, :"meta.value") } + + on_ee do + %{new_session | replay_session_id: event.replay_session_id} + else + new_session + end end end diff --git a/test/plausible/ingestion/request_test.exs b/test/plausible/ingestion/request_test.exs index 3e91a7441d93..e7a4d3e4d5cc 100644 --- a/test/plausible/ingestion/request_test.exs +++ b/test/plausible/ingestion/request_test.exs @@ -645,27 +645,32 @@ defmodule Plausible.Ingestion.RequestTest do request = request |> Jason.encode!() |> Jason.decode!() - assert Map.drop(request, ["timestamp"]) == %{ - "domains" => ["dummy.site"], - "event_name" => "pageview", - "hash_mode" => 1, - "hostname" => "dummy.site", - "pathname" => "/pictures/index.html", - "props" => %{"abc" => "qwerty", "hello" => "world"}, - "query_params" => %{"baz" => "bam", "foo" => "bar"}, - "referrer" => "https://example.com", - "remote_ip" => "127.0.0.1", - "revenue_source" => %{"amount" => "12.3", "currency" => "USD"}, - "uri" => "https://dummy.site/pictures/index.html?foo=bar&baz=bam", - "user_agent" => "Mozilla", - "ip_classification" => nil, - "scroll_depth" => nil, - "engagement_time" => nil, - "tracker_script_version" => 0, - "interactive?" => true, - "replay_id" => nil, - "replay_session_id" => nil - } + expected = %{ + "domains" => ["dummy.site"], + "event_name" => "pageview", + "hash_mode" => 1, + "hostname" => "dummy.site", + "pathname" => "/pictures/index.html", + "props" => %{"abc" => "qwerty", "hello" => "world"}, + "query_params" => %{"baz" => "bam", "foo" => "bar"}, + "referrer" => "https://example.com", + "remote_ip" => "127.0.0.1", + "revenue_source" => %{"amount" => "12.3", "currency" => "USD"}, + "uri" => "https://dummy.site/pictures/index.html?foo=bar&baz=bam", + "user_agent" => "Mozilla", + "ip_classification" => nil, + "scroll_depth" => nil, + "engagement_time" => nil, + "tracker_script_version" => 0, + "interactive?" => true, + "replay_id" => nil + } + + on_ee do + expected = Map.put(expected, "replay_session_id", nil) + end + + assert Map.drop(request, ["timestamp"]) == expected assert %NaiveDateTime{} = NaiveDateTime.from_iso8601!(request["timestamp"]) end diff --git a/test/plausible/session/cache_store_test.exs b/test/plausible/session/cache_store_test.exs index 0f0c85d9fb26..0d47f9384837 100644 --- a/test/plausible/session/cache_store_test.exs +++ b/test/plausible/session/cache_store_test.exs @@ -290,6 +290,7 @@ defmodule Plausible.Session.CacheStoreTest do assert session.start === event.timestamp end + @tag :ee_only test "updates session counters", %{buffer: buffer} do timestamp = DateTime.utc_now() @@ -315,6 +316,7 @@ defmodule Plausible.Session.CacheStoreTest do assert session.replay_session_id == 456 end + @tag :ee_only test "updates session counters for replayed event", %{buffer: buffer} do timestamp = DateTime.utc_now() @@ -335,6 +337,7 @@ defmodule Plausible.Session.CacheStoreTest do assert session.events == 2 end + @tag :ee_only test "treats replayed and normal events as belonging to distinct sessions even if user_id and site_id match", %{buffer: buffer} do timestamp = DateTime.utc_now() From 44fde3c99ad82dba2a07249efa27781b7aa880c0 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Jul 2026 11:41:51 +0200 Subject: [PATCH 21/23] Fix typo --- test/plausible/ingestion/event_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plausible/ingestion/event_test.exs b/test/plausible/ingestion/event_test.exs index 8e6f95d43b45..93a70d8fc413 100644 --- a/test/plausible/ingestion/event_test.exs +++ b/test/plausible/ingestion/event_test.exs @@ -570,7 +570,7 @@ defmodule Plausible.Ingestion.EventTest do # identical fingerprint assert event1.clickhouse_event.user_id != event2.clickhouse_event.user_id # replayed events with the same session for the same fingerprint have the - # same useer id + # same user id assert event2.clickhouse_event.user_id == event3.clickhouse_event.user_id # replayed event from different sessions for the same fingerprint # have different users From f60bbe23ea6a504c75452c7a15634850cf9811ca Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Jul 2026 13:14:30 +0200 Subject: [PATCH 22/23] Reduce confusion in naming between replay session ID and session ID --- extra/lib/plausible/session/computed_salts.ex | 4 ++-- lib/plausible/ingestion/event.ex | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extra/lib/plausible/session/computed_salts.ex b/extra/lib/plausible/session/computed_salts.ex index ed3835ed1ce6..29e0a6b99d2e 100644 --- a/extra/lib/plausible/session/computed_salts.ex +++ b/extra/lib/plausible/session/computed_salts.ex @@ -38,10 +38,10 @@ defmodule Plausible.Session.ComputedSalts do end @spec fetch(module() | atom(), pos_integer()) :: %{previous: nil, current: binary()} - def fetch(name \\ __MODULE__, session_id) do + def fetch(name \\ __MODULE__, replay_session_id) do computed_salt = secret_key_base() - |> KeyGenerator.generate(:binary.encode_unsigned(session_id), cache: name) + |> KeyGenerator.generate(:binary.encode_unsigned(replay_session_id), cache: name) |> binary_part(0, 16) %{previous: nil, current: computed_salt} diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex index 04e16e51bce6..c9c58e075827 100644 --- a/lib/plausible/ingestion/event.ex +++ b/lib/plausible/ingestion/event.ex @@ -388,8 +388,8 @@ defmodule Plausible.Ingestion.Event do on_ee do defp put_salts(%__MODULE__{} = event, _context) do - if session_id = event.clickhouse_event_attrs.replay_session_id do - %{event | salts: Plausible.Session.ComputedSalts.fetch(session_id)} + if replay_session_id = event.clickhouse_event_attrs.replay_session_id do + %{event | salts: Plausible.Session.ComputedSalts.fetch(replay_session_id)} else %{event | salts: Plausible.Session.Salts.fetch()} end From bf527a4c758e69fcc66dd2e73e436629f5c1b480 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Jul 2026 13:14:52 +0200 Subject: [PATCH 23/23] Do not parse `X-Replay-Event-ID` as it's not used now --- lib/plausible/ingestion/request.ex | 19 +++++-------------- test/plausible/ingestion/request_test.exs | 6 +----- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/lib/plausible/ingestion/request.ex b/lib/plausible/ingestion/request.ex index 21e9e6f7bf1e..c9c51a4d5c9c 100644 --- a/lib/plausible/ingestion/request.ex +++ b/lib/plausible/ingestion/request.ex @@ -60,8 +60,7 @@ defmodule Plausible.Ingestion.Request do on_ee do field :revenue_source, :map - # fields meant for replayed events only - field :replay_id, :integer + # field for replayed events field :replay_session_id, :integer end @@ -137,26 +136,19 @@ defmodule Plausible.Ingestion.Request do end on_ee do - @replay_id_header "x-replay-event-id" @replay_session_id_header "x-replay-session-id" @replay_time_header "x-replay-time" defp put_replay_data(changeset, conn) do now = NaiveDateTime.utc_now(:second) - id = + replay_session_id = conn - |> Plug.Conn.get_req_header(@replay_id_header) + |> Plug.Conn.get_req_header(@replay_session_id_header) |> List.first() |> to_integer() - if id do - session_id = - conn - |> Plug.Conn.get_req_header(@replay_session_id_header) - |> List.first() - |> to_integer() - + if replay_session_id do time = conn |> Plug.Conn.get_req_header(@replay_time_header) @@ -165,8 +157,7 @@ defmodule Plausible.Ingestion.Request do if NaiveDateTime.compare(time, now) in [:lt, :eq] do changeset - |> Changeset.put_change(:replay_id, id) - |> Changeset.put_change(:replay_session_id, session_id) + |> Changeset.put_change(:replay_session_id, replay_session_id) |> Changeset.put_change(:timestamp, time) else changeset diff --git a/test/plausible/ingestion/request_test.exs b/test/plausible/ingestion/request_test.exs index e7a4d3e4d5cc..5b7d58f103cd 100644 --- a/test/plausible/ingestion/request_test.exs +++ b/test/plausible/ingestion/request_test.exs @@ -188,7 +188,6 @@ defmodule Plausible.Ingestion.RequestTest do |> put_req_header("x-replay-time", "2026-06-02 12:43:00") assert {:ok, request, _conn} = Request.build(conn) - assert request.replay_id == 123 assert request.replay_session_id == 456 assert NaiveDateTime.compare(request.timestamp, ~N[2026-06-02 12:43:00]) == :eq end @@ -204,7 +203,6 @@ defmodule Plausible.Ingestion.RequestTest do conn = build_conn(:post, "/api/events", payload) assert {:ok, request, _conn} = Request.build(conn) - assert is_nil(request.replay_id) assert is_nil(request.replay_session_id) assert %NaiveDateTime{} = request.timestamp end @@ -227,7 +225,6 @@ defmodule Plausible.Ingestion.RequestTest do |> put_req_header("x-replay-time", NaiveDateTime.to_iso8601(time)) assert {:ok, request, _conn} = Request.build(conn) - assert is_nil(request.replay_id) assert is_nil(request.replay_session_id) assert NaiveDateTime.compare(request.timestamp, time) == :lt end @@ -662,8 +659,7 @@ defmodule Plausible.Ingestion.RequestTest do "scroll_depth" => nil, "engagement_time" => nil, "tracker_script_version" => 0, - "interactive?" => true, - "replay_id" => nil + "interactive?" => true } on_ee do