Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b22e25e
Extract replay data to request struct
zoldar Jun 17, 2026
53818b4
Add virtual `replay_session_id` field to CH event and session structs
zoldar Jun 17, 2026
710807b
Merge replay_session_id from request into event attrs
zoldar Jun 17, 2026
7cdee5b
Put session-dependent salt for replayed event
zoldar Jun 17, 2026
a1a8b58
Add a separate cache for replayed sessions
zoldar Jun 17, 2026
448eeed
Use different cache and key for replayed events in `CacheStore`
zoldar Jun 17, 2026
fa09841
Deserialize and persist replay session ID as integer
zoldar Jun 25, 2026
055c4de
Add tests for Request
zoldar Jun 25, 2026
36c2881
Add ingest event tests
zoldar Jun 25, 2026
947a4b7
Add test for session-dependent salt generation
zoldar Jun 25, 2026
3805f84
Add test for cache store
zoldar Jun 25, 2026
ccb30bf
Revert "Add a separate cache for replayed sessions"
zoldar Jun 25, 2026
024b36f
Revert "Use different cache and key for replayed events in `CacheStore`"
zoldar Jun 25, 2026
0e6ae78
Don't replay events with timestamp in the future
zoldar Jul 7, 2026
2a1bb1a
Fix request encoding test
zoldar Jul 7, 2026
5dff699
Bring back logic using different cache key for replayed sessions
zoldar Jul 8, 2026
9924aab
Add more cache store tests
zoldar Jul 8, 2026
fb01585
Compute session salts more safely and cache them
zoldar Jul 8, 2026
a0864ca
Fix logic and tests for CE
zoldar Jul 9, 2026
987642e
Fix typo
zoldar Jul 9, 2026
39104ef
Reduce confusion in naming between replay session ID and session ID
zoldar Jul 9, 2026
0b13e0c
Do not parse `X-Replay-Event-ID` as it's not used now
zoldar Jul 9, 2026
0290371
Do not clear `Site.domain_changed_at` on rename expiration
zoldar Jul 15, 2026
4bb6769
Load `domain_changed_at` and `native_stats_start_at` into site cache
zoldar Jul 15, 2026
6d661f8
Reject replayed events with timestamp preceeding matching site's domain
zoldar Jul 15, 2026
7461760
Revert "Reject replayed events with timestamp preceeding matching sit…
zoldar Jul 16, 2026
12693f1
Revert "Load `domain_changed_at` and `native_stats_start_at` into sit…
zoldar Jul 16, 2026
be2a444
Revert "Do not clear `Site.domain_changed_at` on rename expiration"
zoldar Jul 16, 2026
26e8189
Simplify integer casting used for parsing replay headers
zoldar Jul 16, 2026
496b50f
Rename `ComputedSalts` to `ReplaySalts`
zoldar Jul 16, 2026
714be1e
Fix mixup in test names
zoldar Jul 21, 2026
2dfe044
Add replay session id to hash value instead of generating salt off it
zoldar Jul 22, 2026
f109824
Remove no longer needed `ReplaySalts`
zoldar Jul 22, 2026
2c60dc2
Skip rate limiting for replayed events
zoldar Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/plausible/clickhouse_event_v2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +49,11 @@ defmodule Plausible.ClickhouseEventV2 do

field :acquisition_channel, Ch, type: "LowCardinality(String)", writable: :never

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
end
Expand All @@ -72,7 +78,7 @@ defmodule Plausible.ClickhouseEventV2 do
:revenue_reporting_amount,
:revenue_reporting_currency,
:interactive?
]
] ++ on_ee(do: [:replay_session_id], else: [])
)
|> validate_required([:name, :site_id, :hostname, :pathname, :user_id, :timestamp])
end
Expand Down
6 changes: 6 additions & 0 deletions lib/plausible/clickhouse_session_v2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand Down Expand Up @@ -75,6 +76,11 @@ defmodule Plausible.ClickhouseSessionV2 do
field :transferred_from, :string

field :acquisition_channel, Ch, type: "LowCardinality(String)", writable: :never

on_ee do
# Field used during event replay
field :replay_session_id, Ch, type: "UInt64"
end
end

def random_uint64() do
Expand Down
31 changes: 27 additions & 4 deletions lib/plausible/ingestion/event.ex
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,20 @@ defmodule Plausible.Ingestion.Event do

@spec build_and_buffer(Request.t(), Keyword.t()) :: {:ok, %{buffered: [t()], dropped: [t()]}}
def build_and_buffer(%Request{domains: domains} = request, context \\ []) do
skip_rate_limit? =
on_ee do
not is_nil(request.replay_session_id)
else
false
end

processed_events =
if spam_referrer?(request) do
for domain <- domains, do: drop(new(domain, request), :spam_referrer)
else
Enum.reduce(domains, [], fn domain, acc ->
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
case GateKeeper.check(domain) do
case GateKeeper.check(domain, skip_rate_limit?: skip_rate_limit?) do
{:allow, site} ->
processed =
domain
Expand Down Expand Up @@ -276,7 +283,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,
Expand All @@ -286,7 +293,13 @@ defmodule Plausible.Ingestion.Event do
scroll_depth: event.request.scroll_depth,
engagement_time: event.request.engagement_time,
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
Expand Down Expand Up @@ -562,7 +575,17 @@ defmodule Plausible.Ingestion.Event do
user_agent = request.user_agent || ""
root_domain = get_root_domain(hostname)

SipHash.hash!(salt, user_agent <> request.remote_ip <> domain <> root_domain)
replay_session_id =
on_ee do
to_string(request.replay_session_id)
else
""
end

SipHash.hash!(
salt,
user_agent <> request.remote_ip <> domain <> root_domain <> replay_session_id
)
end
end

Expand Down
42 changes: 42 additions & 0 deletions lib/plausible/ingestion/request.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ defmodule Plausible.Ingestion.Request do

on_ee do
field :revenue_source, :map

# field for replayed events
field :replay_session_id, :integer
end

field :query_params, :map
Expand Down Expand Up @@ -91,6 +94,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()
Expand Down Expand Up @@ -131,6 +135,44 @@ defmodule Plausible.Ingestion.Request do
defp put_revenue_source(changeset, _request_body), do: changeset
end

on_ee do
@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)

replay_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)
|> List.first()
|> NaiveDateTime.from_iso8601!()

if NaiveDateTime.compare(time, now) in [:lt, :eq] do
changeset
|> Changeset.put_change(:replay_session_id, replay_session_id)
|> Changeset.put_change(:timestamp, time)
else
changeset
end
else
changeset
end
end

defp to_integer(s) when is_binary(s), do: String.to_integer(s)
defp to_integer(_), do: nil
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
Expand Down
35 changes: 32 additions & 3 deletions lib/plausible/session/cache_store.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,7 +72,18 @@ 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 =
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

from_cache = Plausible.Cache.Adapter.get(:sessions, key)

case from_cache do
nil ->
Expand All @@ -84,7 +97,17 @@ defmodule Plausible.Session.CacheStore do
end

defp update_session_cache(session) do
key = {session.site_id, session.user_id}
key =
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

Plausible.Cache.Adapter.put(:sessions, key, session, dirty?: true)
session
end
Expand Down Expand Up @@ -126,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: ""),
Expand Down Expand Up @@ -161,5 +184,11 @@ defmodule Plausible.Session.CacheStore do
"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
15 changes: 10 additions & 5 deletions lib/plausible/site/gate_keeper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,16 @@ defmodule Plausible.Site.GateKeeper do
with %Site{team: %{accept_traffic_until: accept_traffic_until}} = site <-
Cache.get(domain, Keyword.get(opts, :cache_opts, [])),
true <- Plausible.Sites.regular?(site) do
if not is_nil(accept_traffic_until) and
Date.after?(Date.utc_today(), accept_traffic_until) do
:payment_required
else
check_rate_limit(site, opts)
cond do
not is_nil(accept_traffic_until) and
Date.after?(Date.utc_today(), accept_traffic_until) ->
:payment_required

opts[:skip_rate_limit?] ->
{:allow, site}

true ->
check_rate_limit(site, opts)
end
else
_ ->
Expand Down
102 changes: 102 additions & 0 deletions test/plausible/ingestion/event_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,108 @@ 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

@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 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
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()

Expand Down
Loading
Loading