Sessions in ETS: thirty minutes of inactivity ends one.
GoatCounter keeps sessions in memory and sweeps the stale ones, and gets 800 hits/sec out of a $5 VPS doing it. This is that, in BEAM terms. A session is derived state — it can be rebuilt from the event log — so paying a database round-trip per event to store it would be paying for durability nobody needs.
The midnight handover
resolve/3 looks the visitor up under today's salt first, then under
yesterday's. A hit on yesterday's id moves the session to today's id and
keeps going. Without that second lookup every session open at 00:00 UTC ends
and a new one begins, so a site's session count spikes every night, average
session duration collapses, and nothing anywhere reports an error.
That is the whole reason Pixelex.Identity.visitor/3 returns two ids.
What is kept
{visitor_id, session_id, started_at_ms, last_seen_ms, events} and nothing
else — no IP, no user agent, no path. The table is bounded by the sweeper,
which runs every minute and deletes anything past the timeout.
Summary
Functions
How many sessions are currently held. Observability and tests.
Returns a specification to start this module under a supervisor.
Is this page view a repeat of the one just recorded for this session?
Drop every session. Tests only.
Resolve a visitor to a session, starting or continuing one as needed.
Delete sessions idle past the timeout. Runs on a timer; exposed for tests.
Types
@type resolution() :: %{ visitor_id: String.t(), session_id: String.t(), new_session?: boolean(), event_index: non_neg_integer(), attribution: map() }
Functions
@spec active() :: non_neg_integer()
How many sessions are currently held. Observability and tests.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec first_view?(String.t(), String.t() | nil, pos_integer()) :: boolean()
Is this page view a repeat of the one just recorded for this session?
Returns true — and records the path — when the session has NOT seen this
path in the last window_ms, so the caller records it.
What this is for
A Phoenix LiveView renders every page twice: once as plain HTML over HTTP
(the dead render), then again when the WebSocket connects. mount/3 and
handle_params/3 both run twice. Count both and every human is two visitors;
count only the connected one and crawlers, no-JS clients and anyone whose
socket never opens disappear.
Neither is acceptable, and no flag on the socket distinguishes the two cases
reliably — a push_navigate into a LiveView also mounts exactly once,
connected, with no dead render before it. So the honest test is behavioural:
the same session asking for the same path twice inside a few seconds is one
page view. A genuine reload is slower than that; a real navigation changes
the path.
@spec reset() :: :ok
Drop every session. Tests only.
@spec resolve(String.t(), String.t() | nil, String.t() | nil, struct() | nil) :: resolution()
Resolve a visitor to a session, starting or continuing one as needed.
Returns the visitor id under today's salt, so callers always store the current-day identifier even when the session was found under yesterday's.
touch is folded into the session's stored attribution, giving every event
in the session the same first touch and a last touch that updates.
The ceiling on first touch
First touch is session-scoped, and that is a consequence of being cookieless rather than an oversight. Attributing a conversion to an ad clicked three weeks earlier requires a durable identifier on the visitor's device, and putting one there is exactly what ePrivacy Article 5(3) governs — the rule that makes a banner necessary. A library cannot promise "no cookie banner" and "thirty-day first-touch attribution" at the same time; anything claiming both is doing the second one unlawfully in the EEA.
The opt-in exists for hosts that have consent: with device-scoped first touch the JS tracker persists the first touch locally and sends it, and this becomes a true multi-visit first touch. It is off by default.
@spec sweep() :: non_neg_integer()
Delete sessions idle past the timeout. Runs on a timer; exposed for tests.