Pixelex.Sessions (Pixelex v0.1.0)

Copy Markdown View Source

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

resolution()

@type resolution() :: %{
  visitor_id: String.t(),
  session_id: String.t(),
  new_session?: boolean(),
  event_index: non_neg_integer(),
  attribution: map()
}

Functions

active()

@spec active() :: non_neg_integer()

How many sessions are currently held. Observability and tests.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

first_view?(visitor_id, pathname, window_ms \\ 5000)

@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.

reset()

@spec reset() :: :ok

Drop every session. Tests only.

resolve(site_id, user_agent, ip, touch \\ nil)

@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.

start_link(opts)

sweep()

@spec sweep() :: non_neg_integer()

Delete sessions idle past the timeout. Runs on a timer; exposed for tests.