The one thing pixelex stores.
Web analytics, product analytics and ad attribution look like three products, but they differ only in which fields they read and where the row is forwarded. They are three views over this struct. Getting it right is the whole design; everything else is plumbing.
The two fields you cannot add later
:v and the "px." name prefix. A schema version lets a future reader tell a
v1 row from a v2 row without guessing, and a reserved namespace lets pixelex
introduce px.pageview in a release without colliding with an event a host app
already named. Both are free today and unfixable once rows exist — Segment,
Snowplow and PostHog all reserve a prefix, and PostHog's $ came before their
data did.
Two timestamps
:timestamp is stamped by the server and is authoritative. :client_ts is
what the browser claimed, and browsers lie — clock skew of hours is ordinary.
Keeping both lets skew_corrected/2 recover the real client time using
Segment's formula, and lets a query ignore the client's opinion entirely.
Limits
Names are capped at 120 characters and URLs/referrers at 2000 bytes, matching Plausible's ingest. These are not arbitrary: the fields are written from an unauthenticated endpoint, and a column with no ceiling is a disk-filling primitive handed to anyone who can find the URL.
Summary
Functions
Builds a validated event, filling in :id and :timestamp when absent.
The namespace pixelex reserves for its own event names.
The schema version stamped on every event this build writes.
The real client time, correcting for a skewed browser clock.
A UUIDv7 as a 36-character string.
Types
@type render() :: :dead | :connected | :client | :server
@type t() :: %Pixelex.Event{ attribution: map() | nil, browser: String.t() | nil, city: String.t() | nil, client_ts: DateTime.t() | nil, country: String.t() | nil, device_type: String.t() | nil, hostname: String.t() | nil, id: String.t(), name: String.t(), os: String.t() | nil, pathname: String.t() | nil, props: map(), referrer: String.t() | nil, region: String.t() | nil, render: render() | nil, session_id: String.t() | nil, site_id: String.t(), timestamp: DateTime.t(), url: String.t() | nil, user_id: String.t() | nil, v: pos_integer(), visitor_id: String.t() | nil }
Functions
Builds a validated event, filling in :id and :timestamp when absent.
Returns {:ok, event} or {:error, reason}. Every caller of this is on a path
where analytics must never be the reason a request fails, so callers match on
the error and drop — they do not raise.
The namespace pixelex reserves for its own event names.
The schema version stamped on every event this build writes.
@spec skew_corrected(t(), DateTime.t() | nil) :: DateTime.t()
The real client time, correcting for a skewed browser clock.
timestamp = received_at - (sent_at - originally_created_at)Segment's formula. sent_at and client_ts come from the same wrong clock, so
their difference is accurate even when neither value is. Falls back to the
server timestamp when the client sent nothing.
@spec uuid7() :: String.t()
A UUIDv7 as a 36-character string.
v7 over v4 because the first 48 bits are a millisecond timestamp, so ids sort in insertion order. That turns the primary-key index from a random-write hotspot into an append, which is the difference between a b-tree that stays cache-resident and one that does not — on the table that takes every write in the system.
Written here rather than pulled in: it is fifteen lines of :crypto, and the
alternative is a dependency in every consumer's tree for those fifteen lines.
Layout per RFC 9562: 48-bit big-endian unix ms, 4-bit version 0b0111,
12 bits random, 2-bit variant 0b10, 62 bits random.