Привет! Я — Лёша Белов, продуктовый аналитик в команде Отелей сервиса путешествий Туту. Рано или поздно в любом продукте встает вопрос о том, как успевать отлавливать аномалии в аналитических логах и метриках. В статье расскажу о нашем подходе к алертингу и поделюсь кодом, с помощью которого продуктовый аналитик может за пару часов самостоятельно настроить базовый алертинг.

Почему мы решили создать свою систему алертинга

Раньше мы в Туту часто узнавали о недостающих, пропущенных или аномальных данных спустя несколько дней (а иногда — недель) после возникновения аномалии. Это происходило почти случайно: мы либо сами натыкались на них в ходе регулярных аналитических задач, либо их приносили разработчики или QA, а мы дальше уже выясняли причины. 

Иллюстрация типичного кейса обработки аномалии — к продуктовому аналитику приходят разработчики, которые в ходе своей регулярной задачи видят «пропавшее» событие
Иллюстрация типичного кейса обработки аномалии — к продуктовому аналитику приходят разработчики, которые в ходе своей регулярной задачи видят «пропавшее» событие

Частично потребности аналитиков закрывает команда разработки. Обычно они могут настроить алертинг наших событий, но у этого подхода есть несколько минусов:

  • Мы хотим, чтобы именно команда продуктовой аналитики отвечала за исследование природы аномалий в данных. В таком случае между данными и исследователем нет посредника, который может упустить важные детали.

  • Алертинг разработки позволяет увидеть только очевидные аномалии в событиях.

  • Алертинг разработки не считает продуктовые метрики: GMV, revenue, DAU и другие.

Поэтому мы самостоятельно решили сделать инструмент, который позволял бы ежедневно и максимально оперативно узнавать об аномальных изменениях в продукте.

Что мы считаем аномалией

В качестве вдохновения мы использовали подход продуктовых аналитиков из Профи. В качестве MVP мы хотели видеть бота в корпоративном мессенджере (мы используем Zulip), который каждый день оповещает об аномалиях как в продуктовых фронтовых событиях, так в бизнес-метриках. 

И на этом этапе нужно проговорить, как мы для себя определили понятие «аномалия» и что мы считаем аномалией, а что — нет.

Классическое определение звучит так: 

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

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

Нас такой подход не совсем устраивал, так как он позволяет увидеть только очевидные и резкие скачки. При этом аномалией в зрелом продукте мы можем считать и относительно незначительное отклонение, которое не является выбросом в классическом понимании. Например, рост метрики GMV на 40% относительно того же дня неделю назад вызовет у нас интерес для мини-исследования, однако это не «аномалия» как таковая.

Поэтому мы решили пускать сообщение об аномалии в двух случаях:

  • Если продуктовое событие растет или падает относительно среднемесячного значения на более/менее чем 50%.

  • Если бизнес-метрика отклоняется от значения на прошлой неделе более/менее чем на 30%.

Такие критерии мы установили опытным путем в ходе тестирования алертинга на ретро-данных. 

Мы хотели:

  • Ловить все мало-мальски вызывающие подозрение приросты и падения.

  • Минимизировать ложноположительные срабатывания.

Также нам нужно учитывать все возможные срезы: по платформе, по типу трафика (органика, SEO и т.д), по способу оплаты заказа и другие.

Спойлер: спустя полгода использования такого подхода мы все еще получаем много алертов, которые присылают «органические» изменения. Например, много событий «стреляет» после раскатки А/В-теста на 100% аудитории вместо 50%. Сейчас нас такой расклад устраивает, но систему можно настроить более гибко.

Решение

Мы написали скрипт, который выгружает разные значения для двух типов метрик:

  • для событий — среднемесячные и вчерашние;

  • для бизнес-метрик — значения прошлой недели и вчерашние.

Так выглядит SQL-запрос для сбора событий:

Запрос для сбора событий
-- Считаем среднее количество событий в день за месяц
WITH month_avg AS (
    WITH agg_by_day AS (
        SELECT
            count(*) AS events,
            platform,
            event_name,
            toStartOfDay(time) AS date
        FROM (
            SELECT
                event_name,
                platform,
                time
            FROM table
            WHERE
                time >= now() - interval '31 day'
                and time < toDate(now())
                and object_type = 'event'
                AND time < now()
                AND event_name ilike '%hotel%'
        ) AS base_logs
        GROUP BY
            platform, event_name, date
    )
    SELECT
        avg(events) AS avg_events_month,
   	 event_name,
        platform
    FROM agg_by_day
    GROUP BY
        platform, event_name
),
 
-- Считаем события за вчера
yesterday_events AS (
    SELECT
        count(*) AS events_yesterday,
        paltform,
        toStartOfDay(time) AS date,
        event_name
    FROM (
        SELECT
            event_name,
            platform,
            time
        FROM table
        WHERE
                object_type = 'event'
                AND toStartOfDay(time) = toStartOfDay(now()) - interval '1 day'
                AND event_name ilike '%hotel%'
    ) AS base_logs
    GROUP BY
        platform, date, event_name
)
-- Объединяем всё
SELECT
    month_avg.event_name AS "Событие",
    month_avg.platform AS "Платформа",
    round(month_avg.avg_events_month, 0) AS "Среднемесячное значение",
    yesterday_events.events_yesterday AS "Вчерашнее значение",
    round((yesterday_events.events_yesterday - month_avg.avg_events_month) * 100.0 / month_avg.avg_events_month, 2) AS "Разница в % среднемесячное"
FROM month_avg
    JOIN yesterday_events ON month_avg.event_name = yes.event_name AND month_avg.platform = month_avg.platform

Похожим образом собираем код для конверсий, финансовых и аудиторных метрик.

Далее выгружаем полученный результат в питоновский DataFrame и проверяем на аномалии:

Проверка на аномалии
notifications = str('')
notifications_minus = str('')
events_per_platform = {}  # Словарь для хранения списков аномальных событий по каждому приложению -- понадобится для визуализации
anomalies_found = False
 
# Получаем уникальные значения из столбца 'Платформа'
unique_platforms = df['platform'].unique()
 
# Обработка каждого приложения
for app in unique_apps:
    events_per_platform[platform] = []  # Инициализация списка для аномальных событий данной платформы
    for index, row in df[(df['platform'] == platform) & ((df['Вчерашнее значение'] > 200) | (df['Среднемесячное значение'] > 100))].iterrows():
        if row['Разница в % среднемесячное'] > 50:
            anomalies_found = True
            # Добавляем заголовок только один раз
            if not notifications:
                notifications += f"# Положительные изменения в количестве событий за {yesterday}!\n"
            message = f"Событие **{row['Событие']}** аномально увеличилось на **{row['Разница в % среднемесячное']:.2f}%** относительно среднемесячного значения для **{platform}**"
            notifications += message + "\n"
            events_per_app[platform].append(row['Событие'])  # Добавляем событие в список для этого приложения
        elif row['Разница в % среднемесячное'] < -50:
            anomalies_found = True
            # Добавляем заголовок только один раз
            if not notifications_minus:
                notifications_minus += f"# Отрицательные изменения в количестве событий за {yesterday}!\n"
            message = f"Событие **{row['Событие']}** аномально уменьшилось на **{row['Разница в % среднемесячное']:.2f}%** относительно среднемесячного значения для **{platform}**"
            notifications_minus += message + "\n"
            events_per_platform[platform].append(row['Событие'])  # Добавляем событие в список для этой платформы

Отправляем текстовое уведомление в корпоративный мессенджер. На этой стадии вы можете воспользоваться документацией API любого интересующего вас мессенджера.

Отправляем уведомление
import zulip
client = zulip.Client(config_file="/Users/path_to_config_file")
 
def send_alert(message):
    request = {
        "type": "stream",
        "to": "alert hotel",
        "topic": "Мониторинг событий",
        "content": message,
    }
    result = client.send_message(request)
    return result
 
# Отправка алертов только если найдены аномалии и они не пустые
if anomalies_found:
    if notifications:
        send_alert(str(notifications))
    if notifications_minus:
        send_alert(str(notifications_minus))

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

Выгружаем данные по аномальным событиям из events_per_platform для каждого среза и визуализируем:

Код для визуализации
from dotenv import load_dotenv
load_dotenv('/path_to_file/filename.env')

# Получаем логин и пароль
username = os.getenv('CLICKHOUSE_USER')
password = os.getenv('CLICKHOUSE_PASSWORD')
client_clickhouse = connect_to_clickhouse(username, password)

ios = f"""
SELECT id,
       event_name,
       toDate(time) as date
FROM table
WHERE time >= now() - INTERVAL '11 day'
and time < toDate(now())
AND
event_name IN ({events_per_paltform['tutu_ios']})
and platform = 'ios'
"""
 
events_ios = load_data_clickhouse(ios, client_clickhouse)
 
 
if events_ios.shape != (0, 0):
    data = events_ios.groupby(['date', 'event_name'])['id'].count().reset_index()
 
    df = pd.DataFrame(data)
    df['date'] = pd.to_datetime(df['date'])  # Преобразуем столбец date в datetime формат
 
# Группируем данные
    grouped = df.groupby(['date', 'event_name']).agg({'session_id': 'sum'}).reset_index()
 
# Строим график
    plt.figure(figsize=(14, 7))
    for event_name in grouped['event_name'].unique():
        subset = grouped[grouped['event_name'] == event_name]
        plt.plot(subset['date'], subset['session_id'], marker='o', label=event_name)
 
    plt.title('Динамика изменения событий')
    plt.xlabel('Дата')
    plt.ylabel('Количество сессий')
    plt.legend(title='События')
    plt.xticks(rotation=45)
    plt.tight_layout()
    plt.savefig("ios_plot.png", format='png', dpi=300, bbox_inches='tight')
else: 'Нет данных'
 
 
# Список с именами файлов
yesterday = str(date.today() - timedelta(days=1))
files = ['ios_plot.png'] -- список, так как можно хранить несколько графиков для разных срезов
file_urls = []  # Список для хранения URL загруженных файлов
 
# Обработка всех файлов
for file_name in files:
        with open(file_name, "rb") as fp:
            result = client.upload_file(fp)
            file_urls.append(result["url"])  # Сохраняем URL загруженного файла
 
# Если загрузка хотя бы одного файла была успешной
if file_urls:
    # Формируем сообщение с ссылками на все загруженные изображения
    content = "[Данные по аномальным событиям iOD относительно среднемесячного значения]({})".format(file_urls[0]) + ' за ' + yesterday + "\n"
 
        # Отправка сообщения с ссылками на все изображения
    client.send_message(
        {
            "type": "stream",
            "to": "alert hotel",
            "topic": "Мониторинг событий",
            "content": content
        }
    )
else:
    'Нет данных'

На выходе получаем сообщение:

Типичный ежедневный отчет по аномальным событиям
Типичный ежедневный отчет по аномальным событиям

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

Но тут важно отметить, что большинство «аномалий» не получают в итоге должного внимания от аналитиков. Часто «отстреливают» редко используемые события, флуктуации, которые можно объяснить сезонными колебаниями, или просто недостаточно сильные изменения, чтобы делать по ним выводы. Но мы принимаем это условие: в нашем случае лучше перестраховаться и получить лишний алерт, чем упустить что-то важное.

Осталось только поставить скрипт на регулярное выполнение в cron. Для нас это была отдельная и большая дата-инженерная задача, но о ней расскажем как-нибудь в другой раз. 

Алертинг готов.

Какой результат мы получили

Теперь каждое утро продуктового аналитика в Отелях начинается с просмотра алертов и сообщений от лида:

Алертинг помогает нам оперативнее реагировать на сбои и проще отлавливать баги. А ещё теперь продуктовые аналитики полностью контролируют флуктуации метрик.

Но недостатки у нашего решения тоже есть:

  1. Скрипт недостаточно гибок по отношению к новым вводным. Если появляется новое событие, оно наверняка «стрельнет». Подключение нового поставщика или провайдера тоже повлияет на метрики, и нужно учитывать это в срезах.

  2. Доля ложных срабатываний все еще достаточно высока. Мы думаем над более совершенным алгоритмом, который будет выявлять аномалии, а не обращать внимание на относительную разницу «в лоб».

  3. Не совсем недостаток, а скорее пока что не выполненная задача: хотим повесить такой же алертинг на А/В-тесты, чтобы проверять, корректно ли запущен A/B, все ли тестируемые события «долетают» и тд. Но об этом расскажем в следующих статьях.

Репозиторий с проектом, который вы можете протестировать у себя, выложили на GitHub.

Надеюсь, что статья была полезна, буду рад ответить на вопросы.

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


  1. Nevermurr
    24.11.2025 11:50

    Хорошая статья!

    но я вижу две проблемы:

    1. Базовая линия неправильная

    Среднемесячное значение не учитывает выходные и праздники. Если в месяце 10 выходных, они искажают среднее. Рабочие дни могут иметь 2-3x выше трафика, поэтому выходной выглядит аномалией на -40-50%, хотя это норма.

    Мб сравнивать со средним для того же дня недели за 28 дней — сразу исчезнут ложные срабатывания???

    2. Нужны предсказательные пороги

    Жесткие ±50% не работают для разных метрик. Попробуйте Z-score: считайте среднее и сигму за 28 дней, срабатывайте на 2-3 сигма. Адаптируется автоматически к волатильности каждой метрики.


    1. aleksebel Автор
      24.11.2025 11:50

      Спасибо за фидбек!

      1. Подход с со значением того же дня недели мы использем для алертинга как раз аудиторных метрик (например, DAU) и метрик заказов. Там действительно есть ярко выраженная недельная сезонность. Но ситуация с ивентами немного другая -- много событий могут достаточно волатильно себя вести, и среднее среднемесячное характеризует событие, как выяснилось, лучше, чем значения предыдущего дня недели в плане количества ложных срабатываний

      2. Сигмы, к сожалению, слишком жесткий порог, где мы уже ошибку 2 рода будем совершать, то есть, не отловим аномалию, там где она есть, потому что достаточно редко аномалия характеризуется настолько высоким выбросом. Иногда это постепенный рост/падение (когда на какой-то одной версии приложения что-то отвалилось, например)


  1. IvanoDigital
    24.11.2025 11:50

    Так и не понял чего меряют и зачем.. и почему это называется "продуктовая" аналитика?