метрикисессиипродуктаналитика

Метрики сессий: длительность, глубина и сессионизация в SQL

2026-07-12 11 мин

Сессия — это непрерывный отрезок активности одного пользователя, который обрывается паузой: как только человек ничего не делает дольше выбранного таймаута (по традиции — 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-инструмента, открытого весь рабочий день, наоборот, коротко. Поэтому таймаут я всегда фиксирую как параметр запроса, а не как магическую константу, — к тому, как выбрать своё число, вернусь в конце.

Какие метрики сессий важны?

Из готовых сессий я почти всегда смотрю на четыре показателя, и каждый отвечает на свой вопрос.

Ни одна из этих метрик не «хорошая» или «плохая» сама по себе — смысл появляется только в контексте продукта, о чём ниже. Сначала — как всё это посчитать руками.

Как сессионизировать поток событий в 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 занижается, глубина раздувается).

Как выбирать по-человечески: посмотрите на распределение разрывов между соседними событиями. Почти всегда оно бимодальное — плотное облако маленьких пауз внутри визита, затем провал, затем длинные паузы между визитами. Таймаут логично ставить в этот провал, а не в круглое число из дефолтов чужого инструмента.

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;

И ещё несколько граблей, на которые я наступал:

Какие ошибки чаще всего ломают метрики сессий?

Короткий чек-лист, который я мысленно прогоняю перед тем, как показать цифры по сессиям кому-то ещё. Среднее вместо медианы на скошенном распределении. Не отфильтрованные боты и служебный трафик, раздувающие длительность. Дубли событий, из-за которых глубина завышена, — дедуп по (user_id, event_time, event_name) спасает. Нулевая длительность одноэлементных сессий, попавшая в среднее. Разные часовые пояса в одном запросе. И сравнение метрик через границу, где менялось само определение сессии.

Сессионизация — из тех навыков, которые прямо влияют на грейд продуктового аналитика: её просят на собеседованиях и ждут, что вы напишете оконный запрос без подсказок. Если готовитесь к переходу, посмотрите, как это отражается на зарплате и как выстроить подготовку к собеседованию вокруг таких задач.

Лучший способ закрепить — прогнать запрос из этой статьи на своих или тестовых данных, поменять таймаут и посмотреть, как поплывут метрики. Первые задачи на сессионизацию в тренажёре открыты бесплатно; полный банк оконных задач, кейсов и разборов доступен в Pro — если хотите довести навык до автоматизма перед собесом, это самый короткий путь.

Связанная тема — Сессионизация событий в SQL: оконные функции.

Отработай SQL на практике
545 SQL-задач с автопроверкой — первые открыты без регистрации.
SQL-тренажёр →