Pixelex.Event (Pixelex v0.1.0)

Copy Markdown View Source

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

render()

@type render() :: :dead | :connected | :client | :server

t()

@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

new(attrs)

@spec new(map() | keyword()) :: {:ok, t()} | {:error, atom()}

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.

reserved_prefix()

The namespace pixelex reserves for its own event names.

schema_version()

The schema version stamped on every event this build writes.

skew_corrected(event, sent_at)

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

uuid7()

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