Привет, Хабр! На связи Антон Прыгин, аналитик данных в 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%'

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

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

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

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

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

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

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

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

Заключение

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

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

Комментарии (0)