Event Analytics Foundations
Work through examples, try the reasoning yourself, then put it into practice.
View SlidesBefore you start
- Use SELECT, GROUP BY, timestamps and CTEs.
What you will practice
- Derive session boundaries with one consistent window order.
- Select a coherent as-of event after applying the cutoff.
In this lesson
Lesson
5 min readEvent Analytics: From Rows to Sessions and Snapshots
A support engineer asks: "When was this user active?" and "What state was this device in at midnight?" Both start with events, but their output grains differ. Sessionization combines events into one row. A snapshot chooses one eligible event per device. Define the grain before choosing SQL syntax.
Preparation and outcomes
You should know SELECT, WHERE, GROUP BY, COUNT, and timestamp subtraction.
A CTE (WITH name AS (...)) names an intermediate query for inspection. A
window function preserves input rows while calculating across related rows;
a grouped aggregate collapses them.
You will trace session boundaries, place cutoff filters correctly, and handle ties. Complete Event Sessions, then Latest Device State, then the event checkpoint.
1. Derive the session state
One output row is one (user_id, session_index) session. The first event starts
a session; a gap strictly greater than 30 minutes starts another. Compare
adjacent events, not every event with the session start.
For one user, predict the final two columns before reading them:
| event_id | time | gap from previous | is_new | session_index |
|---|---|---|---|---|
| 11 | 09:00 | none | 1 | 1 |
| 12 | 09:30 | 30 minutes | 0 | 1 |
| 13 | 10:00 | 30 minutes | 0 | 1 |
| 14 | 10:31 | 31 minutes | 1 | 2 |
| 15 | 10:31 | 0 minutes | 0 | 2 |
The first session lasts an hour: a chain of short gaps can form a long session.
Duplicate timestamps still represent separate events and both count. The
second session has two events and equal start/end times. Restart the index at
1 for another user; this is the purpose of PARTITION BY user_id.
2. Build inspectable stages
Keep event_id through each window stage. Use the same unique ascending
(event_ts, event_id) order in both windows so tied rows stay consistent.
First attach the preceding timestamp:
LAG(event_ts) OVER (
PARTITION BY user_id ORDER BY event_ts, event_id
) AS previous_ts
In a second CTE, compute a boundary flag:
CASE
WHEN previous_ts IS NULL THEN 1
WHEN event_ts - previous_ts > INTERVAL '30 minutes' THEN 1
ELSE 0
END AS is_new
In a third stage, number sessions using the prefix sum of flags:
SUM(is_new) OVER (
PARTITION BY user_id ORDER BY event_ts, event_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_index
The invariant: the running total is the number of session starts seen for that
user. ROWS makes the frame a row-by-row prefix. Without the shared tie breaker,
the two windows can disagree about which tied event came first. An outer sort
changes presentation, not window computation. Named stages also avoid trying
to nest one window call directly inside another.
Only now group by (user_id, session_index) and compute the minimum timestamp,
maximum timestamp, and count. Grouping before detecting gaps loses the
sequence. Inspect each stage on the table above: incorrect flags indicate a
gap/order/partition problem; correct flags but wrong totals suggest grouping.
3. Select a coherent as-of row
Now one output row is one device with an event at or before the cutoff. Every output attribute must come from the same selected event.
| event_id | recorded_at (UTC) | status |
|---|---|---|
| 21 | May 31, 23:50 | offline |
| 22 | June 1, 00:00 | online |
| 23 | June 1, 00:00 | error |
| 24 | June 1, 00:01 | online |
With the inclusive June 1 midnight cutoff, remove event 24 before ranking.
Events 22 and 23 tie on time. This course explicitly chooses the larger
event_id, so event 23 wins. This policy makes the answer reproducible; an ID
does not establish which physical event really occurred later.
ROW_NUMBER() OVER (
PARTITION BY device_id
ORDER BY recorded_at DESC, event_id DESC
) AS rn
Put recorded_at <= TIMESTAMPTZ '2025-06-01 00:00:00+00' in the input query;
select rn = 1 in an outer query. Ranking the whole history first gives event
24 rank 1. Removing it later leaves no rank-1 row for the device instead of
falling back to event 23.
Independent MAX(status), MAX(customer_id), and MAX(recorded_at) can combine
values from different events into a row that never existed. Select a complete
ranked row instead. A device with only future events is absent: this task has
no separate device master table to preserve.
4. Reconstruct before submitting
- For times 08:00, 08:30, 09:01, 09:01, how many sessions and events per session?
- Why can final
ORDER BY device_idnot resolve ties inside a device? - What changes if the cutoff becomes exclusive?
Answers: (1) Two sessions of two events; only the 31-minute gap splits.
(2) Row selection happens inside the window and needs its own tie breaker.
(3) The input comparison becomes <; recency order stays the same.
The next module adds empty entities and repeated graph paths to this same habit: define the output row, then trace how input rows become that result.
Further reading
PostgreSQL's window tutorial explains partitions, frames, and filtering before windows. The event-ID policy above is an explicit contract of these exercises.
Practice sequence
Use the order below for a guided path, or choose an item to revisit. A pass records a successful attempt; try a fresh example later to check what you can reconstruct.
- Step 1
Event Sessions
- Step 2
Latest Device State
- Step 3
Checkpoint: Event Boundaries and Snapshots
Trace new event histories and diagnose window-query mistakes. Select one option per multiple-choice question; enter only the requested integer for short answers.