Как настроить ежедневный алертинг по маркетинговым метрикам с помощью SQL

от автора

Привет, Хабр! На связи Антон Прыгин, аналитик данных в Garage Eight. Расскажу, как с помощью простых SQL-запросов и базовых математических методов получилось построить систему ежедневного мониторинга и алертинга маркетинговых метрик, которая работает в связке с таск-трекером. 

Как возник запрос от бизнеса

Команда маркетинга ежедневно следит за метриками регистраций и активаций продуктов, и раньше изменения контролировались через дашборды. Чтобы упростить процесс и анализировать сами графики только во время резких пиков и спадов, мы решили найти более быстрый и удобный способ. 

В результате выбрали мониторинг метрик с алертингом в Slack — в нем каждый день появляется информация о том, есть ли аномалии в метриках. 

Сбор исходного датасета

Для начала я проанализировал существующие данные и построил графики, чтобы выявить закономерности всплесков и провалов. 

График мониторинга обычно выглядит так: есть и заметные пики, и глобальные провалы. Алгоритм должен достаточно точно на ежедневной основе определять разницу значений

График мониторинга обычно выглядит так: есть и заметные пики, и глобальные провалы. Алгоритм должен достаточно точно на ежедневной основе определять разницу значений

Мы хотели создать достаточно простой инструмент для алертинга, поэтому при поиске подходящих методов я опирался на базовые математические приемы, которые впоследствии и использовал в алгоритме. 

Z-score

Немного теории: z-score — стандартизированная оценка, показывает значение отклонения параметра от его среднего значения.

Определяется по следующей формуле: 

где X — среднее значение, Sx — стандартное отклонение для набора данных.

С точки зрения контроля метрик z-score помогает обнаруживать аномалии на ранних стадиях, например резкий рост отказов или падение трафика. 

Как я действовал при работе с z-score:

  1. Импортировал данные из базы данных и форматировал: добавил определение квартала и дня недели, классификацию на будни и выходные. 

  2. Определил период, для которого будет рассчитываться отклонение параметра. В нашем случае это две недели — оптимальный баланс между чувствительностью и устойчивостью. Меньшее количество времени добавило бы больше шума и реакции на случайные пики, а большее — отсутствие реакции на короткие изменения.

  3. Задал необходимые для анализа переменные: 

  • 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 определяет алерты. 

Z-score справлялся не очень хорошо: метод выявляет не все значимые изменения. Зеленым выделены пропущенные и неправильно определенные места

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%'

В результате мы получаем уведомление с большим, но емким и понятным объемом информации: 

  • Количество регистраций за день и соответствие ежедневному плану.

  • Изменение в количестве регистраций по сравнению с прошлым периодом. 

  • Соответствие количества регистраций квартальному плану.

  • Количество активаций за день и соответствие ежедневному плану.

  • Изменение в количестве активаций по сравнению с прошлым периодом. 

  • Соответствие количества активаций квартальному плану.

Как выглядят уведомления (цифры подобраны для примера). Здесь видим, что с регистрациями всё хорошо, а вот количество активаций значительно выше нормы.

Как выглядят уведомления (цифры подобраны для примера). Здесь видим, что с регистрациями всё хорошо, а вот количество активаций значительно выше нормы.

Заключение

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

Пользуйтесь и задавайте вопросы в комментариях!


ссылка на оригинал статьи https://habr.com/ru/articles/920226/