pixelex

Cookieless, multi-tenant, first-party analytics for Phoenix. Web visitors, product events and ad-platform attribution over one event log, in your own Postgres.

Hex.pmDocsCILicense

0.1.0 — 265 tests, no compiler warnings, Dialyzer clean. First release; the API may still move before 1.0.

# It counts page views with no JavaScript at all.
plug Pixelex.Plug
# It knows where a visitor came from without a single UTM tag.
Pixelex.Attribution.touch("https://shop.test/?gclid=Cj0KCQ", nil)
#=> %Pixelex.Attribution{network: "google", medium: "paid_search", click_id: "Cj0KCQ"}
# And it identifies visitors without storing anything on their device.
Pixelex.track(context, "booking_completed", %{value: 1499.0, currency: "EGP"})

Why this exists

Nothing in the Elixir ecosystem does this. phoenix_analytics logs requests and has no visitor identity, no sessions and no product analytics. Plausible is AGPL-3.0, a standalone application and a ClickHouse dependency — not something you can put in mix.exs. Nothing on Hex does funnels, retention or cohorts; Plausible CE withholds funnels as a paid feature. Nothing on Hex does ad-platform Conversions API dispatch at all.

Three ideas

One event, three products. Web analytics, product analytics and ad attribution differ in volume, identity model and destination, but they are all views over the same log. Get Pixelex.Event right and the three collapse into one library.

Server-first ingestion. Every hosted analytics product measures from the browser because it is not running inside your application. A library is. A Plug and a LiveView on_mount hook count every page view — initial, live_patch, live_navigate — with zero client bytes and nothing for an ad-blocker to block. Plausible's own research found a site losing 58% of visitors to blockers with Google Analytics. That is the number this does not have. The JS tracker becomes an optional enrichment layer for screen size and scroll depth, not the transport.

Attribution you do not configure. Ad platforms already stamp a click id on every click they sell — fbclid, gclid, ttclid, twenty-odd others — and browsers already send a referrer. Reading both is strictly more reliable than asking a marketer to hand-build utm_source on every creative. UTM parameters still win when present; they are an override, not the mechanism.

Install

mix pixelex.install

generates the migration and prints the wiring. Or by hand:

def deps do
[
{:pixelex, "~> 0.1"},
# Optional but strongly recommended: referrer classification and
# browser/OS/device parsing plus real bot filtering. Both Apache-2.0.
{:ref_inspector, "~> 2.0"},
{:ua_inspector, "~> 3.0"}
]
end
# config/config.exs
config :pixelex,
repo: MyApp.Repo,
sites: %{
"shop.test" => [allowed_events: ~w(book_click call_click order_started)]
}
# priv/repo/migrations/..._add_pixelex.exs
defmodule MyApp.Repo.Migrations.AddPixelex do
use Ecto.Migration
def up, do: Pixelex.Migration.up()
def down, do: Pixelex.Migration.down()
end
mix ref_inspector.download
mix ua_inspector.download

⚠️ Those databases are downloaded, not bundled — which is what keeps pixelex cleanly Apache-2.0, since the underlying referers.yml is GPL-3.0. It also means your Dockerfile needs those two lines before mix release.

Capture

# endpoint.ex, after Plug.Static
plug Pixelex.Plug
# router.ex, in the :browser pipeline, after fetch_session
plug Pixelex.Plug.Session
# router.ex, for browser-sent events (only if you need them)
forward "/px", Pixelex.Plug.Ingest
# router.ex, for LiveView apps
live_session :default, on_mount: [Pixelex.LiveView] do
live "/", HomeLive
end

For LiveView you also need the socket to see the request:

socket "/live", Phoenix.LiveView.Socket,
websocket: [
connect_info: [:peer_data, :user_agent, :x_headers, session: @session_options]
]

Without :peer_data every visitor hashes to the same id and your site has one visitor forever.

Keep partitions ahead of the calendar

config :my_app, Oban,
plugins: [{Oban.Plugins.Cron, crontab: [
{"0 3 * * *", MyApp.PixelexMaintenance}
]}]
defmodule MyApp.PixelexMaintenance do
use Oban.Worker
def perform(_job) do
Pixelex.Partitions.ensure(2)
Pixelex.Partitions.drop_expired()
:ok
end
end

A range-partitioned table with no partition covering today rejects every insert. Nothing degrades gracefully — ingestion stops at midnight on the first of the month. ensure/1 is idempotent; run it hourly if you like.

How identity works

visitor_id = base64url(first 64 bits of HMAC-SHA256(salt_of_day, site_id | ua | ip))

The salt rotates every UTC day and is deleted after two, so the identifier is unlinkable across days and cannot be recomputed later even with the raw inputs. Rotation needs no scheduler: the salts table is keyed by date, so it is INSERT ... ON CONFLICT DO NOTHING and the first node to notice the new day wins.

The subtle part, and the one everything else depends on: at 00:00 UTC every visitor's hash changes, so every open session would look like a new person. Pixelex.Sessions.resolve/4 looks the visitor up under yesterday's salt too and moves the session across. Without it, session counts spike every night and average duration collapses, and nothing anywhere reports an error.

Nothing is stored on or read from the visitor's device, so ePrivacy Article 5(3) — the cookie rule — is not engaged and no banner is required for first-party analytics. GDPR still governs the processing: the rotating hash is pseudonymisation, destroying the salt within 24h is anonymisation, and the lawful basis is legitimate interest under Article 6(1)(f), available precisely because none of it is collected for advertising.

This is Plausible's published position and it is contested at the margin: while the salt lives, the hash is pseudonymous personal data, not anonymous. pixelex does not claim to make you "GDPR compliant". It gives you the argument and its limits.

Forwarding to ad platforms is a separate, narrower gate. That does need consent where consent law applies — and suppressing only the browser pixel while a server-side Conversions API keeps firing is theatre, because the server leg carries more identifying data to the same companies. See Pixelex.Consent.

What cookieless costs you

First touch is session-scoped. Attributing a conversion to an ad clicked three weeks ago requires a durable identifier on the visitor's device, and putting one there is exactly what Article 5(3) governs. A library cannot promise "no cookie banner" and "30-day first-touch attribution" at the same time; anything claiming both is doing the second one unlawfully in the EEA.

Multi-day unique visitor counts are a sum of dailies, and therefore an over-count, for the same reason — the hash is a different value tomorrow. No HyperLogLog sketch fixes this; Plausible has the identical ceiling.

Design notes

decisionwhy
Plain Postgres, no extensionshll is absent on Supabase; TimescaleDB is Apache-edition-only on Neon and deprecated on Supabase. A library that needs an extension is one most people cannot install.
Monthly range partitions from the first migrationConverting a populated table later means copying every row under an exclusive lock, on the table that grows fastest.
Retention by DROP TABLE on a partitionO(1). DELETE ... WHERE occurred_at < … rewrites the largest table in the system and leaves the space to vacuum.
Rollups by ON CONFLICT DO UPDATEREFRESH MATERIALIZED VIEW is a full recompute every time; Postgres has no incremental materialised views.
UUIDv7 event idsThe first 48 bits are a timestamp, so ids sort by insertion and the primary key stops being a random-write hotspot.
HMAC-SHA256, not SipHash:crypto is stdlib. At these volumes the speed difference is far below the cost of the write that follows.
Drop events under pressureAnalytics must never apply backpressure to a request. Losing events is a bad day; growing a queue until the node dies takes the application with it.
A rate limiter in 25 lines of ETSThe requirement is one number per IP per minute. That is :ets.update_counter/4.
ref_inspector / ua_inspector optionalThey depend on hackney ~> 1.0, and hackney 1.25.0 carries four unpatched advisories including one HIGH, with fixes only in 4.x. Requiring them would put that in every consumer's mix hex.audit.

Reading the data

range = Pixelex.Query.range(:last_30_days)
Pixelex.Query.Traffic.summary("shop", range)
#=> %{pageviews: 4182, sessions: 1204, visitors_daily_sum: 1103,
#=> bounce_rate: 0.42, views_per_session: 3.47, events: 5006}
Pixelex.Query.Funnel.run("shop", range, ~w(px.pageview view_doctor book_click booking_completed))
Pixelex.Query.Retention.cohorts("shop", range, bucket: "week")

Also top_pages/3, sources/3, mediums/3, campaigns/3, countries/3, browsers/3, devices/3, events/3, timeseries/3.

The funnel builds one CTE per step, each joined to the previous and taking only events at or after it. The single-pass shortcut everyone reaches for — min(occurred_at) FILTER (WHERE name = step_n) — silently drops anyone who did the last step once before the funnel and again properly within it, which is any funnel whose final step is reachable from elsewhere.

Sending conversions back to the ad platforms

Pixelex.Destinations.fire("shop", :purchase,
event_id: "order:" <> order.id, # derive it from the row
user_data: %{email: patient.email, phone: patient.phone, click_id: fbclid},
custom_data: %{currency: "EGP", value: 1499.0}
)

Meta, TikTok, Snapchat, GA4, Pinterest, Reddit and LinkedIn. Each no-ops without its own credentials. The same event_id goes to the browser pixel and to every platform, so the pair counts once — and because it is derived from the row, a replay cannot double-count, which is what makes retrying safe. Delivery runs through Oban with five attempts.

X is not shipped. Its endpoint and payload are known, but it needs OAuth 1.0a signing, its own API-reference page for the conversions endpoint 404s so there is no field-level spec, and the simpler header auth that would avoid the signer appears only in third-party write-ups. A guessed endpoint is worse than seven platforms.

Three things the platforms disagree on, each of which fails with a 200 and no matches rather than an error — all handled, all tested:

phoneMeta, Snapchat, Pinterest: digits only. TikTok, Reddit: E.164 with the +. LinkedIn: no phone field at all.
hashed match keysarrays for Meta, Snapchat, Pinterest; plain strings for TikTok; {idType, idValue} pairs for LinkedIn.
success200 for most; 200with{"code": 0} for TikTok; 200 with a per-event status array for Pinterest; 201 for LinkedIn.

Reddit's email rule is also its own — lowercase, strip dots from the local part, drop everything after a + — and the implementation is checked against Reddit's published test vector.

The browser tracker

Optional. Page views are already counted server-side.

<script defer src="/px/pixelex.js" data-site="shop"></script>

1,458 bytes gzipped, served from your own origin. Adds clicks (data-track="book_click"), SPA and LiveView navigation, screen size, scroll depth and engagement time. Guards for localhost, file://, headless browsers, GPC and a local opt-out. Sends with keepalive and falls back to an image; fires on visibilitychange and pagehide, neverunload, which disqualifies the page from the back-forward cache.

Telemetry

eventmeasurementswhen
[:pixelex, :ingest, :push]%{count}an event is queued
[:pixelex, :ingest, :flush]%{count, bytes, written, duration_us}a batch reaches the store
[:pixelex, :ingest, :drop]%{count}, %{reason}the buffer shed load
[:pixelex, :pipeline, :drop]%{count}, %{reason, site_id}an event was refused

Watch the drops. Silent loss is the danger, not loss.

Not written yet

A Flutter/mobile SDK — the HTTP ingest endpoint accepts events from anything in the meantime. Rollup tables (raw events answer everything inside the retention window today). ClickHouse and DuckDB store adapters, which the Pixelex.Store behaviour has room for.

Licence

Apache-2.0.