Sessionizing Events: Grouping Activity Into Sessions by Idle Gap

Sessionizing turns a pile of timestamped events into visits, using an idle gap as the dividing line. Compare each event with the one before it for the same user. If the gap is too long, a new session starts.

Asparagus shoots growing in close groups separated by a broad patch of bare soil

Clicks do not come with a visit ID

Your product manager asks how many visits the app had yesterday. You look at the table. It has a user, a timestamp, and nothing else. Nobody logged a “visit started” event, so the visits have to be inferred.

The usual rule is an idle gap. If a user goes quiet for more than 30 minutes, the next click belongs to a new visit. Thirty is a choice, not a law. Some apps need less, and some need an explicit logout event. Decide first which events count as activity, because a background heartbeat will keep every visit alive forever.

DROP TABLE IF EXISTS #Activity;

CREATE TABLE #Activity (EventId int NOT NULL PRIMARY KEY, UserId int NOT NULL, EventAt datetime2 NOT NULL);

INSERT #Activity VALUES
    (1, 10, '2025-01-01T09:00:00'),
    (2, 10, '2025-01-01T09:10:00'),
    (3, 10, '2025-01-01T10:00:00'),
    (4, 20, '2025-01-01T09:05:00'),
    (5, 20, '2025-01-01T09:35:00');

User 10 has two quiet stretches. User 20 has one gap of exactly 30 minutes, which is the interesting one.

Flag each new session with LAG

LAG reads the previous event time inside each user. The first event has no predecessor, so it starts a session. Any later event starts a new one if the previous event is more than 30 minutes older. The ORDER BY uses the timestamp and then EventId, so two events at the same instant always sort the same way.

WITH PreviousEvents AS
(
    SELECT EventId, UserId, EventAt,
           LAG(EventAt) OVER (PARTITION BY UserId ORDER BY EventAt, EventId) AS PreviousAt
    FROM #Activity
)
SELECT EventId, UserId, EventAt, PreviousAt,
       DATEDIFF_BIG(second, PreviousAt, EventAt) AS IdleSeconds,
       CASE WHEN PreviousAt IS NULL OR PreviousAt < DATEADD(minute, -30, EventAt)
            THEN 1 ELSE 0 END AS NewSession
FROM PreviousEvents
ORDER BY UserId, EventAt, EventId;

Events 1 and 4 are the first for their users, so both are flagged. Event 3 follows a 3000-second gap and starts a second session. Event 5 follows exactly 1800 seconds, which is not more than 30 minutes, so it stays in the same session.

Notice that I compare timestamps with DATEADD instead of using DATEDIFF in minutes. DATEDIFF counts boundaries crossed, not time elapsed.

SELECT DATEDIFF(minute, '2025-01-01T09:00:59', '2025-01-01T09:01:01') AS MinutesCounted;

Two seconds apart, and it says 1 minute, because the clock ticked over a minute mark. Near a threshold, that kind of answer decides who gets a new session.

Number the sessions with a running sum

The flags are 1 or 0. A running sum of the flags, per user, turns them into session numbers: 1, 1, 2 for user 10. The running sum cannot sit inside the same SELECT as the flag calculation, so each step gets its own CTE. The numbers restart for every user.

CREATE TABLE #Sessions
(UserId int, SessionNumber int, SessionStart datetime2, LastActivity datetime2, EventCount int);

WITH PreviousEvents AS
(
    SELECT EventId, UserId, EventAt,
           LAG(EventAt) OVER (PARTITION BY UserId ORDER BY EventAt, EventId) AS PreviousAt
    FROM #Activity
),
Flagged AS
(
    SELECT UserId, EventAt,
           CASE WHEN PreviousAt IS NULL OR PreviousAt < DATEADD(minute, -30, EventAt)
                THEN 1 ELSE 0 END AS NewSession,
           EventId
    FROM PreviousEvents
),
Numbered AS
(
    SELECT UserId, EventAt,
           SUM(NewSession) OVER (PARTITION BY UserId ORDER BY EventAt, EventId
                                 ROWS UNBOUNDED PRECEDING) AS SessionNumber
    FROM Flagged
)
INSERT #Sessions (UserId, SessionNumber, SessionStart, LastActivity, EventCount)
SELECT UserId, SessionNumber, MIN(EventAt), MAX(EventAt), COUNT(*)
FROM Numbered
GROUP BY UserId, SessionNumber;

SELECT UserId, SessionNumber, SessionStart, LastActivity, EventCount
FROM #Sessions
ORDER BY UserId, SessionNumber;
Result grids showing idle-gap flags and grouped activity sessions
Top: the idle-gap flags. Bottom: the sessions. The 1800-second gap stays in one session; the 3000-second gap starts another.

Three sessions come back. User 10 has two, one with two events and one with a single event. User 20 has one session with two events. Session 1 for user 10 and session 1 for user 20 are different visits.

From raw events to numbered sessions

Name the duration you actually measured

How long was each visit? Be careful with the answer. First event to last event is the observed span. It does not include time spent reading after the final click.

SELECT UserId, SessionNumber,
       DATEDIFF_BIG(second, SessionStart, LastActivity) AS ObservedSeconds,
       DATEADD(minute, 30, LastActivity) AS InferredExpiry
FROM #Sessions
ORDER BY UserId, SessionNumber;

The single-event session has an observed span of 0 seconds, which surprises people. Keep last activity and inferred expiry as separate columns if you need both, and decide which one the report publishes.

Late events and report boundaries

Real data arrives late. A late event can bridge two sessions into one, or push a session start earlier. Reprocess a window larger than the idle gap, and do not assume arrival order matches event order.

Report boundaries bite too. If you filter to today before running LAG, the first event after midnight looks like a new visit every day. Pull in enough earlier history to know whether it continues one.

DROP TABLE IF EXISTS #Sessions;
DROP TABLE IF EXISTS #Activity;

Keep a few awkward cases in your tests: equal timestamps, an exact-threshold gap, and a lonely single event.

A session number is not a logged fact, it is a grouping rule.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Activity Monitor, SQL Extended Events, SQL Server
Previous Post
SQL SERVER – Fix: Logical Name Mismatch Between Catalog Views sys.master_files and sys.database_files
Next Post
SQL SERVER – What is Wait Type Parallel Backup Queue?

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.