SereneDB

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 | sh
ad events · 148,051 rowsdeals-feedquery
An animation in three scenes over one simulated day, 2026-11-05. First, 146,916 impressions and 1,135 clicks on four publishers are bucketed by hour with time_bucket; deals-feed.example's click-through rate jumps to 5.66 % at 14:00, while no other publisher's best hour passes 1.68 %. Second, the funnel counts each visitor's first click, product view, cart and purchase in that order: 698 visitors clicked a trail_shoes ad, 275 viewed a product, 59 added it to the cart and 26 bought; for rain_jackets the steps are 424, 173, 36 and 14. Third, the site's search log is searched for trail and shoes within one edit each: 418 visitors who searched for trail shoes and did not buy, 167 of them only through misspellings such as "trial running shoes" and "trail sheos".
What is clickstream analytics

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

campaign lab · one day, two campaigns
2026-11-05 · per 30 minutesdeals-feed.exampleother publishers
00:0003:0006:0009:0012:0015:0018:0021:0024:00impressionsclickssite sessionspurchasestrail-shoe searchesvisitor v05054click → $119.00 order
A visitor
sql
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;
result · 11 rows
teventpublisher_or_pagedetailrevenue
11:15:59impressionnews-daily.examplecr-102
14:03:47impressionrun-club.examplecr-102
14:04:25clickrun-club.examplecr-102
14:04:28page_view/trail-shoes?utm_source=run-club.example&utm_medium=display&utm_campaign=trail_shoes&utm_content=cr-102Trail running shoes
14:05:05page_view/p/screeScree trail running shoe
14:05:40search/searchtrail sheos
14:06:55add_to_cart/cartScree trail running shoe
14:08:45purchase/checkout/doneScree trail running shoe119.00
16:02:19impressionnews-daily.examplecr-101
17:57:56impressionnews-daily.examplecr-102
19:09:58impressionweather-now.examplecr-201
Visitor v05054's day as rows: two impressions, a click on creative cr-102 at 14:04:25, the landing page with its UTM tags, a product page, a search typed as "trail sheos", the cart and a $119.00 order 4 minutes 20 seconds after the click. The three impressions after the order went to someone who had already bought.
Campaigns
sql
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;
result · 4 rows
publisherimpressionsclicksctr_pcttop_hour_ctr_pctspend_usd
news-daily.example604802090.350.42362.88
run-club.example259203131.211.68246.24
weather-now.example345601770.510.81138.24
deals-feed.example259564361.685.6657.10
deals-feed.example has the day's highest click-through rate, 1.68 %, and one hour at 5.66 %, more than three times the best hour of any other publisher. time_bucket turns the rows into hours inside the query, and FILTER counts impressions and clicks in the same pass.
Sessions
sql
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;
result · 5 rows
sourcesessionsevents_per_sessionbounce_pctavg_seconds
no UTM tags2556244.147
deals-feed.example4351.2486.716
run-club.example3132.0144.444
news-daily.example2082.0243.344
weather-now.example1772.0643.547
A session ends after 30 minutes without an event: 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 %.
Funnel
sql
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;
result · 2 rows
campaignclickedviewed_productadded_to_cartpurchased
trail_shoes6982755926
rain_jackets4241733614
Each visitor's first click, product view, cart and purchase, counted only in that order. 698 visitors clicked a trail_shoes ad and 26 bought; rain_jackets turned 424 into 14. Of the 329 clicks deals-feed.example took from 14:00 to 15:00, 328 went no further than the landing page.
Attribution
sql
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;
result · 4 rows
publisherspend_usdpurchasesrevenue_usdroas
weather-now.example138.24111189.008.6
run-club.example246.24171843.007.5
deals-feed.example57.102238.004.2
news-daily.example362.88101040.002.9
Last-click attribution credits each purchase to the visitor's last ad click before it, and 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.
Audience
sql
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;
result · 5 rows
queryvisitors
trail shoes wide fit89
waterproof trail shoes89
trail sheos84
trial running shoes83
trail running shoes73
The visitors who searched the store for trail shoes and haven't bought: 418 people for the next retargeting campaign. 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.

Checklist

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.

NeedHow SereneDB does itLooks like
Take in eventsAd 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 timetime_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 peopleapprox_count_distinct estimates distinct visitors with HyperLogLog in one pass; count(DISTINCT …) gives the exact number. Docs →approx_count_distinct(user_id)
Rebuild sessionslag() 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 orderFILTER 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 purchasesASOF 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 tagsregexp_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 typedAn 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 creativesA 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]
DashboardsGrafana's built-in PostgreSQL data source sends each panel's SQL to SereneDB. Docs →floor(extract(epoch from ts)/600)*600 AS "time"
Audience filesCOPY 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 filesCOPY 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)
RetentionA 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 $$ … $$

Architecture

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.

01 · ingest
COPY
ad server batches PostgreSQL :7890
01 · ingest
INSERT
site collector views · searches · orders
02 · store
events
one row per event
02 · store
creatives
headlines · embeddings
03 · inverted indexes
events and creatives
query, title, headlinetext
event, publisher, user_idexact
tsrange
embvector
04 · count
time_bucket
FILTER · count
04 · sessions
lag() OVER
30-minute gaps
04 · attribute
ASOF JOIN
purchase → last click
04 · audiences
@@
ts_levenshtein · BM25
04 · creatives
<->
nearest embeddings
05 · dashboards
Grafana
PostgreSQL data source :7890
06 · audiences
COPY TO
visitor ids · CSV
07 · history
CREATE JOB
Parquet · DELETE
> ingest · ad events

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.

sql
COPY events (event_id, ts, event, user_id, campaign, creative, publisher, cost) FROM STDIN;
990000010 2026-11-06 00:00:01 impression v05054 trail_shoes cr-102 run-club.example 0.0095
990000020 2026-11-06 00:00:01 impression v11873 rain_jackets cr-201 news-daily.example 0.006
990000030 2026-11-06 00:00:02 impression v02664 trail_shoes cr-101 weather-now.example 0.004
990000021 2026-11-06 00:00:09 click v11873 rain_jackets cr-201 news-daily.example \N
\.
-- COPY 4
> ingest · site events

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.

sql
INSERT INTO events (event_id, ts, event, user_id, page, title)
VALUES (990000022, TIMESTAMP '2026-11-06 00:00:12', 'page_view', 'v11873',
'/rain-jackets?utm_source=news-daily.example&utm_medium=display&utm_campaign=rain_jackets&utm_content=cr-201',
'Running rain jackets');
-- the batch above and this page view:
SELECT event, count(*) AS events
FROM events
WHERE ts >= TIMESTAMP '2026-11-06 00:00:00'
GROUP BY event ORDER BY event DESC;
-- page_view 1 · impression 3 · click 1
> stage 02 · events

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:

sql
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;
-- 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.… | 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
> stage 02 · creatives

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.

sql
SELECT creative,
round(100.0 * count(*) FILTER (WHERE event = 'click')
/ count(*) FILTER (WHERE event = 'impression'), 2) AS ctr_pct
FROM events
WHERE event IN ('impression', 'click')
GROUP BY creative
ORDER BY ctr_pct DESC;
-- cr-102 1.01 % · cr-202 0.73 % · cr-101 0.7 % · cr-201 0.59 %
> stage 03 · inverted indexes

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.

sql
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'));
> stage 04 · count

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.

sql
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;
> stage 04 · sessions

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.

sql
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;
> stage 04 · attribute

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.

sql
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;
> stage 04 · audiences

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.

sql
SELECT ts_lexize('ad_text', 'Trail sheos, WIDE fit') AS tokens;
-- {trail,sheos,wide,fit}
SELECT creative, headline, round(BM25(creatives_idx.tableoid), 3) AS score
FROM creatives_idx
WHERE headline @@ 'waterproof trail'
ORDER BY score DESC, creative;
-- cr-102 | Waterproof trail runners with free shipping | 0.735
-- cr-182 | Waterproof gear for wet trail runs, 15% off this week | 0.601
-- cr-202 | Run dry: waterproof running jackets from $89 | 0.458
> stage 04 · creatives

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.

sql
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;
> stage 05 · dashboards

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.

sql
-- the panel, as written in Grafana:
-- SELECT $__timeGroupAlias(ts, '10m'), publisher, round(100.0 * … , 2) AS ctr_pct
-- FROM events WHERE $__timeFilter(ts) AND event IN ('impression', 'click') …
-- what SereneDB receives for the range 13:35–14:25:
SELECT floor(extract(epoch from ts)/600)*600 AS "time", publisher,
round(100.0 * count(*) FILTER (WHERE event = 'click')
/ count(*) FILTER (WHERE event = 'impression'), 2) AS ctr_pct
FROM events
WHERE ts BETWEEN '2026-11-05T13:35:00Z' AND '2026-11-05T14:25:00Z'
AND event IN ('impression', 'click')
AND publisher = 'deals-feed.example'
GROUP BY 1, 2 ORDER BY 1;
-- time is epoch seconds, 1793887200 = 14:00 UTC; one row every 10 minutes
-- 1.74 0 0.84 5.58 5.01 5.8
> stage 06 · audiences

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.

sql
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);
-- COPY 418
SELECT count(*) AS visitors, min(user_id) AS first, max(user_id) AS last
FROM read_csv('/tmp/trail_shoes_segment.csv');
-- 418 | v00022 | v22497
> stage 07 · history

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.

sql
COPY (SELECT *, ts::DATE AS day FROM events
WHERE ts < TIMESTAMP '2026-11-06 00:00:00')
TO '/tmp/clickstream' (FORMAT parquet, PARTITION_BY (day), COMPRESSION zstd, APPEND);
-- COPY 155116
SELECT sum(num_rows) AS events, sum(file_size_bytes) AS bytes,
round(sum(file_size_bytes) / sum(num_rows)::numeric, 1) AS bytes_per_event
FROM parquet_file_metadata('/tmp/clickstream/*/*.parquet');
-- 155116 | 1077870 | 6.9
SELECT event, count(*) AS events, sum(revenue) AS revenue_usd
FROM read_parquet('/tmp/clickstream/*/*.parquet', hive_partitioning = true)
WHERE day = DATE '2026-11-05' AND event IN ('click', 'purchase')
GROUP BY event ORDER BY event;
-- click | 1135 | NULL
-- purchase | 158 | 15317.00
A diagram of seven numbered stages. Impressions and clicks arrive from the ad server with COPY over the PostgreSQL protocol on port 7890; page views, searches, carts and purchases arrive from the site's collector as INSERTs. They are stored in two tables: events, with one row per event, and creatives, with each creative's headline and embedding. Inverted indexes cover the search terms, page titles and headlines as text through the ad_text dictionary, event names, campaigns, publishers and visitor ids as exact terms, timestamps as ranges, and the embeddings as an IVF vector index. Queries count events per hour with time_bucket and FILTER, rebuild sessions with lag() over 30-minute gaps, credit each purchase to the last ad click with ASOF JOIN, find audiences by searching what visitors typed with ts_levenshtein and BM25, and find similar creatives by embedding distance. Grafana reads the results through its PostgreSQL data source, COPY writes audience segments to CSV files, and a scheduled job copies old events to zstd Parquet files and deletes them.

What clickstream analytics needs from one engine

events · creatives
rows
Every event is a row

Impressions, 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 event
asof
Last click, by time

ASOF JOIN pairs each purchase with the visitor's last click before it, without a window function.

p.ts >= c.ts
typos
Typos still match

Site searches match within an edit distance, so "trail sheos" lands in the trail shoes audience.

ts_levenshtein('shoes', 1)
searchbench
A billion log rows

SearchBench, 92 queries over 1B OpenTelemetry log rows: all 92 under the 60 s cap, median 31.0 ms.

otel_logs_1b · 26.09.1
SearchBench on otel_logs_1b →

Build it

Tutorial: 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.

step 01 / 05 · shell
docker run -d --name serenedb -p 7890:7890 \
  -e POSTGRES_PASSWORD=<password> serenedb/serenedb:26.11.2

psql -h localhost -p 7890 -U postgres
Any PostgreSQL client connects the same way, on port 7890.
step 02 / 05 · sql
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'));
Two tables, then the dictionary and the two inverted indexes. ad_text lowercases and splits text into words, so ts_levenshtein compares whole words.
step 03 / 05 · sql
-- 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;
146,916 impressions, 1,135 clicks, 5,514 page views, 998 searches, 395 carts and 158 purchases. Each row's pseudo-random numbers come from its id, so every run loads the same day; VACUUM (REFRESH_TABLE) makes the new rows searchable at once.
step 04 / 05 · sql
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;
deals-feed.example's best hour reaches 5.66 % and its sessions bounce 86.7 % of the time. 698 visitors clicked a trail_shoes ad and 26 bought.
step 05 / 05 · sql
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';
$$;
40 purchases follow an ad click; weather-now.example returns 8.6 times its spend. The segment file holds 418 visitor ids. The job runs every day at 02:00 UTC and moves events older than 30 days to Parquet, so this day stays in the table for now.

Use cases

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.

built with
ASOF JOINmediantime_bucket
Real-time analytics →
example · share of clicks within 2 s of the impression
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;
result
publisherhourclickswithin_2s_pctmedian_s
deals-feed.example2026-11-05 14:00:0032999.71
run-club.example2026-11-05 14:00:0024031
run-club.example2026-11-05 16:00:0022033
deals-feed.example's 329 clicks from 14:00 to 15:00 came a median 1 second after the impression, 99.7 % of them within 2 seconds; the next hours on the list took 31 and 33 seconds.

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.

built with
<->FLOAT[4]ivf
Vector search →
example · archived creatives nearest to cr-102
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;
result
creativestatusheadlinedistance
cr-182archivedWaterproof gear for wet trail runs, 15% off this week0.300
cr-091archivedTrail shoes that drain fast after river crossings0.557
cr-092archivedFree returns on every trail shoe, all season0.587
cr-182 is the nearest at 0.300: waterproof, trail and an offer, as in cr-102. The vectors have four hand-written dimensions so the distances stay readable; a creative library fills them from an embedding model.

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.

built with
approx_count_distinctcount(DISTINCT)GROUP BY
OLAP database →
example · reach and frequency per campaign
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;
result
campaignimpressionsreachreach_exactfrequency
trail_shoes8122018811193564.32
rain_jackets6569617984184633.65
trail_shoes reached about 18,800 visitors, 4.32 times each; the estimate is 2.8 % under the exact 19,356. rain_jackets reached about 18,000, 3.65 times each.

FAQ

Frequently asked questions

Go to docs →

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

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.