Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cbb1e1a
Extract replay data to request struct
zoldar Jun 17, 2026
cb498f9
Add virtual `replay_session_id` field to CH event and session structs
zoldar Jun 17, 2026
982360c
Merge replay_session_id from request into event attrs
zoldar Jun 17, 2026
ba4b39b
Put session-dependent salt for replayed event
zoldar Jun 17, 2026
e571348
Add a separate cache for replayed sessions
zoldar Jun 17, 2026
de9f58a
Use different cache and key for replayed events in `CacheStore`
zoldar Jun 17, 2026
31f2cf2
Add migration renaming `recovery_id` CH columns to `replay_session_id`
zoldar Jun 25, 2026
7dcda47
Deserialize and persist replay session ID as integer
zoldar Jun 25, 2026
95bf8f5
Add tests for Request
zoldar Jun 25, 2026
44b0005
Add ingest event tests
zoldar Jun 25, 2026
79a5490
Add test for session-dependent salt generation
zoldar Jun 25, 2026
33c49b8
Add test for cache store
zoldar Jun 25, 2026
cb99689
Revert "Add a separate cache for replayed sessions"
zoldar Jun 25, 2026
40b9132
Revert "Use different cache and key for replayed events in `CacheStore`"
zoldar Jun 25, 2026
6dbefd2
Don't replay events with timestamp in the future
zoldar Jul 7, 2026
ba874eb
Fix request encoding test
zoldar Jul 7, 2026
89d6991
Bring back logic using different cache key for replayed sessions
zoldar Jul 8, 2026
3066530
Add more cache store tests
zoldar Jul 8, 2026
50b48ec
Compute session salts more safely and cache them
zoldar Jul 8, 2026
f689d12
Fix logic and tests for CE
zoldar Jul 9, 2026
44fde3c
Fix typo
zoldar Jul 9, 2026
f60bbe2
Reduce confusion in naming between replay session ID and session ID
zoldar Jul 9, 2026
bf527a4
Do not parse `X-Replay-Event-ID` as it's not used now
zoldar Jul 9, 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
66 changes: 66 additions & 0 deletions extra/lib/plausible/session/computed_salts.ex
Original file line number Diff line number Diff line change
@@ -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__, replay_session_id) do
computed_salt =
secret_key_base()
|> KeyGenerator.generate(:binary.encode_unsigned(replay_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
3 changes: 3 additions & 0 deletions lib/plausible/application.ex
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
defmodule Plausible.Application do
@moduledoc false

Expand Down Expand Up @@ -194,6 +194,9 @@
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,
Expand Down
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
24 changes: 20 additions & 4 deletions lib/plausible/ingestion/event.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -286,7 +286,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 @@ -380,8 +386,18 @@ 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 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
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
Expand Down
51 changes: 51 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,53 @@ 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
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

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
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading