
Привет, Хабр! На связи Антон Прыгин, аналитик данных в Garage Eight. Расскажу, как с помощью простых SQL-запросов и базовых математических методов получилось построить систему ежедневного мониторинга и алертинга маркетинговых метрик, которая работает в связке с таск-трекером.
Как возник запрос от бизнеса
Команда маркетинга ежедневно следит за метриками регистраций и активаций продуктов, и раньше изменения контролировались через дашборды. Чтобы упростить процесс и анализировать сами графики только во время резких пиков и спадов, мы решили найти более быстрый и удобный способ.
В результате выбрали мониторинг метрик с алертингом в Slack — в нем каждый день появляется информация о том, есть ли аномалии в метриках.
Сбор исходного датасета
Для начала я проанализировал существующие данные и построил графики, чтобы выявить закономерности всплесков и провалов.

Мы хотели создать достаточно простой инструмент для алертинга, поэтому при поиске подходящих методов я опирался на базовые математические приемы, которые впоследствии и использовал в алгоритме.
Z-score
Немного теории: z-score — стандартизированная оценка, показывает значение отклонения параметра от его среднего значения. Определяется по следующей формуле: где X — среднее значение, Sx — стандартное отклонение для набора данных. |
С точки зрения контроля метрик z-score помогает обнаруживать аномалии на ранних стадиях, например резкий рост отказов или падение трафика.
Как я действовал при работе с z-score:
Импортировал данные из базы данных и форматировал: добавил определение квартала и дня недели, классификацию на будни и выходные.
Определил период, для которого будет рассчитываться отклонение параметра. В нашем случае это две недели — оптимальный баланс между чувствительностью и устойчивостью. Меньшее количество времени добавило бы больше шума и реакции на случайные пики, а большее — отсутствие реакции на короткие изменения.
Задал необходимые для анализа переменные:
prev_day_registrations — количество регистраций на предыдущий день.
cumulative_quarter — суммарное количество регистраций за квартал.
AVG_registrations — среднее значение регистраций за последние 13 дней (исключая текущий).
daily_plan и cumulative_plan — целевые значения для ежедневных и кумулятивных регистраций.
lower_bound и upper_bound — границы целевых значений.
delta — изменение регистраций по сравнению с предыдущим днем.
STDDEV_registrations — стандартное отклонение регистраций за те же 13 дней.
positive_limit и negative_limit — границы, определяющие, что является нормальным уровнем регистраций с учетом стандартного отклонения. При расчете границ использовали среднее значение регистраций с разбросом в два стандартных отклонения количества регистраций.
И сам z-score =)
В SQL это выглядит следующим образом:
SELECT
day,
'registrations' AS metric,
day_of_week,
country,
registrations,
prev_day_registrations,
delta,
AVG_registrations + k * STDDEV_registrations AS positive_limit,
AVG_registrations - k * STDDEV_registrations AS negative_limit,
(registrations - AVG_registrations) / STDDEV_registrations AS z_score,
cumulative_quarter,
daily_plan AS daily_registrations,
cumulative_plan AS cumulative_registrations,
lower_bound,
upper_bound
FROM (
SELECT
day,
day_of_week,
country,
registrations,
LAG(registrations) OVER (ORDER BY day) AS prev_day_registrations,
(registrations / LAG(registrations) OVER (ORDER BY day)) - 1 AS delta,
AVG(registrations) OVER (PARTITION BY day_type ORDER BY day ROWS BETWEEN 13 PRECEDING AND 1 PRECEDING) AS AVG_registrations,
STDDEV(registrations) OVER (PARTITION BY day_type ORDER BY day ROWS BETWEEN 13 PRECEDING AND 1 PRECEDING) AS STDDEV_registrations,
2 AS k,
SUM(registrations) OVER (PARTITION BY quarter) AS cumulative_quarter,
plan.daily_target AS daily_plan,
plan.cumulative_target AS cumulative_plan,
plan.lower_bound,
plan.upper_bound
FROM (
SELECT
DATE_TRUNC(registered_at, QUARTER) AS quarter,
DATE(registered_at) AS day,
FORMAT_TIMESTAMP('%A', registered_at) AS day_of_week,
IF(FORMAT_TIMESTAMP('%A', registered_at) IN ('Monday','Tuesday','Wednesday','Thursday','Friday'), 'workday', 'weekend') AS day_type,
country,
COUNT(DISTINCT user_id) AS registrations
FROM `project.dataset.user_events`
WHERE registered_at >= '2024-01-01'
GROUP BY 1, 2, 3, 4, 5
) base
LEFT JOIN `project.dataset.registration_plans` plan
ON base.day = plan.date
)
Если значение выходит за позитивную или негативную границу, то это аномалия. Я прописал это условие в запросе и стал накладывать на графики, чтобы выяснить, насколько качественно только z-score определяет алерты.

По визуализациям стало понятно, что одного метода недостаточно, и тогда я обратился к еще одному базовому приему.
Перцентили
Перцентиль — это значение, которое заданная случайная величина не превышает с фиксированной вероятностью, заданной в процентах. |
В нашем алгоритме если 90% перцентиль по регистрациям составляет десять тысяч, то в 90% случаев за день регистрируется не более этого количества пользователей.
Я начал рассчитывать перцентили для каждого показателя, сравнивать на графиках и подбирать наиболее подходящий. После проверки 90 и 95% наиболее точным оказался 99%.
Для этой метрики я брал период в 28 дней. В отличие от z-score, перцентильный подход не зависит от нормальности распределения, и 99 и 1% перцентили помогают зафиксировать крайние значения — отсечь «хвосты» графика. Период в 28 дней позволяет собрать больше данных и использовать метод для стратегического мониторинга: он менее чувствителен к шуму и лучше подходит для метрик с редкими, но сильными всплесками, такими как инциденты на выходных.
Запрос выглядел так:
registrations_percentile_data AS (
SELECT
r1.day,
PERCENTILE_CONT(r2.registrations, 0.99) OVER (PARTITION BY r1.day) AS p99_rate,
PERCENTILE_CONT(r2.registrations, 0.01) OVER (PARTITION BY r1.day) AS p01_rate
FROM registrations r1
JOIN registrations r2
ON r2.day BETWEEN r1.day - INTERVAL 28 DAY AND r1.day
),
До этого отдельно z-score был точен на 65–80% в зависимости от метрики — в периоде 14 дней появлялись ошибки. Отдельно перцентили были точны в 50–60% случаев в зависимости от показателя — в более длинном периоде 28 дней небольшие пики и провалы терялись. Когда мы объединили методы, точность определения алертов составила уже около 99%. Я понял, что подход корректный, и написал аналогичные запросы для метрик активации.
activations AS (
SELECT
day,
'activations' AS metric,
day_of_week,
country,
activations,
prev_day_activations,
delta,
AVG_activations + k * STDDEV_activations AS positive_limit,
AVG_activations - k * STDDEV_activations AS negative_limit,
(activations - AVG_activations) / STDDEV_activations AS z_score,
cumulative_quarter,
daily_plan AS daily_activations,
cumulative_plan AS cumulative_activations,
lower_bound,
upper_bound
FROM (
SELECT
day,
day_of_week,
country,
activations,
LAG(activations) OVER (ORDER BY day) AS prev_day_activations,
(activations / LAG(activations) OVER (ORDER BY day)) - 1 AS delta,
AVG(activations) OVER (PARTITION BY day_type ORDER BY day ROWS BETWEEN 13 PRECEDING AND 1 PRECEDING) AS AVG_activations,
STDDEV(activations) OVER (PARTITION BY day_type ORDER BY day ROWS BETWEEN 13 PRECEDING AND 1 PRECEDING) AS STDDEV_activations,
2 AS k,
SUM(activations) OVER (PARTITION BY quarter) AS cumulative_quarter,
plan.daily_target AS daily_plan,
plan.cumulative_target AS cumulative_plan,
plan.lower_bound,
plan.upper_bound
FROM (
SELECT
DATE_TRUNC(activated_at, QUARTER) AS quarter,
DATE(activated_at) AS day,
FORMAT_TIMESTAMP('%A', activated_at) AS day_of_week,
IF(FORMAT_TIMESTAMP('%A', activated_at) IN ('Monday','Tuesday','Wednesday','Thursday','Friday'), 'workday', 'weekend') AS day_type,
country,
COUNT(DISTINCT user_id) AS activations
FROM `project.dataset.activation_events`
WHERE activated_at >= '2024-01-01'
AND activation_index = 0
GROUP BY 1, 2, 3, 4, 5
) base
LEFT JOIN `project.dataset.activation_plans` plan
ON base.day = plan.date
)
),
activations_percentile_data AS (
SELECT
a1.day,
PERCENTILE_CONT(a2.activations, 0.99) OVER (PARTITION BY a1.day) AS p99_rate,
PERCENTILE_CONT(a2.activations, 0.01) OVER (PARTITION BY a1.day) AS p01_rate
FROM activations a1
JOIN activations a2
ON a2.day BETWEEN a1.day - INTERVAL 28 DAY AND a1.day
)
Далее нужно было решить, использовать ли оба метода или остановиться только на перцентилях. Я выбрал применять оба, так как они работают комплексно: z-score — для быстрого, чувствительного алертинга, когда метрики просели в текущий момент, а перцентили — для устойчивого контроля и понимания границ нормы за длительный период.
Вместе они дают и широту, и глубину — возможность отслеживать и резкие скачки, и выбросы, которых в целом не должно быть.
Настроил мониторинг так, что, если появляется алерт по одному из методов, это некритичная аномалия. Если же срабатывают оба алерта, то изменения значимы, и команде маркетинга стоит разобраться, с чем они связаны.
SELECT
DISTINCT
r.day,
r.metric,
r.registrations AS daily_fact,
ROUND((r.registrations / r.daily_registrations) * 100, 2) AS daily_percent_to_plan,
r.daily_registrations AS daily_plan,
ROUND(r.delta * 100, 2) AS percent_to_prev_day,
CASE WHEN r.registrations > r.positive_limit AND r.z_score > 2 THEN 1 ELSE 0 END AS stat_positive_alert,
CASE WHEN r.registrations < r.negative_limit AND r.z_score < -2 THEN 1 ELSE 0 END AS stat_negative_alert,
CASE WHEN r.registrations > p99_rate THEN 1 ELSE 0 END AS p99_alert,
CASE WHEN r.registrations < p01_rate THEN 1 ELSE 0 END AS p01_alert,
r.cumulative_quarter AS quarter_fact,
ROUND(r.cumulative_quarter / r.cumulative_registrations * 100, 2) AS quarter_percent_to_plan,
r.cumulative_registrations AS quarter_plan,
CAST(ROUND(reg_to_act, 2) AS STRING) AS reg_to_act,
CAST(ROUND(((reg_to_act / prev_day_cr) - 1) * 100, 2) AS STRING) AS percent_to_prev_cr
FROM registrations r
JOIN `project.dataset.reg_to_act_cr` cr USING(day)
JOIN registrations_percentile_data p USING(day)
UNION ALL
SELECT
DISTINCT
a.day,
a.metric,
a.activations AS daily_fact,
ROUND((a.activations / a.daily_activations) * 100, 2) AS daily_percent_to_plan,
a.daily_activations AS daily_plan,
ROUND(a.delta * 100, 2) AS percent_to_prev_day,
CASE WHEN a.activations > a.positive_limit AND a.z_score > 2 THEN 1 ELSE 0 END AS stat_positive_alert,
CASE WHEN a.activations < a.negative_limit AND a.z_score < -2 THEN 1 ELSE 0 END AS stat_negative_alert,
CASE WHEN a.activations > p99_rate THEN 1 ELSE 0 END AS p99_alert,
CASE WHEN a.activations < p01_rate THEN 1 ELSE 0 END AS p01_alert,
a.cumulative_quarter AS quarter_fact,
ROUND(a.cumulative_quarter / a.cumulative_activations * 100, 2) AS quarter_percent_to_plan,
a.cumulative_activations AS quarter_plan,
'' AS reg_to_act,
'' AS percent_to_prev_cr
FROM activations a
JOIN activations_percentile_data p USING(day)
ORDER BY day DESC
Так с помощью достаточно небольших запросов мы собрали таблицу дневных метрик и их статус аномалии.
Интеграция с таск-трекером
Алгоритм можно интегрировать с разными трекерами и мессенджерами. Чтобы связать сервисы у нас, мы обратились к data-инженерам, которые настроили взаимосвязь по API, я же написал запрос для корректного отображения результатов.
WITH data_preparation AS (
SELECT
-- Символы статуса по дневному плану
CASE
WHEN D_percent_to_Plan < 60 THEN ':red_circle:'
WHEN D_percent_to_Plan < 90 THEN ':yellow_circle:'
ELSE ':green_circle:'
END AS D_plan_symbol,
-- Символы статуса по квартальному плану
CASE
WHEN Q_percent_to_Plan < 60 THEN ':red_circle:'
WHEN Q_percent_to_Plan < 90 THEN ':large_yellow_circle:'
ELSE ':large_green_circle:'
END AS Q_plan_symbol,
day,
UPPER(metric) AS metric,
D_fact,
D_percent_to_plan,
D_Plan,
percent_to_prev_day,
Q_FACT,
Q_percent_to_plan,
Q_Plan,
REG_to_ACT,
percent_to_prev_CR_Reg_to_Act,
-- Определение текста алерта
CASE
WHEN P99_alert = 1 AND stat_possitive_alert = 1 THEN ":alert: Обнаружена статистическая аномалия - значительное превышение объема для данного дня недели :alert:"
WHEN P01_alert = 1 AND stat_negative_alert = 1 THEN ":alert: Обнаружена статистическая аномалия - значительное снижение объема для данного дня недели :alert:"
WHEN stat_possitive_alert = 1 THEN ":alert: Объем для текущего дня недели значительно выше нормы за последние 2 недели"
WHEN stat_negative_alert = 1 THEN ":alert: Объем для текущего дня недели значительно ниже нормы за последние 2 недели"
WHEN P99_alert = 1 THEN ":alert: Объем превышает 99% значений за последние 28 дней"
WHEN P01_alert = 1 THEN ":alert: Объем ниже 1% значений за последние 28 дней"
ELSE "Отклонений не выявлено"
END AS ALERT
FROM project_id.analytics_metrics_summary
WHERE day = DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
ORDER BY metric
)
),
registrations_D AS (
SELECT
'FFFFFF' AS color,
'' AS text,
metric AS title,
D_plan_symbol || " Day plan/fact: " || D_percent_to_Plan || "%" ||
"\n - Day current fact: " || D_fact ||
"\n - Day plan: " || D_Plan ||
"\n - % To previous Day: " || percent_to_prev_day || "%" ||
"\n - CR Reg to Act: " || REG_to_ACT || "% (" || percent_to_prev_CR_Reg_to_Act || ")" ||
"\n - " || ALERT AS value
FROM data_preparation
WHERE LOWER(metric) = 'registrations'
),
registrations_Q AS (
SELECT
'FFFFFF' AS color,
'' AS text,
'' AS title,
Q_plan_symbol || " Quarter plan/fact: " || Q_percent_to_Plan || "%" ||
"\n - Quarter current fact: " || Q_FACT ||
"\n - Quarter plan: " || Q_Plan AS value
FROM data_preparation
WHERE LOWER(metric) = 'registrations'
),
activations_D AS (
SELECT
'FFFFFF' AS color,
'' AS text,
metric AS title,
D_plan_symbol || " Day plan/fact: " || D_percent_to_Plan || "%" ||
"\n - Day current fact: " || D_fact ||
"\n - Day plan: " || D_Plan ||
"\n - % To previous Day: " || percent_to_prev_day || "%" ||
"\n - " || ALERT AS value
FROM data_preparation
WHERE LOWER(metric) = 'activations'
),
activations_Q AS (
SELECT
'FFFFFF' AS color,
'' AS text,
'' AS title,
Q_plan_symbol || " Quarter plan/fact: " || Q_percent_to_Plan || "%" ||
"\n - Quarter current fact: " || Q_FACT ||
"\n - Quarter plan: " || Q_Plan AS value
FROM data_preparation
WHERE LOWER(metric) = 'activations'
)
-- Основной блок формирования итогового сообщения (для мессенджера)
SELECT *
FROM (
SELECT
'CHANNEL_ID_PLACEHOLDER' AS channel_field,
'DATE: ' || DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY) AS message_field,
TO_JSON_STRING(ARRAY_AGG(STRUCT(color, text, title, value))) AS attachments
FROM (
SELECT * FROM (
SELECT 1 AS rn, color, text, ARRAY_AGG(title) AS title, ARRAY_AGG(value) AS value FROM registrations_D GROUP BY 1,2,3
UNION ALL
SELECT 2, color, text, ARRAY_AGG(title), ARRAY_AGG(value) FROM registrations_Q GROUP BY 1,2,3
UNION ALL
SELECT 3, color, text, ARRAY_AGG(title), ARRAY_AGG(value) FROM activations_D GROUP BY 1,2,3
UNION ALL
SELECT 4, color, text, ARRAY_AGG(title), ARRAY_AGG(value) FROM activations_Q GROUP BY 1,2,3
-- Места для добавления других метрик
)
ORDER BY rn
)
)
WHERE attachments NOT LIKE '%null%'
В результате мы получаем уведомление с большим, но емким и понятным объемом информации:
Количество регистраций за день и соответствие ежедневному плану.
Изменение в количестве регистраций по сравнению с прошлым периодом.
Соответствие количества регистраций квартальному плану.
Количество активаций за день и соответствие ежедневному плану.
Изменение в количестве активаций по сравнению с прошлым периодом.
Соответствие количества активаций квартальному плану.

Заключение
Стоит отметить, что решение не идеальное, но качественное — я стремился сделать простой и работающий инструмент, чтобы вовремя следить за изменениями. Теперь им пользуется не только наша команда маркетинга, но и коллеги из смежных отделов. Все просят добавить собственные метрики, поэтому эта статья появилась в том числе для того, чтобы каждый самостоятельно мог это сделать =)
Пользуйтесь и задавайте вопросы в комментариях!