Clickstream analytics: sessions, funnels and audience search in SQL
Clickstream analytics turns the events that ads and websites record, from impressions and clicks to page views, searches and purchases, into answers: which publisher's clicks are real, where visitors leave the funnel, which ad gets credit for a sale, who sees the next ad. SereneDB is its storage and query layer: the ad server's and the site's events sit in one Postgres-compatible engine, and SQL counts, sessionizes, attributes and searches them.
curl https://install.serenedb.com | shWhat does clickstream analytics answer? Six questions about one day of ad and site events
Clickstream analytics is the analysis of the events people leave as they see ads, click, browse, search and buy. Below is one simulated day of an online running store: two campaigns on four publishers, 146,916 impressions, 1,135 clicks and 7,065 events on the store's site. Pick a question to run it; each is one SQL statement.
SELECT ts::TIME AS t, event, coalesce(publisher, page) AS publisher_or_page, coalesce(creative, query, title) AS detail, revenue FROM events WHERE user_id = 'v05054' ORDER BY ts;
| t | event | publisher_or_page | detail | revenue |
|---|---|---|---|---|
| 11:15:59 | impression | news-daily.example | cr-102 | |
| 14:03:47 | impression | run-club.example | cr-102 | |
| 14:04:25 | click | run-club.example | cr-102 | |
| 14:04:28 | page_view | /trail-shoes?utm_source=run-club.example&utm_medium=display&utm_campaign=trail_shoes&utm_content=cr-102 | Trail running shoes | |
| 14:05:05 | page_view | /p/scree | Scree trail running shoe | |
| 14:05:40 | search | /search | trail sheos | |
| 14:06:55 | add_to_cart | /cart | Scree trail running shoe | |
| 14:08:45 | purchase | /checkout/done | Scree trail running shoe | 119.00 |
| 16:02:19 | impression | news-daily.example | cr-101 | |
| 17:57:56 | impression | news-daily.example | cr-102 | |
| 19:09:58 | impression | weather-now.example | cr-201 |
SELECT publisher, sum(impressions) AS impressions, sum(clicks) AS clicks, round(100.0 * sum(clicks) / sum(impressions), 2) AS ctr_pct, round(max(100.0 * clicks / impressions), 2) AS top_hour_ctr_pct, round(sum(spend), 2) AS spend_usd FROM ( SELECT publisher, time_bucket(INTERVAL '1 hour', ts) AS hour, count(*) FILTER (WHERE event = 'impression') AS impressions, count(*) FILTER (WHERE event = 'click') AS clicks, sum(cost) AS spend FROM events WHERE event IN ('impression', 'click') GROUP BY publisher, hour ) GROUP BY publisher ORDER BY spend_usd DESC;
| publisher | impressions | clicks | ctr_pct | top_hour_ctr_pct | spend_usd |
|---|---|---|---|---|---|
| news-daily.example | 60480 | 209 | 0.35 | 0.42 | 362.88 |
| run-club.example | 25920 | 313 | 1.21 | 1.68 | 246.24 |
| weather-now.example | 34560 | 177 | 0.51 | 0.81 | 138.24 |
| deals-feed.example | 25956 | 436 | 1.68 | 5.66 | 57.10 |
time_bucket turns the rows into hours inside the query, and FILTER counts impressions and clicks in the same pass.WITH site AS ( SELECT user_id, ts, page, CASE WHEN ts - lag(ts) OVER (PARTITION BY user_id ORDER BY ts) <= INTERVAL '30 minutes' THEN 0 ELSE 1 END AS starts_session FROM events WHERE event IN ('page_view', 'search', 'add_to_cart', 'purchase') ), numbered AS ( SELECT *, sum(starts_session) OVER (PARTITION BY user_id ORDER BY ts) AS session FROM site ), sessions AS ( SELECT user_id, session, coalesce(nullif(regexp_extract(arg_min(page, ts), 'utm_source=([^&]+)', 1), ''), 'no UTM tags') AS source, count(*) AS events, date_diff('second', min(ts), max(ts)) AS seconds FROM numbered GROUP BY user_id, session ) SELECT source, count(*) AS sessions, round(avg(events), 2) AS events_per_session, round(100.0 * count(*) FILTER (WHERE events = 1) / count(*), 1) AS bounce_pct, round(avg(seconds)) AS avg_seconds FROM sessions GROUP BY source ORDER BY sessions DESC;
| source | sessions | events_per_session | bounce_pct | avg_seconds |
|---|---|---|---|---|
| no UTM tags | 2556 | 2 | 44.1 | 47 |
| deals-feed.example | 435 | 1.24 | 86.7 | 16 |
| run-club.example | 313 | 2.01 | 44.4 | 44 |
| news-daily.example | 208 | 2.02 | 43.3 | 44 |
| weather-now.example | 177 | 2.06 | 43.5 | 47 |
lag() finds the gaps and a running sum() numbers the sessions. deals-feed.example's 435 sessions bounce 86.7 % of the time and last 16 seconds on average; every other source bounces 43 to 45 %.WITH firsts AS ( SELECT user_id, arg_min(campaign, ts) FILTER (WHERE event = 'click') AS campaign, min(ts) FILTER (WHERE event = 'click') AS clicked, min(ts) FILTER (WHERE event = 'page_view' AND page LIKE '/p/%') AS viewed, min(ts) FILTER (WHERE event = 'add_to_cart') AS carted, min(ts) FILTER (WHERE event = 'purchase') AS bought FROM events WHERE event IN ('click', 'page_view', 'add_to_cart', 'purchase') GROUP BY user_id ) SELECT campaign, count(*) AS clicked, count(*) FILTER (WHERE viewed > clicked) AS viewed_product, count(*) FILTER (WHERE carted > viewed AND viewed > clicked) AS added_to_cart, count(*) FILTER (WHERE bought > carted AND carted > viewed AND viewed > clicked) AS purchased FROM firsts WHERE campaign IS NOT NULL GROUP BY campaign ORDER BY clicked DESC;
| campaign | clicked | viewed_product | added_to_cart | purchased |
|---|---|---|---|---|
| trail_shoes | 698 | 275 | 59 | 26 |
| rain_jackets | 424 | 173 | 36 | 14 |
WITH clicks AS ( SELECT user_id, ts, publisher FROM events WHERE event = 'click' ), purchases AS ( SELECT user_id, ts, revenue FROM events WHERE event = 'purchase' ), spend AS ( SELECT publisher, sum(cost) AS spend FROM events WHERE event = 'impression' GROUP BY publisher ) SELECT c.publisher, round(any_value(s.spend), 2) AS spend_usd, count(*) AS purchases, sum(p.revenue) AS revenue_usd, round(sum(p.revenue) / any_value(s.spend), 1) AS roas FROM purchases p ASOF JOIN clicks c ON c.user_id = p.user_id AND p.ts >= c.ts JOIN spend s ON s.publisher = c.publisher GROUP BY c.publisher ORDER BY roas DESC;
| publisher | spend_usd | purchases | revenue_usd | roas |
|---|---|---|---|---|
| weather-now.example | 138.24 | 11 | 1189.00 | 8.6 |
| run-club.example | 246.24 | 17 | 1843.00 | 7.5 |
| deals-feed.example | 57.10 | 2 | 238.00 | 4.2 |
| news-daily.example | 362.88 | 10 | 1040.00 | 2.9 |
ASOF JOIN finds that click by time. 40 of the day's 158 purchases followed a click. weather-now.example returned $8.60 for every dollar spent; news-daily.example, the biggest budget, $2.90.SELECT query, count(DISTINCT user_id) AS visitors FROM events_idx WHERE event = 'search' AND query @@ (ts_levenshtein('trail', 1) && ts_levenshtein('shoes', 1)) AND user_id NOT IN (SELECT user_id FROM events WHERE event = 'purchase') GROUP BY query ORDER BY visitors DESC, query;
| query | visitors |
|---|---|
| trail shoes wide fit | 89 |
| waterproof trail shoes | 89 |
| trail sheos | 84 |
| trial running shoes | 83 |
| trail running shoes | 73 |
ts_levenshtein accepts one edit per word, so the 167 who typed "trial running shoes" or "trail sheos" are in it; the exact words alone find 251.What clickstream analytics needs from its database, and how SereneDB does it in SQL
The database under clickstream analytics takes in events as ad servers and sites send them, counts them over time and distinct visitors, rebuilds sessions and funnels from the order of events, connects ad events to site events, searches what visitors typed and keeps the raw history in compressed files it can still query. Lists of clickstream analytics tools compare products; these are the jobs, each with the SereneDB feature that does it and its docs.
| Need | How SereneDB does it | Looks like |
|---|---|---|
| Take in events | Ad servers and site collectors write over the PostgreSQL protocol: INSERT for single events, COPY for batches, from any PostgreSQL client library. Docs → | COPY events (event_id, ts, event, user_id, campaign, creative, publisher, cost) FROM STDIN |
| Count over time | time_bucket groups events into minutes, hours or days, and FILTER counts impressions and clicks in the same pass. Docs → | time_bucket(INTERVAL '1 hour', ts) |
| Count people | approx_count_distinct estimates distinct visitors with HyperLogLog in one pass; count(DISTINCT …) gives the exact number. Docs → | approx_count_distinct(user_id) |
| Rebuild sessions | lag() compares each event with the visitor's previous one, and a running sum over the gaps numbers the sessions. Docs → | lag(ts) OVER (PARTITION BY user_id ORDER BY ts) |
| Funnels in order | FILTER takes each step's first time per visitor, and comparisons between those times keep the steps in order. Docs → | min(ts) FILTER (WHERE event = 'add_to_cart') |
| Attribute purchases | ASOF JOIN pairs each purchase with the same visitor's last ad click at or before it. Docs → | ASOF JOIN clicks c ON c.user_id = p.user_id AND p.ts >= c.ts |
| Read UTM tags | regexp_extract pulls utm_source, utm_campaign and utm_content out of landing-page URLs. Docs → | regexp_extract(arg_min(page, ts), 'utm_source=([^&]+)', 1) |
| Search what visitors typed | An inverted index over search terms and page titles: words, phrases, BM25 ranking and matches within an edit distance for typos. Docs → | query @@ (ts_levenshtein('trail', 1) && ts_levenshtein('shoes', 1)) |
| Find similar creatives | A vector index over creative embeddings returns the nearest ones by distance, in the same SQL. Docs → | ORDER BY emb <-> [0.80, 0.60, 0.50, 0.10]::FLOAT[4] |
| Dashboards | Grafana's built-in PostgreSQL data source sends each panel's SQL to SereneDB. Docs → | floor(extract(epoch from ts)/600)*600 AS "time" |
| Audience files | COPY writes a segment's visitor ids to a CSV file for the system that serves the next campaign. Docs → | ) TO '/tmp/trail_shoes_segment.csv' (HEADER) |
| Raw history as files | COPY writes old events to zstd Parquet files partitioned by day, and read_parquet queries them in place, on disk or in S3. Docs → | (FORMAT parquet, PARTITION_BY (day), COMPRESSION zstd, APPEND) |
| Retention | A scheduled job inside the server archives and deletes the events past their period. Docs → | CREATE JOB clickstream_history EVERY 1 DAY OFFSET 2 HOURS AS $$ … $$ |
Clickstream analytics architecture: ad events, site events and creatives in one engine
The ad server and the site's collector send events over the PostgreSQL protocol into one events table, and the creatives sit next to it with their embeddings. Inverted indexes cover what is searched and filtered, and every question is a SELECT: a time bucket, a window function, an ASOF JOIN, a search or a vector distance. Grafana reads the answers, and COPY writes audience files and Parquet history. Hover a stage, or a lane inside the index.
Impressions and clicks arrive in batches
An ad server or its log shipper batches impressions and clicks and sends them with COPY … FROM STDIN over the PostgreSQL protocol. In COPY's default text format, each line is one event with tab-separated values; a site collector sends single events with INSERT.
Page views, searches and orders as rows
The site's collector writes every page view, search, cart and purchase with the visitor's anonymous id, from any PostgreSQL client library. Landing pages keep their UTM tags in the page column, and regexp_extract reads them at query time.
A clickstream schema: one row per event
Every event is a row with its time, its name and the visitor's id. Ad events add campaign, creative, publisher and cost; site events add the page, its title, the search terms and the order value. One visitor's day reads in order:
The creative library, next to its results
Each creative with its campaign, its headline and an embedding of what it says. Because the events sit in the same engine, a creative's click-through rate is a join away; cr-102 leads the day.
What is searched and filtered is indexed
One inverted index per table: search terms, page titles and headlines through the ad_text dictionary, event names, campaigns, publishers and visitors as exact terms, times as ranges, and the creatives' embeddings as an IVF vector index. New rows become searchable at the next refresh, every second by default, or at once after VACUUM (REFRESH_TABLE). An aggregate counts a row as soon as it commits; the real-time analytics page covers both timings.
Campaign numbers by the hour
time_bucket groups the events into hours, and FILTER counts impressions and clicks in the same pass. This is the lab's Campaigns question.
Sessions from the gaps between events
lag() gives each site event the time of the visitor's previous one; a gap over 30 minutes starts a session, and a running sum() numbers them.
Each purchase, with the click before it
ASOF JOIN pairs every purchase with the last ad click of the same visitor at or before it, which is last-click attribution, then joins the spend per publisher.
Search what visitors typed, ranked
ad_text lowercases and splits text into words. Words in a query match any row that has them, ts_levenshtein matches within an edit distance for typos, and BM25 ranks the closest matches first, here over the creatives' headlines.
The creatives closest in meaning
<-> is the L2 distance between embeddings, and the vector index returns the nearest first. The page writes four dimensions by hand; in production the vectors come from your embedding model or from ai_embed() in SQL.
Grafana panels in SQL
Add SereneDB as a PostgreSQL data source on port 7890 and write panels in raw SQL. Grafana replaces its time macros before sending; the statement below is what reached SereneDB.
A segment out as a file
COPY … TO writes the visitor ids a query returns to a CSV file, for the system that serves the next campaign. This one holds the visitors who searched for trail shoes and didn't buy.
Raw events to Parquet, measured
COPY … TO with PARTITION_BY writes one folder of zstd Parquet files per day, parquet_file_metadata reports their rows and bytes, and read_parquet queries them in place. With an S3 secret the same COPY writes to a bucket, and a job does it every night.
What clickstream analytics needs from one engine
events · creativesImpressions, clicks, page views, searches and orders share one table, so a funnel or an attribution is one query.
SELECT event, count(*) FROM events GROUP BY eventASOF JOIN pairs each purchase with the visitor's last click before it, without a window function.
p.ts >= c.tsSite searches match within an edit distance, so "trail sheos" lands in the trail shoes audience.
ts_levenshtein('shoes', 1)SearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.
otel_logs_1b · 26.09.1Tutorial: clickstream analytics in SQL, in five steps
From an empty server to a retargeting audience: start SereneDB, create the tables, load one day of ad and site events, count the campaigns, sessions and funnel, then attribute the purchases and export the audience.
docker run -d --name serenedb -p 7890:7890 \ -e POSTGRES_PASSWORD=<password> serenedb/serenedb:26.11.2 psql -h localhost -p 7890 -U postgres
CREATE TABLE events ( event_id BIGINT, ts TIMESTAMP, event VARCHAR, -- impression, click, page_view, search, add_to_cart or purchase user_id VARCHAR, -- the visitor's anonymous id campaign VARCHAR, -- ad events: the campaign, the creative and the site that showed it creative VARCHAR, publisher VARCHAR, cost DECIMAL(10,6), -- impression: the price paid, CPM / 1000 page VARCHAR, -- site events: the page path, UTM tags on landing pages title VARCHAR, -- the page's title or the product query VARCHAR, -- search: what the visitor typed revenue DECIMAL(10,2) -- purchase: the order value ); CREATE TABLE creatives ( creative VARCHAR, campaign VARCHAR, status VARCHAR, -- live or archived headline VARCHAR, emb FLOAT[4] -- hand-written here; a model's embeddings in production ); CREATE TEXT SEARCH DICTIONARY ad_text AS split_text(case := 'lower') WITH (frequency, position, norm); CREATE INDEX events_idx ON events USING inverted (ts, event, user_id, campaign, publisher, title ad_text, query ad_text); CREATE INDEX creatives_idx ON creatives USING inverted (creative, campaign, status, headline ad_text, emb ivf (metric = 'l2'));
-- One day of ad impressions, 2026-11-05: four publishers, more traffic in the -- evening than at night, and four times the usual traffic on deals-feed from -- 14:00 to 15:00. Each row gets a fixed pseudo-random number from its id, so -- every run loads the same day. INSERT INTO events (event_id, ts, event, user_id, campaign, creative, publisher, cost) SELECT id * 10, TIMESTAMP '2026-11-05 00:00:00' + m * INTERVAL '1 minute' + (h % 60) * INTERVAL '1 second', 'impression', 'v' || lpad((h / 60 % 20000)::VARCHAR, 5, '0'), CASE WHEN c < 55 THEN 'trail_shoes' ELSE 'rain_jackets' END, CASE WHEN c < 25 THEN 'cr-101' WHEN c < 55 THEN 'cr-102' WHEN c < 80 THEN 'cr-201' ELSE 'cr-202' END, publisher, cpm / 1000 FROM ( SELECT *, h / 1200000 % 100 AS c FROM ( SELECT p.publisher, p.cpm, g.m, (p.n * 1440 + g.m) * 100 + k.k AS id, ((p.n * 1440 + g.m) * 100 + k.k) * 2654435761 % 4294967296 AS h FROM (VALUES (1, 'news-daily.example', 6.00, 70), (2, 'run-club.example', 9.50, 30), (3, 'weather-now.example', 4.00, 40), (4, 'deals-feed.example', 2.20, 25)) AS p(n, publisher, cpm, peak), generate_series(0, 1439) AS g(m), generate_series(1, 100) AS k(k) WHERE k.k <= round(p.peak * (0.6 - 0.4 * cos(2 * pi() * (g.m - 240) / 1440)) * CASE WHEN p.n = 4 AND g.m >= 840 AND g.m < 900 THEN 4 ELSE 1 END) ) ); -- Clicks: a share of the impressions that depends on the publisher and the -- creative. From 14:00 to 15:00, deals-feed's clicks come one second after -- the impression, at ten times its usual rate. INSERT INTO events (event_id, ts, event, user_id, campaign, creative, publisher) SELECT event_id + 1, ts + CASE WHEN burst THEN 1 ELSE 4 + floor(r2 * 40) END * INTERVAL '1 second', 'click', user_id, campaign, creative, publisher FROM ( SELECT *, (event_id * 2246822519 % 4294967296) / 4294967296.0 AS r, (event_id * 3266489917 % 4294967296) / 4294967296.0 AS r2, publisher = 'deals-feed.example' AND ts >= TIMESTAMP '2026-11-05 14:00:00' AND ts < TIMESTAMP '2026-11-05 15:00:00' AS burst FROM events WHERE event = 'impression' ) WHERE r < CASE publisher WHEN 'news-daily.example' THEN 0.0030 WHEN 'run-club.example' THEN 0.0110 WHEN 'weather-now.example' THEN 0.0045 ELSE CASE WHEN burst THEN 0.0500 ELSE 0.0050 END END * CASE creative WHEN 'cr-101' THEN 1.0 WHEN 'cr-102' THEN 1.45 WHEN 'cr-201' THEN 0.85 ELSE 1.1 END; -- What visitors did on the store's site: every ad click lands on its -- campaign's page with UTM tags, and 2,500 visitors come in from search on the -- home page. A share views a product, searches, adds to the cart and buys; -- some come back three hours later and buy then. deals-feed's 14:00 clicks -- land and leave. The day ends at midnight. INSERT INTO events (event_id, ts, event, user_id, page, title, query, revenue) SELECT v.id + s.n, v.ts + CASE WHEN v.burst THEN INTERVAL '1 second' ELSE s.after END, s.event, v.user_id, CASE WHEN s.n = 2 AND v.campaign IS NULL THEN '/' WHEN s.n = 2 THEN '/' || replace(v.campaign, '_', '-') || '?utm_source=' || v.publisher || '&utm_medium=display' || '&utm_campaign=' || v.campaign || '&utm_content=' || v.creative WHEN s.event = 'search' THEN '/search' WHEN s.event = 'add_to_cart' THEN '/cart' WHEN s.event = 'purchase' THEN '/checkout/done' ELSE (['/p/ridge-3', '/p/scree', '/p/trail-socks', '/p/stormline', '/p/drizzle-shell', '/p/brim-cap'])[v.k] END, CASE WHEN s.n = 2 THEN CASE v.campaign WHEN 'trail_shoes' THEN 'Trail running shoes' WHEN 'rain_jackets' THEN 'Running rain jackets' ELSE 'Running gear for trails and rain' END WHEN s.event = 'search' THEN NULL ELSE (['Ridge 3 waterproof trail running shoe', 'Scree trail running shoe', 'Trail running socks, 3-pack', 'Stormline waterproof running jacket', 'Drizzle 180 g packable rain jacket', 'Running cap with brim'])[v.k] END, CASE WHEN s.event = 'search' THEN (['trail running shoes', 'waterproof trail shoes', 'trial running shoes', 'trail sheos', 'trail shoes wide fit', 'rain jacket', 'waterprof running jacket', 'packable rain jacket', 'running cap', 'headlamp', 'running socks'])[v.q] END, CASE WHEN s.event = 'purchase' THEN ([139.00, 119.00, 24.00, 149.00, 89.00, 29.00])[v.k] END FROM ( SELECT *, CASE campaign WHEN 'trail_shoes' THEN 1 + floor(r2 * 3) WHEN 'rain_jackets' THEN 4 + floor(r2 * 3) ELSE 1 + floor(r2 * 6) END::INTEGER AS k, CASE campaign WHEN 'trail_shoes' THEN 1 + floor(r3 * 5) WHEN 'rain_jackets' THEN 6 + floor(r3 * 4) ELSE 1 + floor(r3 * 11) END::INTEGER AS q FROM ( SELECT event_id AS id, ts, user_id, campaign, creative, publisher, publisher = 'deals-feed.example' AND ts >= TIMESTAMP '2026-11-05 14:00:00' AND ts < TIMESTAMP '2026-11-05 15:00:00' AS burst, (event_id * 2654435761 % 4294967296) / 4294967296.0 AS r, (event_id * 2246822519 % 4294967296) / 4294967296.0 AS r2, (event_id * 3266489917 % 4294967296) / 4294967296.0 AS r3 FROM events WHERE event = 'click' UNION ALL SELECT 10000000 + i * 10, TIMESTAMP '2026-11-05 07:00:00' + floor((i * 2971215073 % 4294967296) / 4294967296.0 * 57600) * INTERVAL '1 second', 'v' || lpad((20000 + i)::VARCHAR, 5, '0'), NULL, NULL, NULL, false, (i * 2246822519 % 4294967296) / 4294967296.0, (i * 3266489917 % 4294967296) / 4294967296.0, (i * 2654435761 % 4294967296) / 4294967296.0 FROM generate_series(1, 2500) AS g(i) ) ) AS v JOIN (VALUES (2, 'page_view', INTERVAL '3 seconds', 0.0, 1.0), (3, 'page_view', INTERVAL '40 seconds', 0.0, 0.55), (4, 'search', INTERVAL '75 seconds', 0.0, 0.30), (5, 'add_to_cart', INTERVAL '150 seconds', 0.0, 0.12), (6, 'purchase', INTERVAL '260 seconds', 0.0, 0.03), (7, 'page_view', INTERVAL '3 hours', 0.03, 0.05), (8, 'purchase', INTERVAL '3 hours 4 minutes', 0.03, 0.05)) AS s(n, event, after, lo, hi) ON v.r >= s.lo AND v.r < s.hi AND (NOT v.burst OR s.n = 2) WHERE v.ts + s.after < TIMESTAMP '2026-11-06 00:00:00'; -- The creative library: four creatives live today, four from past flights. -- emb has four hand-written dimensions (trail shoes, rain, price offer, -- product detail) so the distances stay readable. INSERT INTO creatives VALUES ('cr-101', 'trail_shoes', 'live', 'Grip on wet rock: trail shoes for the season', [0.90, 0.30, 0.05, 0.40]), ('cr-102', 'trail_shoes', 'live', 'Waterproof trail runners with free shipping', [0.80, 0.60, 0.50, 0.10]), ('cr-201', 'rain_jackets', 'live', 'A 180 g rain shell that packs into its own pocket', [0.05, 0.90, 0.05, 0.50]), ('cr-202', 'rain_jackets', 'live', 'Run dry: waterproof running jackets from $89', [0.10, 0.90, 0.60, 0.10]), ('cr-091', 'trail_shoes', 'archived', 'Trail shoes that drain fast after river crossings', [0.90, 0.50, 0.00, 0.30]), ('cr-092', 'trail_shoes', 'archived', 'Free returns on every trail shoe, all season', [0.80, 0.05, 0.70, 0.05]), ('cr-181', 'rain_jackets', 'archived', 'Reflective running jacket for dark, wet mornings', [0.05, 0.60, 0.05, 0.70]), ('cr-182', 'rain_jackets', 'archived', 'Waterproof gear for wet trail runs, 15% off this week', [0.60, 0.80, 0.60, 0.10]); VACUUM (REFRESH_TABLE) events; VACUUM (REFRESH_TABLE) creatives; SELECT event, count(*) AS events FROM events GROUP BY event ORDER BY events DESC;
SELECT publisher, sum(impressions) AS impressions, sum(clicks) AS clicks, round(100.0 * sum(clicks) / sum(impressions), 2) AS ctr_pct, round(max(100.0 * clicks / impressions), 2) AS top_hour_ctr_pct, round(sum(spend), 2) AS spend_usd FROM ( SELECT publisher, time_bucket(INTERVAL '1 hour', ts) AS hour, count(*) FILTER (WHERE event = 'impression') AS impressions, count(*) FILTER (WHERE event = 'click') AS clicks, sum(cost) AS spend FROM events WHERE event IN ('impression', 'click') GROUP BY publisher, hour ) GROUP BY publisher ORDER BY spend_usd DESC; WITH site AS ( SELECT user_id, ts, page, CASE WHEN ts - lag(ts) OVER (PARTITION BY user_id ORDER BY ts) <= INTERVAL '30 minutes' THEN 0 ELSE 1 END AS starts_session FROM events WHERE event IN ('page_view', 'search', 'add_to_cart', 'purchase') ), numbered AS ( SELECT *, sum(starts_session) OVER (PARTITION BY user_id ORDER BY ts) AS session FROM site ), sessions AS ( SELECT user_id, session, coalesce(nullif(regexp_extract(arg_min(page, ts), 'utm_source=([^&]+)', 1), ''), 'no UTM tags') AS source, count(*) AS events, date_diff('second', min(ts), max(ts)) AS seconds FROM numbered GROUP BY user_id, session ) SELECT source, count(*) AS sessions, round(avg(events), 2) AS events_per_session, round(100.0 * count(*) FILTER (WHERE events = 1) / count(*), 1) AS bounce_pct, round(avg(seconds)) AS avg_seconds FROM sessions GROUP BY source ORDER BY sessions DESC; WITH firsts AS ( SELECT user_id, arg_min(campaign, ts) FILTER (WHERE event = 'click') AS campaign, min(ts) FILTER (WHERE event = 'click') AS clicked, min(ts) FILTER (WHERE event = 'page_view' AND page LIKE '/p/%') AS viewed, min(ts) FILTER (WHERE event = 'add_to_cart') AS carted, min(ts) FILTER (WHERE event = 'purchase') AS bought FROM events WHERE event IN ('click', 'page_view', 'add_to_cart', 'purchase') GROUP BY user_id ) SELECT campaign, count(*) AS clicked, count(*) FILTER (WHERE viewed > clicked) AS viewed_product, count(*) FILTER (WHERE carted > viewed AND viewed > clicked) AS added_to_cart, count(*) FILTER (WHERE bought > carted AND carted > viewed AND viewed > clicked) AS purchased FROM firsts WHERE campaign IS NOT NULL GROUP BY campaign ORDER BY clicked DESC;
WITH clicks AS ( SELECT user_id, ts, publisher FROM events WHERE event = 'click' ), purchases AS ( SELECT user_id, ts, revenue FROM events WHERE event = 'purchase' ), spend AS ( SELECT publisher, sum(cost) AS spend FROM events WHERE event = 'impression' GROUP BY publisher ) SELECT c.publisher, round(any_value(s.spend), 2) AS spend_usd, count(*) AS purchases, sum(p.revenue) AS revenue_usd, round(sum(p.revenue) / any_value(s.spend), 1) AS roas FROM purchases p ASOF JOIN clicks c ON c.user_id = p.user_id AND p.ts >= c.ts JOIN spend s ON s.publisher = c.publisher GROUP BY c.publisher ORDER BY roas DESC; COPY ( SELECT DISTINCT user_id FROM events_idx WHERE event = 'search' AND query @@ (ts_levenshtein('trail', 1) && ts_levenshtein('shoes', 1)) AND user_id NOT IN (SELECT user_id FROM events WHERE event = 'purchase') ORDER BY user_id ) TO '/tmp/trail_shoes_segment.csv' (HEADER); CREATE JOB clickstream_history EVERY 1 DAY OFFSET 2 HOURS AS $$ COPY (SELECT *, ts::DATE AS day FROM events WHERE ts < current_date - INTERVAL '30 days') TO '/tmp/clickstream' (FORMAT parquet, PARTITION_BY (day), COMPRESSION zstd, APPEND); DELETE FROM events WHERE ts < current_date - INTERVAL '30 days'; $$;
What adtech and martech teams build on SereneDB
Invalid traffic: flag clicks no person could make
A person needs a few seconds to see an ad and click it. ASOF JOIN pairs each click with the impression it followed, from the same visitor and creative, so the seconds between them are a column to bucket by hour and take the median of. The rule is SQL, so the threshold and the window are yours to change.
Real-time analytics →WITH clicks AS ( SELECT user_id, creative, publisher, ts FROM events WHERE event = 'click' ), impressions AS ( SELECT user_id, creative, ts FROM events WHERE event = 'impression' ) SELECT c.publisher, time_bucket(INTERVAL '1 hour', c.ts) AS hour, count(*) AS clicks, round(100.0 * count(*) FILTER (WHERE c.ts - i.ts <= INTERVAL '2 seconds') / count(*), 1) AS within_2s_pct, median(date_diff('second', i.ts, c.ts)) AS median_s FROM clicks c ASOF JOIN impressions i ON i.user_id = c.user_id AND i.creative = c.creative AND c.ts >= i.ts GROUP BY c.publisher, hour ORDER BY within_2s_pct DESC, clicks DESC, hour, c.publisher LIMIT 3;
| publisher | hour | clicks | within_2s_pct | median_s |
|---|---|---|---|---|
| deals-feed.example | 2026-11-05 14:00:00 | 329 | 99.7 | 1 |
| run-club.example | 2026-11-05 14:00:00 | 24 | 0 | 31 |
| run-club.example | 2026-11-05 16:00:00 | 22 | 0 | 33 |
Creative search: find the ones like today's best
cr-102, "Waterproof trail runners with free shipping", has the day's highest click-through rate, 1.01 %. Its embedding finds the archived creatives closest in meaning, candidates for the next flight, and the same index ranks headlines by BM25 for a word such as waterproof. Search, the distance and the click-through rate come from one SQL engine.
Vector search →SELECT creative, status, headline, round((emb <-> [0.80, 0.60, 0.50, 0.10]::FLOAT[4])::numeric, 3) AS distance FROM creatives_idx WHERE creative <> 'cr-102' ORDER BY emb <-> [0.80, 0.60, 0.50, 0.10]::FLOAT[4] LIMIT 3;
| creative | status | headline | distance |
|---|---|---|---|
| cr-182 | archived | Waterproof gear for wet trail runs, 15% off this week | 0.300 |
| cr-091 | archived | Trail shoes that drain fast after river crossings | 0.557 |
| cr-092 | archived | Free returns on every trail shoe, all season | 0.587 |
Reach and frequency: how many people, how often
Reach is the number of distinct people a campaign's impressions reached, and frequency the average number of impressions each of them saw. approx_count_distinct estimates reach with HyperLogLog in one pass; count(DISTINCT …) next to it shows how close the estimate is.
SELECT campaign, count(*) AS impressions, approx_count_distinct(user_id) AS reach, count(DISTINCT user_id) AS reach_exact, round(count(*) / approx_count_distinct(user_id)::numeric, 2) AS frequency FROM events WHERE event = 'impression' GROUP BY campaign ORDER BY impressions DESC;
| campaign | impressions | reach | reach_exact | frequency |
|---|---|---|---|---|
| trail_shoes | 81220 | 18811 | 19356 | 4.32 |
| rain_jackets | 65696 | 17984 | 18463 | 3.65 |
Clickstream analytics is the analysis of the events people leave as they see ads, click, browse, search and buy: impressions, clicks, page views, searches and orders. It answers which campaigns and publishers bring visitors who buy, where visitors leave, and who to show the next ad to. SereneDB stores these events and answers those questions in SQL.
Clickstream data is the record of what a visitor did, in time order, one event per row: when it happened, what happened, who did it under an anonymous id, and the details, such as the page, the ad, the search terms or the order value. The lab's first question shows one visitor's day, from two ad impressions to a $119.00 order.
Clickstream analytics tools do three jobs: collect the events (a tracking script or SDK on the site, the ad server's logs), store and query them, and show the results as dashboards, reports and audience lists. SereneDB is the store and query part: it takes events over the PostgreSQL protocol and answers in SQL, so dashboards and scripts read it with a PostgreSQL driver.
Compare each event with the same visitor's previous one using lag(), mark a new session when the gap is longer than your timeout, 30 minutes on this page, and number the sessions with a running sum of those marks. The lab's Sessions question does it in one statement and reports bounce rate and length per traffic source.
Take each visitor's first time at every step with min(ts) FILTER (WHERE …), then count the visitors whose steps come in order: viewed after clicking, added to the cart after viewing, bought after adding. In the lab, 698 visitors clicked a trail_shoes ad, 275 viewed a product, 59 added it to the cart and 26 bought.
Each purchase is credited to the last ad click of the same visitor at or before it. ASOF JOIN finds that click directly, on the visitor and p.ts >= c.ts; grouping by publisher and dividing revenue by spend gives the return on ad spend. A lookback window, such as 30 days, is a filter on p.ts - c.ts.
Write the segment as a query: the visitors who did one thing and not another, such as searched for trail shoes and didn't buy. With an inverted index on the search terms, ts_levenshtein includes the misspelled searches, and COPY … TO writes the visitor ids to a CSV file for the system that serves the next campaign. Keep the buyers instead of leaving them out, and the same query is a suppression list.
Take in events as they happen, count them over time windows and distinct visitors, rebuild sessions and funnels from the order of events, join ad events to site events, search what visitors typed, and keep the raw history as compressed files. The checklist above maps each need to the SereneDB feature that does it and its docs.
Measure it on your own events: in zstd Parquet, the 155,116 events of this page's day take 1,077,870 bytes, 6.9 bytes per event, where the same rows as CSV take 15,118,380 bytes. Events with full URLs, user agents and longer ids take more per row. parquet_file_metadata reports the rows and bytes of each file, and read_parquet queries the files in place.
Look for clicks no person makes: clicks a second after the impression, sessions that end on the landing page, bursts on one publisher. ASOF JOIN pairs each click with its impression, so time to click is a column: in this page's day, deals-feed.example's clicks from 14:00 to 15:00 came a median 1 second after the impression, and its sessions bounced 86.7 % of the time over the day.
Yes. Grafana's built-in PostgreSQL data source connects to SereneDB on port 7890, and each panel is a SQL query. Grafana expands macros such as $__timeFilter into plain SQL before sending it.
Yes, under the Apache-2.0 license, with its source on GitHub. Its Docker image runs the walkthrough above with one command.
Get started with SereneDB
Start SereneDB, load your ad and site events, and answer campaign, funnel and audience questions in SQL from Grafana or any PostgreSQL client.