Сессия — это непрерывный отрезок активности одного пользователя, который обрывается паузой: как только человек ничего не делает дольше выбранного таймаута (по традиции — 30 минут), его следующее действие открывает новую сессию. Из этого одного правила вырастают четыре метрики, которые я считаю почти на каждом продукте: сколько сессий приходится на пользователя, сколько они длятся, насколько глубокие (событий за визит) и какая доля обрывается сразу — bounce. И всё это добывается из сырого потока событий одним оконным запросом. Дальше — как именно, и где чаще всего всё ломается.
Что такое сессия и зачем её считать?
В базе у вас почти наверняка лежит таблица событий примерно такого вида:
-- events
-- user_id | event_time | event_name | screen
-- 101 | 2026-07-10 09:14:02 | open | home
-- 101 | 2026-07-10 09:14:20 | click | search
-- 101 | 2026-07-10 09:15:41 | view_item | product
-- 101 | 2026-07-10 13:02:10 | open | home
Отдельное событие почти ничего не говорит о поведении. DAU считает уникальных людей за день, но пользователь, который зашёл один раз на 10 секунд, и тот, кто вернулся пять раз и что-то купил, в этой метрике неотличимы. Сессия — это единица «визита»: она склеивает подряд идущие события в осмысленный кусок и даёт говорить на языке продукта — «сколько раз человек к нам приходит и что успевает сделать за раз».
Пользователя 101 из примера выше при таймауте 30 минут мы разрежем на две сессии: три события утром и одно днём, потому что между 09:15:41 и 13:02:10 прошло почти четыре часа. Именно сессии, а не сырые события, потом ложатся в воронки, в retention и в оценку того, «залипает» продукт или нет. Как это соотносится с базовой метрикой активной аудитории, я разбирал в заметке про DAU.
Почему стандартный таймаут — 30 минут?
Тридцать минут — наследство ранней веб-аналитики, и живёт оно до сих пор по инерции. Логика за числом простая: пауза должна быть достаточно длинной, чтобы человек успел прочитать длинную статью или отойти за кофе и вернуться в тот же визит, и достаточно короткой, чтобы возвращение спустя несколько часов не приклеилось к предыдущему заходу.
Важно понимать: это соглашение, а не закон природы. Никакой физики за «тридцатью» нет — это удобная средняя точка, которую когда-то зашили в дефолты, и все привыкли. Для утилитарного сервиса, где человек забежал на 40 секунд за ответом, тридцать минут — целая вечность. Для B2B-инструмента, открытого весь рабочий день, наоборот, коротко. Поэтому таймаут я всегда фиксирую как параметр запроса, а не как магическую константу, — к тому, как выбрать своё число, вернусь в конце.
Какие метрики сессий важны?
Из готовых сессий я почти всегда смотрю на четыре показателя, и каждый отвечает на свой вопрос.
- Сессий на пользователя — частота возвращений,
число сессий / число уникальных пользователейза период. Это прокси стикинесса: растёт — продукт входит в привычку, падает — люди приходят реже. Рядом полезно держать отношениеDAU / MAU, но сессии дают более честную «частоту визитов». - Длительность — время от первого до последнего события внутри сессии. Медиана здесь важнее среднего: распределение всегда с длинным правым хвостом от забытых вкладок и фоновых процессов.
- Глубина — сколько событий или уникальных экранов человек прошёл за визит. Показывает, доходит ли пользователь до сути или отваливается на входе.
- Bounce (отказ) — доля сессий с единственным событием (или очень коротких). Пришёл, ничего не сделал, ушёл.
Ни одна из этих метрик не «хорошая» или «плохая» сама по себе — смысл появляется только в контексте продукта, о чём ниже. Сначала — как всё это посчитать руками.
Как сессионизировать поток событий в SQL?
Классический приём в три шага, который спрашивают на собеседованиях чуть ли не в каждой второй продуктовой команде: LAG достаёт время предыдущего события, CASE ставит флаг начала новой сессии, а нарастающая сумма флагов превращается в порядковый номер сессии.
WITH gaps AS (
SELECT
user_id,
event_time,
event_name,
LAG(event_time) OVER (
PARTITION BY user_id ORDER BY event_time
) AS prev_time
FROM events
),
flagged AS (
SELECT
user_id,
event_time,
event_name,
CASE
WHEN prev_time IS NULL
OR event_time - prev_time > INTERVAL '30 minutes'
THEN 1 ELSE 0
END AS is_new_session
FROM gaps
)
SELECT
user_id,
event_time,
event_name,
SUM(is_new_session) OVER (
PARTITION BY user_id ORDER BY event_time
ROWS UNBOUNDED PRECEDING
) AS session_num
FROM flagged;
Разберём по шагам. В gaps для каждого события внутри пользователя берём время предыдущего — это и есть окно. В flagged ставим единицу, когда предыдущего события нет вовсе (самое первое действие человека) или когда разрыв больше таймаута. Дальше — ключевой трюк: SUM(...) OVER (... ORDER BY event_time) считает нарастающий итог этих единиц. Пока разрывы маленькие, сумма не меняется и все события несут один session_num; на каждом «большом разрыве» она увеличивается на единицу — то есть номер сессии инкрементится ровно там, где начинается новый визит.
Дальше из user_id и session_num собирается стабильный ключ сессии — например user_id::text || '-' || session_num. Если оконные функции пока кажутся магией, разберитесь с LAG и SUM OVER в SQL-справочнике, а сам запрос удобно гонять на живых данных в SQL-тренажёре — там эта задача есть в нескольких вариациях.
Длительность, глубина и bounce из готовых сессий
Когда у каждого события проставлен session_num, всё остальное — обычная агрегация с группировкой по паре «пользователь + номер сессии».
WITH sessionized AS (
-- запрос из предыдущего раздела
...
)
SELECT
user_id,
session_num,
MIN(event_time) AS started_at,
MAX(event_time) AS ended_at,
EXTRACT(EPOCH FROM MAX(event_time) - MIN(event_time)) AS duration_sec,
COUNT(*) AS depth
FROM sessionized
GROUP BY user_id, session_num;
Поверх этого одним запросом сворачиваются продуктовые агрегаты, включая bounce как долю сессий из одного события:
SELECT
COUNT(*) AS sessions,
COUNT(DISTINCT user_id) AS users,
ROUND(COUNT(*)::numeric
/ COUNT(DISTINCT user_id), 2) AS sessions_per_user,
ROUND(AVG(depth), 2) AS avg_depth,
ROUND(AVG((depth = 1)::int), 3) AS bounce_rate
FROM session_stats;
Тот же алгоритм на pandas — почти дословно, diff вместо LAG, cumsum вместо нарастающей суммы:
df = df.sort_values(["user_id", "event_time"])
gap = df.groupby("user_id")["event_time"].diff()
df["is_new_session"] = (gap > pd.Timedelta(minutes=30)) | gap.isna()
df["session_num"] = df.groupby("user_id")["is_new_session"].cumsum()
sessions = df.groupby(["user_id", "session_num"]).agg(
started_at=("event_time", "min"),
ended_at=("event_time", "max"),
depth=("event_name", "size"),
)
sessions["duration_sec"] = (
sessions["ended_at"] - sessions["started_at"]
).dt.total_seconds()
bounce_rate = (sessions["depth"] == 1).mean()
Тут прячется первый неприятный сюрприз: у сессии из одного события duration_sec равен нулю. Мы фиксируем время только по событиям, а сколько человек реально смотрел на единственный экран до закрытия — неизвестно, «выхода» в логах нет. Поэтому среднюю длительность я обычно считаю по сессиям глубже одного события, иначе метрика механически занижена. Собрать pandas-версию и поиграть с таймаутом можно в Python-тренажёре.
На что смотреть в продукте, а не в вакууме
Первое, от чего предостерегаю: «дольше сессия = лучше» — это ловушка. Для поисковика или утилиты короткая сессия часто означает успех — человек мгновенно нашёл ответ и ушёл довольным. Для контентного или соцпродукта, наоборот, вовлечённость обычно хорошо. Прежде чем радоваться росту длительности, спросите себя, что этот рост значит именно для вашего сценария.
Дальше — то, на что я смотрю почти всегда:
- Медиана, а не среднее. Распределение длительности и глубины скошено: боты, забытые вкладки и фоновые сессии раздувают среднее. Медиана и перцентили честнее, а хвост стоит смотреть отдельно.
- Сегменты, а не общее число. Средний bounce по всему продукту бесполезен. Разбейте сессии по точке входа, платформе, новый или вернувшийся пользователь — и станет видно, что отказ живёт на конкретном лендинге или на одной ОС.
- Глубина против конверсии. Полезно проверить, как растёт вероятность целевого действия с глубиной сессии: где кривая выходит на плато, там обычно и лежит «достаточный» онбординг.
- Частота против retention. Число сессий на пользователя хорошо коррелирует с удержанием: люди, приходящие чаще, почти всегда остаются дольше. Это связка, которую любят спрашивать на собеседованиях — типовые разборы есть в разделе вопросы с интервью и на страницах конкретных компаний.
В чём подвох с выбором таймаута?
Тот самый разговор, который я отложил. Тридцать минут — дефолт, а не истина, и слепо взятый таймаут тихо искажает все метрики сразу. Слишком короткий рвёт один визит на несколько (длительность падает, число сессий взлетает), слишком длинный — склеивает разные заходы (bounce занижается, глубина раздувается).
Как выбирать по-человечески: посмотрите на распределение разрывов между соседними событиями. Почти всегда оно бимодальное — плотное облако маленьких пауз внутри визита, затем провал, затем длинные паузы между визитами. Таймаут логично ставить в этот провал, а не в круглое число из дефолтов чужого инструмента.
SELECT
width_bucket(
EXTRACT(EPOCH FROM event_time - prev_time) / 60,
0, 120, 24
) AS gap_bucket_5min,
COUNT(*) AS events
FROM gaps
WHERE prev_time IS NOT NULL
GROUP BY 1
ORDER BY 1;
И ещё несколько граблей, на которые я наступал:
- Мобильные приложения. Здесь визит естественнее резать по уходу в фон и возврату (
app_background/app_foreground), а не по абстрактной паузе — человек может держать приложение открытым часами. - Полночь и часовой пояс. Некоторые системы принудительно начинают новую сессию в полночь или при смене UTM-метки, из-за чего один визит двоится. И всё считайте в едином часовом поясе, иначе «ночные» сессии съедут.
- Смена определения ретроспективно. Если вы поменяли таймаут, все исторические метрики сессий сдвинулись — сравнивать «до» и «после» напрямую нельзя. Число фиксируется один раз и документируется.
Какие ошибки чаще всего ломают метрики сессий?
Короткий чек-лист, который я мысленно прогоняю перед тем, как показать цифры по сессиям кому-то ещё. Среднее вместо медианы на скошенном распределении. Не отфильтрованные боты и служебный трафик, раздувающие длительность. Дубли событий, из-за которых глубина завышена, — дедуп по (user_id, event_time, event_name) спасает. Нулевая длительность одноэлементных сессий, попавшая в среднее. Разные часовые пояса в одном запросе. И сравнение метрик через границу, где менялось само определение сессии.
Сессионизация — из тех навыков, которые прямо влияют на грейд продуктового аналитика: её просят на собеседованиях и ждут, что вы напишете оконный запрос без подсказок. Если готовитесь к переходу, посмотрите, как это отражается на зарплате и как выстроить подготовку к собеседованию вокруг таких задач.
Лучший способ закрепить — прогнать запрос из этой статьи на своих или тестовых данных, поменять таймаут и посмотреть, как поплывут метрики. Первые задачи на сессионизацию в тренажёре открыты бесплатно; полный банк оконных задач, кейсов и разборов доступен в Pro — если хотите довести навык до автоматизма перед собесом, это самый короткий путь.
Связанная тема — Сессионизация событий в SQL: оконные функции.