Двадцать пятого июня мой сторож бюджета отработал ровно так, как я его написал. И именно поэтому за день ушло 224,98 евро при жёстком пороге в 150.

2026-06-25T17:55:31.185Z | сегодня 224.98 EUR (ad-periods today) | soft 120 hard 150
состояние сохранено {"date":"2026-06-25","metric":"spent_v3","baseline":null,
                     "alertedSoft":true,"alertedHard":true,"authDead":false}

Читается эта строка неприятно. alertedSoft: true и alertedHard: true означают, что оба уведомления ушли. authDead: false означает, что связь с рекламным кабинетом была живая, то есть остановить кампании технически было можно в любую секунду. Система знала о проблеме, имела рычаг и не дёрнула его, потому что я её такой написал.

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

Контекст: что вообще крутится

Я веду рекламу в Telegram Ads для проекта с мебелью из Китая. Кабинет большой, объявлений в нём около девятнадцати тысяч, руками там делать нечего. Поверх него у меня стоит своя обвязка на Node: набор .mjs-скриптов на маленьком VPS, лежат в /opt/tgbooster-api, состояние и логи в /var/log/tgbooster/. Запускаются частью по cron, частью руками через ssh.

Скрипты примерно такие: степпер, который двигает ставки по правилам; refresh-пилот, который перезапускает выгоревшие креативы по отобранным каналам; сборщик статистики; и сторож расходов, spend-guard.mjs, который раз в N минут спрашивает у кабинета сумму за сегодня и сравнивает с порогами.

Сторож был написан примерно так (упростил до сути, убрал ретраи и разбор ошибок):

// spend-guard.mjs, первая версия
const SOFT = Number(process.env.SOFT_EUR ?? 120)
const HARD = Number(process.env.HARD_EUR ?? 150)
const STATE = '/var/log/tgbooster/spend-guard.json'

const today = new Date().toISOString().slice(0, 10)
let st = readState()
if (st.date !== today) st = { date: today, alertedSoft: false, alertedHard: false }

const spent = await getSpentToday()          // сумма по ad-periods за сегодня
console.log(`${new Date().toISOString()} | сегодня ${spent} EUR | soft ${SOFT} hard ${HARD}`)

if (spent >= HARD && !st.alertedHard) {
  await notify(`ЖЁСТКИЙ порог: ${spent} EUR при ${HARD}`)
  st.alertedHard = true
} else if (spent >= SOFT && !st.alertedSoft) {
  await notify(`мягкий порог: ${spent} EUR при ${SOFT}`)
  st.alertedSoft = true
}

writeState(st)

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

Что произошло 25 июня

Восстанавливаю по истории команд, потому что по памяти я бы соврал.

В 17:44 по UTC я гоняю refresh-пилот в сухом режиме:

$ ssh <vps> 'cd /opt/tgbooster-api && node --check channel-refresh-pilot.mjs && node channel-refresh-pilot.mjs'
=== channel-refresh-pilot DRY 2026-06-25T17:44:06.375Z ===
к запуску: 15 | бюджет 2.5 EUR | папка RETEST | потенциал возврата 400 подп

Пятнадцать каналов, по 2,5 евро на каждый. Итого 37,5 евро в сутки сверху, если считать в лоб. Ничего страшного, и в тот момент я так и подумал.

Дальше самое интересное. Чтобы применить изменения, я запускаю вот такую конструкцию:

$ ssh <vps> 'cd /opt/tgbooster-api && node -e "
    process.argv.push(\"--apply\",\"--max=15\");
    const fs=require(\"fs\");
    const f=\"/var/log/tgbooster/stepper-stop.flag\";
    const had=fs.existsSync(f);
    if(had) fs.renameSync(f, f+\".bak\");
    import(\"./channel-refresh-pilot.mjs\").finally(()=>{ if(had) fs.renameSync(f+\".bak\", f); });
  "'
=== channel-refresh-pilot APPLY 2026-06-25T17:51:23.267Z ===
к запуску: 15 | бюджет 2.5 EUR | папка RETEST | потенциал возврата 400 подп

Обратите внимание, что делает обёртка. stepper-stop.flag это файл-флаг, наличие которого запрещает автоматике трогать ставки. Он у меня стоял. И чтобы запустить refresh-пилот, я его временно переименовываю, а после запуска возвращаю на место.

То есть у меня был работающий предохранитель, и мой собственный рабочий процесс требовал его снимать. Не один раз, а каждый раз. Через семь минут я делаю то же самое ещё раз, уже короче, обычным шеллом:

$ ssh <vps> 'cd /opt/tgbooster-api && \
    mv /var/log/tgbooster/stepper-stop.flag /tmp/ssf.bak 2>/dev/null; \
    node channel-refresh-pilot.mjs --apply --max=15; \
    [ -f /tmp/ssf.bak ] && mv /tmp/ssf.bak /var/log/tgbooster/stepper-stop.flag; \
    echo "флаг возвращён"'

А в 17:55 я из любопытства дёргаю сторожа руками и вижу те самые 224,98.

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

Причина перерасхода тут не в одной ошибке. Их три, и они разного сорта.

Схема того, как было устроено 25 июня
Схема того, как было устроено 25 июня

Два порога, два уведомления и ни одной остановки. Рычаг был, система его не дёрнула.

Ошибка первая: уведомление я считал защитой

Самая простая и самая дорогая. У меня было два порога, и в голове они лежали как два уровня защиты. На деле это один уровень, отправленный дважды, потому что оба заканчиваются вызовом notify().

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

Блокировка не зависит ни от чего из этого. И у меня в тот момент в коде уже был живой клиент к API кабинета, тот самый, которым я собирал сумму расхода. То есть возможность поставить кампании на паузу была прямо там, на расстоянии одного вызова. Я её просто не написал.

Ошибка вторая: пороги в абсолютных числах

SOFT=120, HARD=150 это суммы за сутки. Проблема в том, что нормальный день и день разгона выглядят по-разному, а порог у них общий.

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

И вот это самое противное во всей истории: сработавший сторож, которого игнорируют, хуже отсутствующего. Отсутствующий хотя бы не создаёт ощущения, что всё под присмотром.

Отдельно: абсолютная сумма ловит проблему поздно. К моменту, когда счётчик доехал до 150, деньги уже потрачены. Скорость расхода в первые часы дня сказала бы то же самое сильно раньше.

Ошибка третья: предохранитель, который мешает работать, снимают

Про stepper-stop.flag я думал, что это хорошее решение. Простой файл, проверяется одной строкой, ставится руками, снимается руками.

На практике он оказался в позиции, где мой обычный рабочий сценарий требует его снять. И я не придумал ничего лучше, чем оборачивать запуск в renameSync туда и обратно. Причём в двух разных вариантах, шелловом и нодовом, потому что первый раз я написал одно, второй раз другое.

Дальше очевидное: если в обёртке что-то падает до finally, флаг остаётся снятым. Если два таких запуска накладываются, второй восстановит флаг из .bak, который положил первый, и получится каша. Я это не поймал в тот вечер, но конструкция такова, что поймать было можно.

Общая мораль тут не про Node и не про флаги. Предохранитель, который приходится обходить в рутине, перестаёт быть предохранителем через месяц.

Как я это переписал

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

Дальше переписал spend-guard.mjs так, чтобы он умел останавливать.

// spend-guard.mjs, версия с исполнительной частью
const SOFT = num(process.env.SOFT_EUR, 120)
const HARD = num(process.env.HARD_EUR, 150)
const BURST = num(process.env.BURST_EUR_PER_H, 25)   // потолок скорости
const STATE = '/var/log/tgbooster/spend-guard.json'
const HALT  = '/var/log/tgbooster/HALTED'            // ставится сторожем, снимается руками

const now = new Date()
const st  = loadStateFor(now)

let spent, rate, authDead = false
try {
  spent = await getSpentToday()
  rate  = spent / hoursSinceMidnight(now)            // EUR/час с начала суток
} catch (e) {
  authDead = true
}

// нет связи с кабинетом это тоже авария, а не тишина
if (authDead) {
  await notify('spend-guard: нет связи с кабинетом, лимит проверить не могу')
  st.authDead = true
  return save(st)
}

const overHard  = spent >= effectiveHard(st)         // с учётом окна разгона
const overBurst = rate  >= BURST && spent >= SOFT

if (overHard || overBurst) {
  const paused = await pauseAllActive()              // идемпотентно, возвращает список
  fs.writeFileSync(HALT, JSON.stringify({ at: now.toISOString(), spent, rate, paused }))
  await notify(
    `ОСТАНОВЛЕНО. ${spent} EUR за сегодня, скорость ${rate.toFixed(1)} EUR/ч. ` +
    `Снято с показа: ${paused.length}. Возврат только руками.`
  )
  st.halted = true
  return save(st)
}

if (spent >= SOFT && !st.alertedSoft) {
  await notify(`мягкий порог: ${spent} EUR, скорость ${rate.toFixed(1)} EUR/ч`)
  st.alertedSoft = true
}
save(st)

Что здесь поменялось по сути.

Появился файл HALTED. Пока он лежит, все остальные скрипты, включая степпер и refresh-пилот, при старте видят его и выходят с ненулевым кодом. Снимается только руками, автоматического возврата нет намеренно: если система остановилась, кто-то должен посмотреть, почему.

Появилась скорость расхода, а не только сумма. BURST_EUR_PER_H ловит аварию за час-полтора вместо «к вечеру набежало».

Появилось окно разгона. effectiveHard() смотрит в файл /var/log/tgbooster/boost-window.json, куда я перед ручной выкаткой кладу строку вида {"until":"2026-06-25T21:00:00Z","hard":300}. Порог поднимается на конкретные часы и сам опускается обратно. Именно это убрало ложные тревоги и, соответственно, привычку их игнорировать.

Отсутствие связи с кабинетом стало отдельной аварией. Раньше authDead просто писался в состояние. Тихо неработающий сторож это худшее из состояний, потому что снаружи он неотличим от работающего.

И убрал историю с stepper-stop.flag как ручным флагом. Теперь остановка одна, она называется HALTED, и снимать её ради рутины не нужно, потому что запуск refresh-пилота больше не конфликтует со степпером: они берут общий advisory-лок на файл, а не проверяют флаг друг друга.

Что поменял после инцидента
Что поменял после инцидента

Четыре правки. Главная первая: на жёстком пороге теперь останавливаются кампании, а не уходит сообщение.

Учения

Отдельный пункт, который я раньше пропускал. Защиту, которую ни разу не проверяли в бою, считать защитой нельзя.

Раз в квартал я запускаю сторожа с искусственно заниженным порогом:

$ ssh <vps> 'cd /opt/tgbooster-api && HARD_EUR=1 BURST_EUR_PER_H=1 node spend-guard.mjs'

Смотрю, что кампании действительно встали, что появился HALTED, что степпер при следующем запуске отказался работать, и что уведомление дошло. Потом снимаю флаг руками и проверяю, что всё поднялось.

Первый такой прогон, кстати, нашёл смешную вещь: pauseAllActive() возвращал список остановленного, но одну кампанию не трогал, потому что она была в статусе, который я не учёл в фильтре. То есть даже переписанный сторож с первого раза останавливал не всё.

Чего это не решает

Честно про границы.

Остановка спасает от аварии, но не спасает от медленной утечки. Если правила двигают ставки чуть-чуть не туда и расход держится на 140 при пороге 150, сторож промолчит, и правильно сделает. Это ловится не лимитом, а сравнением стоимости результата с базой, и это другая задача.

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

И самое главное: ни один из уровней не отменяет лимита на стороне площадки. Мой код может упасть, VPS может уехать, токен может протухнуть в самый неподходящий момент. Единственная защита, которая переживает всё это, живёт не у меня.

Если коротко

Посмотрите на свою систему предупреждений и ответьте на один вопрос: что произойдёт, если сообщение никто не прочитает. Если ответ «ничего, деньги продолжат уходить», у вас не защита, а информирование. У меня было именно так, и стоило это 74,98 евро сверх порога за один вечер плюс понимание, что при более серьёзной поломке потолка не было бы вообще никакого.