Двадцать пятого июня мой сторож бюджета отработал ровно так, как я его написал. И именно поэтому за день ушло 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.
Сложите картинку. Пятнадцать новых запусков, флаг остановки снят дважды за десять минут, степпер в это время свободен двигать ставки, сторож всё видит и пишет мне сообщения, которые я в тот момент не читаю, потому что сижу в соседнем терминале и занят.
Причина перерасхода тут не в одной ошибке. Их три, и они разного сорта.

Два порога, два уведомления и ни одной остановки. Рычаг был, система его не дёрнула.
Ошибка первая: уведомление я считал защитой
Самая простая и самая дорогая. У меня было два порога, и в голове они лежали как два уровня защиты. На деле это один уровень, отправленный дважды, потому что оба заканчиваются вызовом 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 евро сверх порога за один вечер плюс понимание, что при более серьёзной поломке потолка не было бы вообще никакого.

