Каталог событий
В этом деле начать нужно с заведения справочника событий, в том числе собиравшихся ранее. Настройка нового события должна предваряться занесением его в этот каталог. Такой список служит очевидным целям:
- Хранить метаданные событий — в каких случаях они генерируются, в какой части сайта, кто заказчик события, когда оно начало собираться, когда сбор прекратился.
- Согласовывать разметку событий и ТЗ на их сбор между продактами, аналитиками, разработчиками и другими акторами.
- Проверять полноту данных по каждому событию, чтобы обнаружить пропажу.
Наша таблица с событиями имеет такие поля:
- insert_date — дата внесения события в таблицу.
- finish_date — заполняется, если событие временное, то есть заранее известно, когда оно перестанет генерироваться (например, для А/В-теста).
- event_category — категория события, справочное поле для удобства группировки событий. Например: Accounts — группа событий из раздела «Аккаунты» в личном кабинете.
- event — обозначение события, которое, собственно, и передается в базу данных. Обычно мы закладываем в него иерархический путь к действию, инициирующему событие. Например: Accounts — Statistics by Accounts — Filter and Search — Click Apply.
- event_description — описание события: что это за действие и где его может выполнить пользователь.
- parameters — параметры или свойства события, если они необходимы. Указываются через запятую, например: ad_system, title.
- examples — типовые значения свойств или их полный перечень. Например: «ad_system: [ВКонтакте|Яндекс Директ|Google Ads| …], title: название аккаунта».
- parameter_description — описание параметров события.
- task — ссылка на задачу настройки события в таск-трекере.
- Заказчик/отдел — кто заказал событие.
Постановка задачи
Возможны четыре проблемы, о которых мы хотим своевременно узнавать:
- Нового события нет в БД. Прошел месяц с постановки задачи разработчикам на его настройку, но никаких данных о событии так и не пришло.
- Данные по событию поступают без требуемых параметров.
- Данные по событию перестали поступать.
- Данные по параметрам события не всегда полные.
Разберем эти случаи отдельно.
Нет нового события
Это актуально, если настройку события на сайте осуществляют не аналитики, а другая команда. Аналитики ставят задачу, дату постановки заносят в insert_date и спокойно забывают об этом. Следить за исполнением задачи будет запланированный скрипт, который ежедневно запускает запрос:
SELECT event_category, event, start_date, task
FROM
(
-- события, которые передаются разработке для разметки на сайте
SELECT toDate(parseDateTimeBestEffortOrNull(insert_date)) AS start_date,
event_category, event, task
FROM datamart.events_dictionary -- справочник событий
) dict
-- убираем события, по которым данные пошли
ANTI LEFT JOIN
(
SELECT DISTINCT event_name
FROM datamart.user_events -- события в аналитическом хранилище
) events ON dict.event = events.event_name
WHERE start_date + INTERVAL 1 MONTH <= today() -- Даем исполнителям месяц на настройку события
OR start_date IS NULL
Если результат не пуст, скрипт сигнализирует об этом в корпоративный мессенджер. А далее дежурному аналитику нужно:
- Проверить в user_events, действительно ли нет такого события. Возможно, не совпадает наименование? Разработчики могли ошибиться с именем. Тогда достаточно исправить имя события в каталоге на реальное, а лучше — добиться правильной отправки событий (хотя уже отправленные не изменятся, их придется править вручную или подменять при обращении к ним).
- Если данных по новому событию действительно нет, то по задаче из поля task находится и оповещается заказчик. Если событие еще в работе у разработки, меняется insert_date на текущую дату (в ячейке оставляется комментарий, какая была первоначальная дата занесения), и тем самым исполнителям дается еще один месяц.
Нет параметров события
Положим, что события складываются к нам в аналитическое хранилище в таблицу datamart.user_events, причем его параметры хранятся в поле data в виде JSON-словаря. Тогда нам нужно выявить события, у которых есть параметры, которые никогда не приходили заполненными, приблизительно таким запросом:
SELECT event, parameters_upd, task, start_date,
-- подсчитываем заполненные параметры, указанные в справочнике событий
COUNT(nullIf(JSONExtractString(data, parameters_upd), '')) AS value_data_count
FROM
(
SELECT event_name, `data`, toDate(stream_date) AS date
FROM datamart.user_events -- данные о событиях
) AS events
JOIN
(
SELECT event, task,
toDate(parseDateTimeBestEffortOrNull(insert_date)) AS start_date,
-- разбиваем параметры из справочника на отдельные строки
arrayJoin(splitByRegexp('([, \n]+)', assumeNotNull(parameters))) AS parameters_upd
FROM datamart.events_dictionary -- справочник событий
WHERE parameters_upd != ''
-- берем только актуальные события
AND (toDate(parseDateTimeBestEffortOrNull(finish_date)) AS last_date IS NULL
OR last_date >= yesterday())
) AS dict ON events.event_name = dict.event
GROUP BY ALL
HAVING value_data_count=0
Запрос выдаст события и параметры, по которым ни разу не пришло непустого значения, а также задачу и дату постановки задачи на настройку события. Действия дежурного аналитика при появлении варнинга аналогичны таковым для первого случая.
Данные по событиям перестали поступать
А здесь придется действовать хитрее. Мы не знаем, как часто случается событие, и его отсутствие еще не означает, что имеет место инцидент. Нам придется немного погрузиться в статистику и вычислить допустимое количество дней без данных для каждого события. Для этого мы разделим события на 4–5 паттернов по частоте их появления и определим, сколько дней после отсутствия данных можно не беспокоиться, а когда начинать бить тревогу. Вот запрос для определения порога предупреждения и критического порога:
SELECT event_name, median_interval, max_interval, p90_interval,
-- Классификация событий по регулярности на основе медианного интервала
-- Определяет паттерн поступления события для дальнейшего расчета порогов
CASE
WHEN median_interval <= 1.2 THEN 'daily' -- События, происходящие почти каждый день
WHEN median_interval <= 3 THEN 'frequent' -- Частые события (2-3 раза в неделю)
WHEN median_interval <= 7 THEN 'weekly' -- Еженедельные события
WHEN median_interval <= 14 THEN 'bi_weekly' -- События раз в две недели
ELSE 'rare' -- Редкие события (реже раза в две недели)
END AS event_pattern,
-- Адаптивный порог для предупреждений (warning)
-- Рассчитывается индивидуально для каждого паттерна событий
CASE
WHEN event_pattern = 'daily' THEN 2 -- Ежедневные: тревога после 2 дней отсутствия
WHEN event_pattern = 'frequent' THEN round(p75_interval + 2) -- Частые: 75-й перцентиль + 2 дня буфер
WHEN event_pattern = 'weekly' THEN round(p90_interval + 1) -- Еженедельные: 90-й перцентиль + 1 день
WHEN event_pattern = 'bi_weekly' THEN round(p95_interval + 2) -- Двухнедельные: 95-й перцентиль + 2 дня
ELSE 30 -- Для редких событий: фиксированный порог 30 дней
END AS warning_threshold,
-- Адаптивный порог для критических алертов
-- Более высокие значения, чем warning, для эскалации проблем
CASE
WHEN event_pattern = 'daily' THEN 5 -- Критично после 5 дней отсутствия
WHEN event_pattern = 'frequent' THEN round(p90_interval + 3) -- 90-й перцентиль + 3 дня
WHEN event_pattern = 'weekly' THEN round(p95_interval + 3) -- 95-й перцентиль + 3 дня
WHEN event_pattern = 'bi_weekly' THEN round(max_interval * 1.2) -- 120% от максимального интервала
ELSE 60 -- Для редких событий: 60 дней
END AS critical_threshold,
-- Дата обновления порогов для отслеживания актуальности
today() AS update_date
FROM
(
-- Расчет статистических показателей интервалов между событиями
SELECT event_name
,round(median(days_since_last_event), 1) AS median_interval -- Медианный интервал (дней)
,max(days_since_last_event) AS max_interval -- Максимальный интервал
,quantile(0.75)(days_since_last_event) AS p75_interval -- 75-й перцентиль
,quantile(0.90)(days_since_last_event) AS p90_interval -- 90-й перцентиль
,quantile(0.95)(days_since_last_event) AS p95_interval -- 95-й перцентиль
FROM
(
-- Расчет интервалов между последовательными событиями
SELECT event_name, event_date,
-- Вычисляем количество дней с момента предыдущего события того же типа
greatest(
date_diff('day',
lagInFrame(event_date, 1, event_date) OVER (
PARTITION BY event_name
ORDER BY event_date
),
event_date
) - 1, -- Вычитаем 1, т. к. события в один день = интервал 0
0
) AS days_since_last_event
FROM
(
-- Выборка уникальных дат событий за последний год
SELECT DISTINCT event_type,
toDate(stream_date) AS event_date -- Преобразование timestamp в дату
FROM datamart.user_events AS db -- Таблица с потоковыми событиями пользователей
-- Присоединяем справочник для фильтрации активных событий
JOIN datamart.events_dictionary AS dict -- Справочник типов событий из Kafka
ON dict.event = db.event_type
-- Берем события за последние 12 месяцев до вчерашнего дня
WHERE event_date BETWEEN today() - INTERVAL 12 MONTH AND yesterday()
-- Фильтруем только активные события (без даты окончания или окончание в будущем)
AND (toDate(parseDateTimeBestEffortOrNull(finish_date)) AS last_date IS NULL
OR last_date >= yesterday())
)
)
GROUP BY ALL
) AS interval_stats
Теперь можно сравнить длительность отсутствия события с допустимым порогом. А если порог превышен, выдается соответствующее сообщение. Запрос для такой проверки:
SELECT ev.event_name AS event_name
,last_event_date -- дата последнего появления события
,days_since_last -- количество дней с последнего появления
,warning_threshold -- порог предупреждения
,critical_threshold -- критический порог (инцидент)
,CASE
WHEN critical_threshold IS NULL OR warning_threshold IS NULL THEN 'NEW EVENT'
WHEN days_since_last >= critical_threshold THEN 'CRITICAL'
WHEN days_since_last >= warning_threshold THEN 'WARNING'
ELSE 'OK'
END AS alert_level -- тип оповещения
,CASE
WHEN alert_level = 'NEW EVENT' THEN CONCAT('Новое событие: ',
toString(first_event_date), ' первые данные')
WHEN alert_level = 'CRITICAL' THEN CONCAT('Критично: ',
toString(days_since_last), ' дней без данных (норма: до ', toString(critical_threshold), ')')
WHEN alert_level = 'WARNING' THEN CONCAT('Внимание: ',
toString(days_since_last), ' дней без данных (норма: до ', toString(warning_threshold), ')')
ELSE CONCAT('Норма: последнее событие ', toString(days_since_last), ' дней назад')
END AS status_description -- Описание проблемы. Например: 'Критично: 8 дней без данных (норма: до 5)'
,update_date AS score_update_date -- дата перерасчета порогов (мы обновляем их раз в неделю)
,today() AS dq_date_check -- Дата проверки (сегодняшняя)
FROM datamart.event_observability_score AS metrics -- таблица с порогами
RIGHT JOIN
(
-- все события, по которым была статистика в последний год
SELECT event_name
,max(toDate(stream_date)) AS last_event_date
,min(toDate(stream_date)) AS first_event_date -- минимальная дата за последний год
,date_diff('day', last_event_date, today()) AS days_since_last
FROM datamart.user_events AS db
JOIN datamart.events_dictionary AS dict
ON dict.event = db.event_name
WHERE toDate(stream_date) BETWEEN today() - INTERVAL 12 MONTH AND yesterday()
-- Берем только актуальные
AND (toDate(parseDateTimeBestEffortOrNull(finish_date)) AS last_date IS NULL OR last_date >= yesterday())
GROUP BY ALL
) AS ev ON metrics.event_name = ev.event_name
WHERE alert_level != 'OK' -- Берем только проблемные события
В результате имеем три типа оповещений:
- NEW EVENT — данные по новому событию только появились, пороговые значения еще не рассчитаны.
- WARNING — предупреждение, данные по событию отсутствуют дольше, чем обычно.
- CRITICAL — инцидент, данные по событию отсутствуют неприемлемо долго.
Данные по параметрам события перестали поступать
Действуем аналогично третьему кейсу, но применительно не к событиям, а к их параметрам:
- Рассчитываем допустимые пороги отсутствия заполненных параметров. Только вместо количества дней используем долю событий с заполненным параметром в общем числе данных событий с этим параметром.
- Сравниваем текущую долю заполненных параметров с порогом и при необходимости оповещаем.
Расчет порогов:
SELECT event AS event_name, parameters
,round(median(not_null_percent_param_per_week), 1) AS median_interval
,min(not_null_percent_param_per_week) AS min_interval
,quantile(0.25)(not_null_percent_param_per_week) AS p25_interval
,quantile(0.10)(not_null_percent_param_per_week) AS p10_interval
-- Классификация параметра события по регулярности
,CASE
WHEN median_interval = 0 AND min_interval = 0 THEN 'Не настроен'
WHEN median_interval <= 35 THEN 'Редкий'
WHEN median_interval <= 75 THEN 'Периодический'
WHEN median_interval <= 95 THEN 'Частый'
WHEN median_interval <= 100 THEN 'Постоянный'
ELSE NULL
END AS event_pattern
-- Адаптивные пороги
,CASE
WHEN event_pattern = 'Постоянный' THEN round(median_interval * 0.97, 2)
WHEN event_pattern = 'Частый' THEN round(p25_interval * 0.97, 2)
WHEN event_pattern = 'Периодический' THEN round(p25_interval * 0.90, 2)
WHEN event_pattern = 'Редкий' THEN round(p25_interval * 0.80, 2)
ELSE 0
END AS warning_threshold
,CASE
WHEN event_pattern = 'Постоянный' THEN round(median_interval * 0.95, 2)
WHEN event_pattern = 'Частый' THEN round(p25_interval * 0.95, 2)
WHEN event_pattern = 'Периодический' THEN round(p25_interval * 0.85, 2)
WHEN event_pattern = 'Редкий' THEN round(p25_interval * 0.70, 2)
ELSE 0
END AS critical_threshold
,today() AS update_date
FROM
(
SELECT event, parameters, week
,count(*) AS total_amount
,countIf(event, value_data IS NOT NULL) AS not_null_amount
,round(divide(not_null_amount, total_amount) * 100, 2) AS not_null_percent_param_per_week
,first_param_date
FROM
(
-- Начало недели — это вс (так работает toStartOfWeek без доп. параметра).
-- Срез по неделе — с вс по сб. Обновление этой витрины делаем каждое вс.
-- Получаем, что в каждое вс в витрине данные по сб, последняя неделя получается полной и не искажает статистику.
SELECT event, parameters_upd AS parameters
,toStartOfWeek(date) AS week
,nullIf(JSONExtractString(data, parameters_upd), '') AS value_data
,minIf(date, value_data IS NOT NULL) OVER(PARTITION BY event, parameters_upd) AS first_param_date
FROM
(
SELECT event_name, data
,toDate(stream_date) AS date
FROM datamart.user_events
WHERE date BETWEEN today() - INTERVAL 6 MONTH AND yesterday()
) AS ev
JOIN
(
-- Все актуальные события с параметрами
SELECT event
,arrayJoin(splitByRegexp('([, \n]+)', assumeNotNull(parameters))) AS parameters_upd
FROM datamart.events_dictionary
WHERE parameters_upd != ''
AND (toDate(parseDateTimeBestEffortOrNull(finish_date)) AS last_date IS NULL OR last_date >= yesterday())
) AS dict ON ev.event_name = dict.event
)
-- Некоторые параметры разработка добавляет после того, как статистика по событию уже пошла.
-- Чтобы исключить попадание нулевых значений в срез для расчета квантилей, применяем фильтр ниже.
-- Т. е. мы можем потерять пороги по новому параметру события на первой неделе, на второй он появится.
WHERE week > first_param_date
OR first_param_date IS NULL
GROUP BY ALL
)
GROUP BY ALL
И проверка доли событий с заполненным параметром:
SELECT ev.event AS event_name, ev.parameters AS parameters
,not_null_percent_param_per_current_week
,warning_threshold
,critical_threshold
,CASE
WHEN critical_threshold IS NULL OR warning_threshold IS NULL THEN 'NEW EVENT PARAMETER'
WHEN not_null_percent_param_per_current_week < critical_threshold THEN 'CRITICAL'
WHEN not_null_percent_param_per_current_week < warning_threshold THEN 'WARNING'
ELSE 'OK' -- сюда же относим event_pattern = 'Не настроен'
END AS alert_level
,CASE
WHEN alert_level = 'NEW EVENT PARAMETER' THEN CONCAT('Новый параметр: ',
toString(min_param_date), ' первые данные')
WHEN alert_level = 'CRITICAL' THEN CONCAT('Критично: ',
toString(not_null_percent_param_per_current_week),
' % заполненности параметра (норма: до ', toString(critical_threshold), ' %)')
WHEN alert_level = 'WARNING' THEN CONCAT('Внимание: ',
toString(not_null_percent_param_per_current_week),
' % заполненности параметра (норма: до ', toString(warning_threshold), ' %)')
ELSE CONCAT('Норма: ', toString(not_null_percent_param_per_current_week),
' % заполненности параметра')
END AS status_description
,update_date AS score_update_date
,today() AS dq_date_check
FROM datamart.event_parameter_observability_score AS metrics -- таблица с порогами для параметров
RIGHT JOIN
(
SELECT event, parameters_upd AS parameters
,min(date) AS min_param_date
,count(*) AS total_amount
-- количество строк с заполненным параметром
,countIf(event, nullIf(JSONExtractString(data, parameters_upd), '') IS NOT NULL) AS current_not_null_amount
-- доля заполненных
,round(divide(current_not_null_amount, total_amount) * 100, 2) AS not_null_percent_param_per_current_week
FROM
( -- Статистика событий за неделю (по вчера)
SELECT event_name, `data` -- JSON с параметрами события
,toDate(stream_date) AS date
FROM datamart.user_events
WHERE date BETWEEN today() - INTERVAL 1 WEEK AND yesterday()
) AS ev
JOIN
( -- Все актуальные события с параметрами из справочника
SELECT event
,arrayJoin(splitByRegexp('([, \n]+)', assumeNotNull(parameters))) AS parameters_upd
FROM datamart.events_dictionary -- справочник событий
WHERE parameters_upd != ''
AND (toDate(parseDateTimeBestEffortOrNull(finish_date)) AS last_date IS NULL OR last_date >= yesterday())
) AS dict ON ev.event_type = dict.event
GROUP BY ALL
) AS ev ON metrics.event_name = ev.event
AND metrics.parameters = ev.parameters
WHERE alert_level != 'OK'
Резюме
Мы рассмотрели четыре случая некачественности данных в событиях веб-аналитики, а также способы отследить эти проблемы:
- События не поступают долгое время после постановки задачи на их настройку. Для мониторинга считаем количество появлений события. Если их ноль более месяца с постановки задачи — даем сигнал.
- То же для параметров событий. События приходят, но параметры не заполнены. Опять же, считаем количество заполненных и реагируем на пустые.
- Событие перестало поступать. На основе уже имеющихся данных о событии выясняем его допустимую частоту и допустимое количество пропущенных дней. Если последний пропуск больше порога — даем сигнал.
- Параметр события поступает с перебоями или перестал поступать. Рассчитываем допустимую долю событий с заполненным параметром и сверяем с ней текущее значение этой доли. Если оно ниже порогового — сигнализируем о проблеме.