# Отчёты об ошибках: контракт с `POST /api/report` Приёмник существует и отвечает, а его исходник доступен, поэтому всё описанное ниже **прочитано в коде** приёмника, а не выведено из формы ответов: `server.mjs` (функции `normalizeReport`, `readJson`, `rateOk`, `buildView`, `makeMongoStore`, `makeFileStore`), его `config.json` и `README.md`. Документ описывает две половины одного провода: что приёмник принимает и как плагин (`lib/errors.js`, `lib/net.js`) заполняет его поля. Автору монитора достаточно этого файла — читать исходник плагина не нужно. Там, где код расходится с прежним описанием, построенным на пробах, это сказано прямо. Расхождений три, и все они в пользу точности: правило отказа `400` известно дословно, превысивший предел `meta` отбрасывается целиком, а не обрезается, и картина строится по времени получения отчёта, а не по времени события. Адрес по умолчанию: `http://185.75.189.247:3789/api/report` — общий публичный приёмник, один на все установки. Метод `POST`, `content-type: application/json`, тело — **один плоский объект**. Конверта и массива событий нет: один отчёт — один POST. ## Зачем это существует Монитору нужно три факта: **когда** модель перестала отвечать, **у скольких людей** одновременно и **что именно** сломалось. Обычный способ их получить — зонд: отдельный запрос к модели по расписанию. У зонда две беды: он сжигает токены на запросы, которые никому не нужны, и он отвечает за одного человека — за того, на чьей машине запущен, поэтому «сайт лежит» и «у меня не работает» для него неразличимы. Вместо зонда используются **пассивные отчёты**. Плагин и так знает исход каждого запроса к модели, который сделал пользователь, и сообщает только о сбоях инфраструктуры. Один сбой — один отчёт, в котором есть момент, модель, вид сбоя, статус, длительность и очищенный текст ошибки. Отсюда два следствия, важных для приёмника. Отчёты приходят **пачками и задним числом**: всякий раз, когда отправить не удалось — приёмник занят или лежит, машина пользователя не в сети или спала, — отчёты накапливаются в очереди на диске и после восстановления приезжают разом, со своими исходными метками времени, которым может быть несколько часов или дней. Приёмник при этом живёт не там же, где API провайдера, поэтому авария провайдера сама по себе не мешает отчётам дойти; мешают ей только собственный простой приёмника и отсутствие сети у пользователя. И отчёты **не требуют аутентификации**: плагин не отправляет ни ключей, ни заголовка `Authorization` никогда. ## Что отправляется и когда **Ошибка** отправляется только тогда, когда она инфраструктурная: соединение не состоялось, DNS не разрешился, сокет отказал или оборвался, TLS не собрался, шлюз провайдера ответил 5xx или 404, ответ не пришёл за отведённое время. Решение принимает `classifyFailure`, и оно же отвечает за то, чего в отчёте не будет вовсе: кончившаяся подписка, исчерпанная квота или баланс, неверный либо отсутствующий ключ, rate limit, не поместившийся контекст, прерванный пользователем запрос, неизвестная модель и любая другая ошибка запроса, которую harness помечает собственным кодом. Это положение конкретного пользователя, а не состояние сервиса, и в мониторе оно выглядело бы как авария на ровном месте. **Внешний статус 5xx перевешивает текст про аккаунт.** Порядок решений здесь важен, потому что одно и то же сообщение можно прочитать двумя способами. Строка ``` 502: {"message":"Provider authentication failed","type":"upstream_error","code":null} ``` содержит и внешний статус 502, и предложение про аутентификацию. Прочитанная как текст, она выглядит чужим ключом и не публикуется вовсе. Прочитанная как статус, она означает, что **шлюз Rustvy не смог авторизоваться на собственном апстриме** — авария общая для всех пользователей, а не чья-то настройка. Правило поэтому такое: 5xx — это по определению отказ самого провайдера, и он важнее любых слов в теле, поэтому такой сбой уходит как `http_502` с `status: 502`. Ограничение ровно то же: только 5xx. `404: model gpt-6.1-sol does not exist` остаётся `model` и не публикуется — неизвестная модель это положение отправителя, какой бы статус ни пришёл, — и `429: rate limit exceeded` остаётся `rate_limit`. Граница проходит по вопросу «сломался ли механизм самого провайдера», и отвечают на него да только пятисотые статусы. Отдельно стоит `upstream_NNN`: если шлюз сам назвал пользовательский статус (`upstream_401`, `upstream_402`, `upstream_403`, `upstream_429`), это его собственный вердикт, и он остаётся непубликуемым. **Собственный код harness всё же старше статуса.** 5xx перевешивает *текст* ответа, но не вердикт уровня, который запрос выполнял. Если harness пометил сбой кодом `AUTH`, `QUOTA`, `RATE_LIMIT` или `ABORTED`, он остаётся положением пользователя даже тогда, когда в процитированном теле стоит 502, — и не публикуется. Различие не формальное: код это структурированный вердикт, а статус и текст плагин выводит сам, регулярными выражениями. Обратный порядок означал бы, что чужая проблема с ключом попадает на публичный дашборд всякий раз, когда тело ответа случайно содержит пятисотый статус, — а это худшая из двух ошибок: пропущенная авария видна как пропуск, опубликованные чужие учётные данные не видны никак. Полный порядок решений, от сильнейшего свидетельства к слабейшему: `upstream_NNN`, затем код harness, затем внешний 5xx, затем текст про аккаунт, и только потом транспортные признаки и остальные статусы. **Успех не отправляется никогда.** Приёмник принимает ошибки, и успешный запрос — не событие для него. Успех выполняет две локальные задачи: считается для страницы настроек, чтобы было видно, что плагин жив и смотрит, а не молча простаивает, и немедленно отправляет ещё не закрытую свёрнутую ошибку той же модели. Именно второе даёт монитору метку восстановления: отчёт о сбое приезжает в момент, когда модель снова ответила, а не по окончании окна свёртки. Отправка не блокирует запрос и не может его уронить: очередь живёт в памяти и на диске, таймер не держит процесс, а любая ошибка внутри отчётности проглатывается. ### Почему перехват хрупок по построению Событие `agent/request-error` в харнессе — **водопад**, а не обычная рассылка, и это единственное свойство всей схемы, из-за которого отчёты могут пропасть целиком, а не по одному. Водопад диспатчится в `.asar-src/dsh/node_modules/@deepseek-ai/dsh-agent-loop/lib/index.js:1137`, а его семантика описана в `.../cordis/src/events.ts:230-245`: слушатели идут от внешнего к внутреннему, и **слушатель, не вызвавший `next()`, отменяет всю остальную цепочку**, включая встроенное поведение. Порядок — порядок в массиве, а `options.prepend` (`ctx.on(name, listener, true)`, `events.ts:287`) ставит слушателя самым внешним. Встроенный плагин харнесса `dsh-llm-retry` подписан на тот же водопад (`.../dsh-llm-retry/lib/index.js:176`) и грузится раньше пользовательских плагинов, то есть стоит снаружи от нас. Когда его `recover()` решает повторить запрос, он вызывает `backoff(...)`, который **возвращает `{kind:'retry'}`, не вызывая `next()`** (строки 117-150). `next()` вызывается только когда политики повтора нет, код не входит в `retryableCodes`, попытки исчерпаны, либо режим `always` — там внутренний слушатель получает управление первым. Следствие: каждая повторяемая ошибка до нашего слушателя не доходит. Если плагин зарегистрирован без `prepend`, он внутренний, и весь класс сбоев, которые харнесс считает повторяемыми, исчезает из отчётности молча — не «отправлено и потеряно», а «никогда не увидено». Именно это и произошло на второй машине пользователя: реальные сбои апстрима шли, в коллектор не пришло ничего. Лечат это двумя независимыми швами, и оба принадлежат половине хоста (`lib/host.js`), а не классификатору: регистрация слушателей с `prepend: true`, чтобы нас нельзя было отсечь, и чтение события сессии `llm/retry`, которое `backoff()` пишет в журнал до всякого решения (строка 142) и которое перехватить нельзя. Наш слушатель `next()` вызывает безусловно, поэтому prepend не меняет поведение повторов — он меняет только то, что нас больше нельзя обойти. Стоит помнить и про третий путь: сбой вроде `pi-ai stream idle timeout after 300000ms` выбрасывается из шага и до `agent/request-error` не доходит вовсе — единственный его след это `turn/end`. Если попытка к тому моменту не записана, отчёт теряется молча, поэтому путь `turn/end` обязан уметь собирать отчёт best-effort, а не только дочитывать записанное. Классификатор читает и **голую строку**: `classifyFailure('502 status code (no body)')` разбирается так же, как объект с тем же `message`. Это не украшение, а исправление положения, которое было хуже обоих крайних. Раньше строка на входе давала пустое сообщение, то есть вид `unknown` со статусом 0; `unknown` инфраструктурно-способен, поэтому отчёт **уходил** в коллектор, описывая аварию, которую дашборд не мог описать никак. Запись без сведений хуже отсутствия записи: отсутствие видно как пропуск, а пустая авария видна как авария. Сейчас строка классифицируется по-настоящему, и шов, у которого есть только `String(error)`, деградирует в настоящий вид, а не в `unknown`. ## Поля приёмника Все поля необязательны, кроме одного правила: должно присутствовать хотя бы `message` или `code`. У большинства полей есть синонимы — это варианты одного хранимого столбца, а не дополнительные поля. | Поле (синонимы) | Тип | Смысл | |---|---|---| | `message` (`error`, `detail`) | string | текст ошибки, обрезается до **2000** символов | | `code` (`type`, `kind`) | string | тип ошибки, обрезается до **120**; при отсутствии приёмник подставляет `UNKNOWN` | | `plugin` (`source`) | string | какой плагин прислал, до 120; при отсутствии `unknown` | | `version` | string | версия плагина, до 60 | | `provider`, `model` | string | провайдер и модель, до 120 каждое | | `status` | int | HTTP-статус, **если он есть**; не-целое становится `null` | | `ts` (`timestamp`) | number | время клиента в миллисекундах; сохраняется как `clientTs` | | `meta` | object | дополнительные данные; принимается, только если его `JSON.stringify` не длиннее **8192** символов | Неизвестные поля на верхнем уровне приёмник игнорирует и отвечает `200`, так что добавление поля в будущем не сломает доставку. ### Что приёмник делает с телом Всё ниже прочитано в `normalizeReport` из `server.mjs`, а не выведено из ответов. Синонимы разбираются через `??` в порядке, указанном в таблице, поэтому **первый непустой выигрывает** — это и есть причина правила «один синоним на понятие»: прислав `message` и `error` рядом, отправитель не выбирает, что сохранится, а оставляет выбор приёмнику. Три детали, которые меняют представления отправителя. **Отказ `400` наступает ровно в одном случае**: когда `message` пуст **и** `code` оказался равен `UNKNOWN` (то есть не был прислан вовсе). Всё остальное принимается, включая отчёт без модели, без статуса и без `ts`. **Превысивший 8192 символов `meta` не обрезается, а выбрасывается целиком** — приёмник сохраняет вместо него `null`. Молча, с ответом `200`. Для отправителя это означает, что раздутый `meta` теряет не часть данных, а все, включая то единственное поле, ради которого `meta` и существует. **`status` принимается как `Number.isInteger(+b.status)`, и ноль — целое число.** Присланный `status: 0` сохранился бы как `0`, а не как «статуса не было», и попал бы в агрегаты как настоящий HTTP-статус. Отсутствующее поле становится `null`. Это делает наше решение не отправлять ноль не вопросом вкуса, а единственно верным. **Обрезка на стороне приёмника короче нашей.** Плагин ограничивает строковые поля 128 символами, а `normalizeReport` режет `code`, `plugin`, `provider` и `model` до 120, а `version` — до 60. Сегодня расхождение не имеет значения: значения `code` берутся из фиксированного словаря, где самое длинное — `connect_timeout`, — id моделей приходят из каталога провайдера, а версия плагина короткая. Но это именно расхождение, а не совпадение, и оно молчаливое: поле длиннее 120 символов доедет обрезанным, с ответом `200`, и узнать об этом можно только сравнив отправленное с сохранённым. Тому, кто добавит длинный вид сбоя или начнёт присылать составной id модели, стоит сначала укоротить наш предел до их. Сохранённый документ выглядит так — это схема и для MongoDB, и для файлового хранилища: ``` { plugin, version, provider, model, code, message, status, meta, clientTs, serverTs, ip } ``` `serverTs` приёмник ставит сам (`Date.now()`), `ip` берёт из `x-forwarded-for`, а при его отсутствии — из адреса сокета. Индексы в MongoDB созданы по `serverTs` и по паре `(code, serverTs)`. История обрезается до 45 дней по `serverTs`, но **только в файловом хранилище**, которое фильтрует строки при каждом сохранении; в MongoDB не удаляется ничего. Отдельно стоит запомнить, что `clientTs` сохраняется, но **не используется** агрегацией: картина строится по `serverTs`. Подробности и следствия — в разделе «`ts` может быть намного старше доставки» ниже и в [docs/uptime-view.md](). ## Как плагин их заполняет Один синоним на понятие, всегда. Плагин отправляет `message`, `code`, `plugin`, `ts` и никогда не отправляет рядом `error`, `detail`, `type`, `kind`, `timestamp` или `source`. Причина теперь известна из кода: `normalizeReport` разбирает синонимы цепочкой `message ?? error ?? detail`, и при двух присланном одновременно сохранится тот, что стоит в цепочке раньше, — то есть выбор сделает не отправитель, а получатель, и не заметит этого. | Поле приёмника | Значение плагина | |---|---| | `plugin` | `dsh-rustvy`, всегда | | `version` | версия плагина из `package.json` | | `provider` | маршрут провайдера: `rustvy`, `rustvy-k2`, … | | `model` | id модели; `unknown`, если определить не удалось | | `code` | **наш вид сбоя** (`connect_timeout`, `upstream_502`, …) — см. ниже | | `status` | HTTP-статус; **поле отсутствует**, когда статуса не было | | `message` | очищенный текст ошибки, до 2000 символов; никогда не пустой | | `ts` | момент события в миллисекундах, а не момент отправки | | `meta.id` | uuid установки | | `meta.ms` | длительность запроса в миллисекундах | | `meta.n` | сколько событий свёрнуто в отчёт; отсутствует, когда одно | | `meta.harnessCode` | код ошибки, каким его дал harness | | `meta.t` | тот же момент события, что и `ts` | Четыре решения стоит объяснить отдельно. **В `code` — наш вид сбоя, а не код harness.** Монитор группирует по вопросу «какие ошибки падали на данной модели», и `upstream_502` на него отвечает, а `PI_AI_ERROR` — нет: под этим кодом одинаково живут и отказ соединения, и ответ шлюза, и оборванный поток. Вердикт harness при этом не теряется, он переезжает в `meta.harnessCode`. Если автору приёмника нужна в `code` собственная таксономия — в его примере стоит `INVALID_REQUEST`, то есть как раз код harness, — замена делается в одну строку в `reportPayload`, и строка помечена комментарием. **`status` отсутствует, а не равен нулю.** У тайм-аута соединения HTTP-статуса нет вовсе, а ноль — не статус. Теперь это подтверждено кодом, а не только пробой: приёмник хранит статус как `Number.isInteger(+b.status) ? +b.status : null`, и `+0` — целое, так что присланный ноль лёг бы в хранилище числом `0` рядом с настоящими статусами и рано или поздно попал бы в агрегат. Отсутствующее поле становится `null`, что и означает «статуса не было». Длительность при этом не теряется: она лежит в `meta.ms`. **`meta.id` — единственное место для uuid установки.** Поля на верхнем уровне для него нет, а без него монитор не ответит на вопрос «у скольких людей»: тысяча отчётов от одного человека, который повторяет запрос, выглядит как тысяча пострадавших. Поэтому `id` — то единственное поле `meta`, которое нельзя терять, и поэтому `meta` держат крошечным: приёмник принимает его, только если `JSON.stringify(meta)` не длиннее 8192 символов, а **превышение отбрасывает объект целиком**, сохраняя `null` и отвечая при этом `200`. Наш `meta` занимает около 150 байт — запас в пятьдесят с лишним раз, и он сделан не из осторожности вообще, а потому что цена превышения не «обрезали лишнее», а «потеряли идентификатор установки молча». **`message` не бывает пустым.** Правило отказа приёмника известно точно: `400` возвращается тогда и только тогда, когда `message` пуст **и** `code` равен `UNKNOWN`. Наш `message` никогда не пуст — при отсутствии текста подставляется вид сбоя, — поэтому **от нашего плагина `400` недостижим**: ни одно сочетание полей, которое способен собрать `reportPayload`, не удовлетворяет условию отказа. Это не подстраховка на неизвестный случай, а доказуемое свойство отправляемой формы; `400` остаётся в таблице ответов как ветка, которую плагин обрабатывает, но не может вызвать. **`model` — либо id из каталога провайдера, либо `unknown`.** Это не соглашение оформления, а свойство приёмника: «вселенная» моделей в его картине строится как каталог из `config.json` **плюс любой id, встретившийся в отчётах за период**. Id, которого в каталоге нет, добавляется в картину отдельной строкой — с именем, равным id, и с собственной доступностью, — и остаётся там, пока не истечёт срок хранения, то есть до 45 дней. Один диагностический запрос с собственным именем модели стоит публичному дашборду лишней строки на полтора месяца. Наш запасной вариант совпадает с их собственным: отчёт, у которого модель определить не удалось, уходит с `model: 'unknown'`, и ровно так приёмник называет отчёт без поля `model` — строка «Без модели», общая для всех таких отчётов. Совпадение полезное: безымянные отчёты не плодят строки, а складываются в одну. ### Пример Запрос, построенный из реального сбоя: соединение с `rustvy.xyz:443` не состоялось за десять секунд. ```http POST /api/report HTTP/1.1 Host: 185.75.189.247:3789 Content-Type: application/json User-Agent: dsh-rustvy ``` ```json { "plugin": "dsh-rustvy", "version": "1.0.0", "provider": "rustvy", "model": "gpt-6.1-sol", "code": "connect_timeout", "message": "Connect Timeout Error (attempted address: rustvy.xyz:443, timeout: 10000ms)", "ts": 1792435355423, "meta": { "id": "4f0a1c9e-7b2d-4e6a-9c31-8d5b2f7a0e14", "ms": 10683, "harnessCode": "TIMEOUT", "t": 1792435355423 } } ``` Поля `status` здесь нет: ответа не было вовсе. Тот же сбой у пользователя, который повторил запрос пять раз за минуту, приходит одним отчётом — с числом повторов и со статусом шлюза: ```json { "plugin": "dsh-rustvy", "version": "1.0.0", "provider": "rustvy", "model": "gpt-6.1-sol", "code": "upstream_502", "status": 502, "message": "upstream_502 from the provider gateway while streaming", "ts": 1792435355423, "meta": { "id": "4f0a1c9e-7b2d-4e6a-9c31-8d5b2f7a0e14", "ms": 1290, "n": 5, "harnessCode": "PI_AI_ERROR", "t": 1792435355423 } } ``` `ts` и `meta.t` — момент **первого** из пяти событий, `meta.ms` — длительность **последнего**, `meta.n` — сколько их было. Свёрнутый отчёт описывает начало окна, а не его середину. ## Ответы приёмника и что делает плагин Тела ответов взяты из `server.mjs` дословно. | Ответ | Тело | Что делает плагин | |---|---|---| | `200` | `{"ok":true,"id":1792683213484}` | отчёт принят и забыт; откат сбрасывается | | `400` | `{"ok":false,"error":"need a message or code field"}` | отчёт **отбрасывается** без повтора и считается потерянным | | `429` | `{"ok":false,"error":"rate limit exceeded"}` | отчёт **остаётся в очереди**, следующая попытка откладывается | | `503` | `{"ok":false,"error":"storage unavailable"}` | отчёт **остаётся в очереди**, следующая попытка откладывается | | прочие `5xx`, сетевая ошибка, тайм-аут | — | то же: остаётся в очереди и откладывается | `id` в успешном ответе — это `serverTs` сохранённого документа, то есть момент, когда приёмник принял отчёт. Форма «похожа на миллисекунды» не случайно: это они и есть. Это единственное подтверждение доставки, которое мы получаем, и по нему автор приёмника может соотнести отчёт со своей строкой; плагин его не хранит, потому что хранить нечего — отчёт уже удалён из очереди. `503` возвращается, когда вставка в хранилище бросила исключение, и это единственный ответ, который означает «приёмник жив, но записать не смог». Он повторяем, и плагин повторяет. **Предел тела — 64 КБ, и то, как приёмник сообщает о превышении, важнее самого предела.** Тело запроса читается с пределом в **64 КБ** (`readJson(req, 65536, …)`). Текущий Node-приёмник при превышении **уничтожает сокет, не отвечая вовсе**; Go-приёмник отвечает честным `413` с телом. Два противоположных поведения, и разница видна только в том, что происходит с очередью. Уничтоженный сокет выглядит как сетевая ошибка, то есть как транспортный сбой, а транспортный сбой у плагина повторяем. Отчёт, который не влез в предел, остался бы в очереди навсегда, и, поскольку очередь отправляется по порядку, он задержал бы за собой всё остальное: классическая блокировка головы очереди, которую не видно ни в одном счётчике, кроме растущего `queued`. Честный `413` — это `4xx`, а любой `4xx` кроме `429` у плагина отбрасывает один отчёт и продолжает со следующим, что ровно правильно: тело, которое слишком велико, не станет меньше от ожидания. Плагину не пришлось ничему учиться. Правило «`4xx` кроме `429` отбрасывает отчёт и идёт дальше» было верным с самого начала и стало работать как задумано, когда сервер перестал отвечать на превышение предела молчанием. Поэтому `413` не появляется в таблице ответов выше отдельной строкой: не потому, что его не бывает, а потому, что он не требует отдельной ветки. До этого предела нам далеко, и расстояние стоит посчитать, потому что оно и есть гарантия. Самое длинное поле — `message`, до 2000 символов; в худшем случае, когда текст целиком кириллический, это около 6 КБ в UTF-8. Остальные поля обрезаны до 128 символов, `meta` — около 150 байт. Итого порядок 8 КБ против предела в 64 КБ, то есть восьмикратный запас при самом неблагоприятном тексте. Плагин никогда не отправляет тело, которое приёмник уничтожил бы не читая. ### `200` с предупреждением Go-приёмник добавляет к успешному ответу необязательное поле `warnings` — массив устойчивых строковых кодов в `lower_snake_case`, который присутствует, только когда сказать есть что, так что обычный `200` остаётся байт в байт прежним `{ok, id}`: ```json { "ok": true, "id": 1792766000000, "warnings": ["meta_too_large"] } ``` Повод пока один, и он ровно тот, о котором этот документ предупреждал: `meta` больше 8192 символов отбрасывается **целиком**, вместе с `meta.id` — единственным полем, отвечающим на вопрос «у скольких людей». Прежде приёмник делал это молча и отвечал `200`, то есть отправитель узнавал о потере идентификатора установки только по тому, что его отчёты переставали складываться в число пострадавших. Правила поля: - **ветвиться можно только по строке кода.** Незнакомый код потребитель игнорирует, а не считает ответ ошибочным, — иначе добавление кода ломает всех, кто поле читает; - коды не переименовываются и не переиспользуются: новый повод — новый код; - **прозы в поле нет вовсе.** Не потому, что пояснение не важно, а потому, что формулировка меняется при следующей правке и молча ломает всех, кто сопоставлял с ней, — а сопоставлять будут именно потому, что строка похожа на описание. Чисел, ради которых прозу хотелось бы оставить, здесь нет, и это правильно: размер своей `meta` отправитель может измерить сам — он единственный, у кого она есть, — так что серверное пояснение не сообщило бы ему ничего нового. Числа при этом не потеряны: коллектор пишет их в собственный журнал (`meta from is N bytes, over the M limit; dropped whole, losing meta.id`), где их и найдёт оператор, которому нужно увидеть плагин, постоянно превышающий предел. **Предупреждение не отменяет успех.** Отчёт сохранён, поэтому `200` с `warnings` — принятая отправка, и предупреждение принадлежит журналу или уведомлению, а не пути повтора. Поле появляется только на `2xx`: предупреждение при отказе — часть отказа и доходит до страницы настроек обычным путём, через строку `error`. **Контракт требует, чтобы плагин это поле читал.** Формулировка контракта прямая: отправка обязана прочитать предупреждение и сказать один раз (announce-once), назвав отчёт, — иначе контракт описывает поле, которое никто не читает, а это хуже, чем поле без него. Живёт это в половине хоста (`lib/host.js`), а не в `lib/errors.js`, потому что именно хост владеет журналом и правилом «сказать один раз». На момент этой правки ветка не реализована, и это расхождение с контрактом, а не приемлемая отсрочка. Терпимость читателя направлена в одну сторону, и три её детали важны, потому что без них реализация получается небезопасной. Знакомый строковый код читается как код. Незнакомый строковый код проходит дальше как есть, а не считается ошибкой, — иначе добавление кода ломает всех, кто поле читает. Элемент **не строкой** не разбирается и не отбрасывается, а **печатается как есть**: приведённый к строке, с управляющими символами, заменёнными пробелами, и обрезанный, — в той же строке журнала, что и распознанные коды. Почему правило именно такое, стоит записать, потому что обе очевидные замены хуже. Разбирать чужую форму (читать `code` у объекта) значит писать код под форму, которую никто не обещал: объект предлагали дважды и дважды отвергли, а сторонний коллектор вправе назвать поле `type`, и тогда чтение `code` молча даст пустую строку — исход, от которого правило защищает, только теперь за веткой, которая выглядит как обработка случая. Считать нераспознанные элементы («было N нераспознанных предупреждений») значит выбросить содержание, которое коллектор всё-таки прислал. Печать как есть дешевле обоих вариантов: имени поля она не предполагает, а содержание сохраняет. Обрезка и замена управляющих символов — не украшение: строка журнала не должна принимать произвольную длину и произвольные байты от удалённого сервиса. Модулю отправки изменение не нужно, и это проверено, а не предполагается: приём в `lib/errors.js` определяется как `status >= 200 && status < 300`, так что `200` с `warnings` уже считается успешной отправкой — запись уходит из очереди, откат сбрасывается. Ни ветка приёма, ни ветка отказа не читают тело на `2xx`, поэтому предупреждение не может случайно стать повтором и не может случайно стать потерей. Именно это делает поле бесплатным для каждого существующего отправителя. Наш собственный `meta` занимает около 150 байт при пределе в 8192 символа, так что `meta_too_large` нам не светит: запас пятидесятикратный. Но это не причина не реализовывать читателя — предупреждение придёт от сторонних плагинов, которые отправляют на тот же адрес. Тело ответа читается в одном случае: при отказе `4xx` строка `error` из тела попадает в `summary().lastError` дословно, как `the collector answered 400: need a message or code field`. Это единственное объяснение отброшенного отчёта, которое кто-либо когда-либо увидит, поэтому оно сохраняется, а не пересказывается. Для `429` та же строка даёт `the collector answered 429: rate limit exceeded` — и это полезно, потому что отличает «приёмник занят» от «приёмник лежит» без обращения к его телу. **Почему `429` — не отказ, а временная недоступность.** Прежняя логика «любой `4xx` отбрасывает партию» для этого приёмника означала бы потерю настоящих отчётов каждый раз, когда он занят: `429` говорит «слишком часто», а не «этот отчёт непригоден». Пригодность выражает `400`. Отсюда деление: `429` и `5xx` — повторяем, `400` и прочие `4xx` — отбрасываем. Отброшенный отчёт не будет принят никогда, а отложенный будет принят, как только приёмник освободится. Код приёмника подтверждает, что деление правильное: `429` возвращается **до** чтения тела, то есть приёмник даже не смотрел на отчёт, и утверждать о нём что-либо не может. Если вместе с `429` пришёл `Retry-After`, плагин соблюдает его как нижнюю границу паузы: ждёт `max(своя задержка, Retry-After)`, но не дольше собственного потолка отката. Потолок нужен потому, что ошибочный или враждебный заголовок не должен иметь возможность остановить отчётность на дни; ранняя попытка при `429` не стоит ничего — отчёт остаётся в очереди. Сам приёмник `Retry-After` не присылает, так что на практике работает собственное удвоение задержки. ## Частота и потолки Приёмник допускает **60 отчётов в минуту на IP**, и он один на все установки плагина. Ограничение реализовано как скользящее минутное окно: для каждого адреса хранится список моментов запросов, из него выбрасывается всё старше минуты, и если осталось 60 или больше — запрос отклоняется, не доходя до чтения тела. Число 60 берётся из `rateLimitPerMinute` в конфигурации приёмника, так что это настройка, а не константа. Адрес берётся из `x-forwarded-for`, если заголовок есть, — первое значение списка, — иначе из адреса сокета. За обратным прокси это означает, что **все установки за одним прокси считаются одним клиентом**, независимо от NAT. Один IP — это часто несколько установок, и каждая из них тратит общую долю. Отсюда собственный потолок плагина заметно ниже: установка, которая шлёт без ограничений, зарабатывает `429` не только себе, но и соседям по адресу. | Что | Значение | Следствие | |---|---|---| | Отчётов в одном сбросе | ≤ 10 | один POST на отчёт, последовательно, не параллельно | | Отчётов в минуту на установку | ≤ 20 | треть лимита приёмника на одну установку | | Отчётов в час на установку | ≤ 1200 | выведен из минутного как 20 × 60, а не задан вторым числом; сверх него отправка **останавливается** до конца часа, а очередь сохраняется и уходит после его начала — отбрасываются только окна успехов, и они считаются | | Очередь в памяти | ≤ 500 отчётов | самое старое выбрасывается и считается потерянным | | Таймер сброса | 15 с | не чаще одного прохода в 15 с | | Тайм-аут отправки | 10 с | дольше приёмник держать запрос не может | | Откат после сбоя | 15 с → 30 с → … → 600 с | пока приёмник лежит, это 6–12 запросов в час | | Окно свёртки ошибок | 60 с | один отчёт на пару «модель + вид» в минуту | | Запись на диск | не чаще раза в 5 с | очередь переживает перезапуск | Минутный потолок **не теряет** отчёты: когда он исчерпан, остаток просто ждёт следующей минуты и уходит со своими исходными `ts`. Часовой — теряет, и это единственное место, где отчёты выбрасываются намеренно; счётчик `dropped` делает это видимым вместо загадочного. **Свёртка означает, что дашборд считает происшествия, а не попытки.** Установка, которая за минуту повторила запрос пятьдесят раз и получила пятьдесят отказов, отправит **один** отчёт с `meta.n = 50`. Приёмник считает отчёты, поэтому в картине это одна ошибка там, где не состоялось пятьдесят запросов, и числа всегда будут выглядеть меньше, чем ожидает читатель, знающий о шторме. Обмен правильный, и он осознанный. Лимит 60 отчётов в минуту на IP общий для всех установок за адресом, поэтому взвешивание каждой попытки сожгло бы его одной машиной; а правило состояния в картине аптайма основано на **широте** — сколько различных установок увидели сбой, — а не на объёме. Вопрос монитора «у скольких людей сломалось», а не «сколько раз сломалось у одного». Настоящее число попыток при этом не потеряно: оно лежит в `meta.n` и сохраняется приёмником, просто не участвует в счёте. Читать числа надо соответственно: полоса корзин и `errors` измеряют происшествия. Если когда-нибудь будет решено взвешивать ошибки по `meta.n`, этот абзац — причина, по которой такое изменение безопасно, а не сюрприз: данные для него уже собираются. На момент написания решение не принято, и плагин ничего не взвешивает. **Спокойный режим**, когда всё работает: ноль отчётов. Ошибок нет — отправлять нечего, и это главная экономия против зонда. **Авария**: один отчёт на пару «модель + вид» в минуту, то есть 60 в час на модель, плюс метка восстановления. Одна лежащая модель даёт 60 отчётов в час; потолок в 1200 ограничивает любую установку сверху, что бы ни происходило. **Авария самого приёмника** — отдельный случай, и он важнее остальных: пока он не отвечает, отчёты дойти не могут, поэтому плагин уходит в откат до 10 минут и держит отчёты в очереди (до 500) и на диске. Нагрузка в этот момент почти нулевая, а после восстановления приходит пачка с метками времени из прошлого. Абсолютный худший случай на установку — 1200 POST-запросов в час; реалистичный — единицы. ## Словарь `code` Полный набор значений, которые плагин кладёт в поле `code`. Колонка «Отправляется» говорит, увидит ли приёмник этот `code` вообще: строка с «нет» не покидает машину ни при каких обстоятельствах, не попадая ни в документ ошибки, ни в документ отказа. | `code` | Отправляется | Что означает | |---|---|---| | `connect_timeout` | да | соединение не установилось за отведённое время, `ETIMEDOUT` | | `timeout` | да | дедлайн запроса или соединения, за которым ничего не пришло: «Request timed out.», `deadline exceeded`, `TimeoutError` | | `idle_timeout` | да | поток замолчал на середине ответа: `LLM_STREAM_IDLE_TIMEOUT`, «stream idle timeout». Выделяется **до** общего `timeout`, как `connect_timeout` — до него: соединение открывалось, значит что-то ответило | | `dns` | да | имя не разрешилось: `ENOTFOUND`, `EAI_AGAIN`, `getaddrinfo` | | `refused` | да | соединение отклонено: `ECONNREFUSED` | | `reset` | да | соединение оборвано: `ECONNRESET`, `EPIPE`, `socket hang up`, `other side closed`, `premature close` | | `unreachable` | да | маршрута нет: `ENETUNREACH`, `EHOSTUNREACH`, `no route to host` | | `tls` | да | сертификат или рукопожатие: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `DEPTH_ZERO_SELF_SIGNED_CERT`, `ERR_TLS_*` | | `http_5xx` | да | шлюз или сервер ответил 5xx: `http_500`, `http_502`, `http_503`, `http_504` | | `http_404`, `http_408` | да | модель или путь исчезли, либо запрос не успел | | `http_400`, `http_4xx` остальные | да, как **отказ** | уходят отдельным документом `kind: "refusal"`, см. ниже. Отличие `404`/`408` от них не в отправке, а в том, что те входят в процент аптайма, а отказ нет | | `upstream_NNN` | 5xx, 400, 404, 408 — да | так шлюз провайдера назвал ответ собственного апстрима: `upstream_502`, `upstream_503`, `upstream_400`, `upstream_404` | | `server` | да | код `SERVER` без статуса | | `transport` | да | сетевая ошибка общего вида, `fetch failed` | | `unknown` | да, если код из `INFRA_CODES` | код способен описывать сбой инфраструктуры, но текст не распознан | | `http_401`, `http_402`, `http_403`, `http_429` | нет | голый `4xx`, которому классификатор не дал другого слова: решение, а не классификация — см. ниже | | `upstream_401`, `upstream_402`, `upstream_403`, `upstream_429` | нет | апстрим ответил про аутентификацию, оплату или лимит — это положение пользователя | | `quota` | нет | квота, кредиты или баланс исчерпаны | | `billing` | нет | подписка, оплата, задолженность, заблокированный аккаунт | | `auth` | нет | ключ неверен, отсутствует или истёк; `401`, `403` | | `rate_limit` | нет | `429`, too many requests | | `context` | нет | контекст не поместился | | `model` | нет | модель неизвестна или не поддерживается | | `aborted` | нет | запрос прервал пользователь | | `request` | нет | ошибка самого запроса или конфигурации: `INVALID_REQUEST`, `EMPTY_RESPONSE`, `NO_ADAPTER` и подобные коды harness | | `unknown` | нет, если код не из `INFRA_CODES` | нераспознанный код без статуса: про такой сбой известно только то, что он не про машинерию провайдера | **Почему 404 и 408 — это про сервис, а остальные 4xx — про отправителя.** Шлюз, ответивший 404 на просьбу маршрутизировать модель, сообщает факт о себе: такого маршрута у него нет. 408 — то же по смыслу: запрос не успел дойти до конца, и это состояние пути, а не содержимого запроса. Остальные 4xx описывают вызывающую сторону: 400 — запрос собран неверно, 401 и 403 — ключ не годится, 429 — она шлёт слишком часто. Различие не формальное, и оно нужно читателю дашборда: строка `http_404` в картине — ожидаемый трафик от шлюза, у которого нет маршрута, а не признак того, что классификация плагина сломана. Правило действует с первой версии модуля и с тех пор не менялось. Изменилось с `1.3.1` другое: что плагин делает с остальными `4xx` — он их больше не выбрасывает, а отправляет отказом, кроме четырёх статусов, исключённых решением (следующий раздел). ### Отказ сервиса: отправляется, но своим документом Отказ — это `4xx`, который классификатор приписал **только статусу**: `kind` такого сбоя равен `http_NNN`, и никакого другого слова у него нет. Это первая половина границы. Сбой, которому классификатор дал собственное слово — `billing`, `auth`, `quota`, `rate_limit`, `model`, `context`, `aborted`, `request`, `upstream_401`/`402`/`403`/`429` и прочие, — не отправляется вовсе: это положение самого отправителя, а не факт о сервисе. Поэтому `401` с текстом про негодный ключ остаётся невидимым, а `400` с телом `{"type":"invalid_request_error"}` уходит: ключ один, провайдер отказал содержимому запроса, остальные модели в этот момент отвечали. Вторая половина — четыре статуса, которые исключены **решением**, даже когда никакого другого слова у сбоя нет: `401`, `402`, `403` и `429` (плюс `404` и `408`, которые и так инфраструктурные). Здесь HTTP сам называет виновную сторону, и это тот случай, когда статусу верить нужно больше, чем прозе: `401` — это учётные данные, `402` — это баланс, и то и другое принадлежит вызывающему; голый `403` одинаково может быть банком аккаунта за его собственное поведение и сбоем провайдера, то есть неоднозначен в сторону, которая важна. Отдать эти три статуса прозе значило бы позволить **непрочитанному или неожиданному телу** перевернуть клиентскую проблему в публичный отчёт, а клиентские сбои этот плагин не публикует. Таблицы слов при этом никуда не делись: `402` со словами про истёкшую подписку по-прежнему ловится как `billing`, а `401` со словами про ключ — как `auth`, и оба остаются невидимыми. Меняется только голый случай, и в нём статус называет не ту сторону. Цена названа, а не спрятана: отказ перестаёт покрывать ровно те статусы, которые провайдеры чаще всего и отдают без объяснений. Провайдер, ответивший `403` и не сказавший почему, не сказал ничего о причине, и не опубликовать ничего — честный ответ на сообщение, в котором ничего нет. Документ отказа — та же плоская обёртка, что у ошибки, и отличается одним полем: ```json { "plugin": "dsh-rustvy", "version": "1.3.1", "provider": "rustvy", "kind": "refusal", "model": "gpt-6.1-sol", "code": "invalid_request_error", "status": 400, "message": "400: {\"message\":\"The provider rejected the request content during its safety check\",…}", "ts": 1793301147829, "meta": { "id": "86f39ac6-037b-45df-86b8-b8c8135ff052", "ms": 812, "harnessCode": "UNKNOWN" } } ``` Два следствия, ради которых это отдельный вид, а не ещё одна ошибка: - **В процент аптайма отказ не входит** — по построению, а не по фильтру на приёмнике: процент считается по документам ошибок и успехов, а `kind: "refusal"` — третий вид, который не является ни тем, ни другим. Проверка безопасности блокирует один запрос, пока остальные одиннадцать моделей отвечают, поэтому «показать отказ» и «объявить недоступность» — два разных утверждения, и здесь делается только первое. - **`code` несёт провайдерский `type`, а не наш `http_400`.** Блок на мониторе ведёт строкой `code`, и `invalid_request_error` говорит больше, чем статус, из которого он выведен. Порядок выбора: `type` из тела → код harness → статус текстом. Пустым `code` не бывает никогда: строка «код: —» бесполезнее статуса, который мог бы там стоять. `429` в отказы не входит по решению, а не по классификации: это единственный `4xx`, который по определению про объём самого вызывающего, и его серверный вариант уже приходит как `503` или `upstream_`-статус. ### Как распознаётся статус Поле `status` у сбоя harness обычно отсутствует, поэтому плагин разбирает его из текста. Распознаются две формы, и проверяются они именно в этом порядке: сначала ключевое слово перед числом, потом число в начале собственной фразы. Ключевое слово перед числом — форма, которая была с первой версии: `status 503`, `status code: 503`, `HTTP 502`, `got 502`, `answered 503`, `the collector replied 500`, `upstream 504`. Сюда же относится число перед стандартной фразой причины: `502 Bad Gateway`, `503 Service Unavailable`, `429 Too Many Requests`. Число в начале фразы — форма, которую добавили после живого сбоя у пользователя, потому что именно её классификатор не видел: `502 status code (no body)`, `503 (no body)`, `502: {"message":"Provider authentication failed",…}`. Общее у них одно — сообщение **начинается** с числа, и никакого статусного слова перед ним нет. Число при этом обязано стоять отдельно: перед ним начало сообщения, пробел или открывающая скобка, а после него — двоеточие, завершающее число, слово `status`, либо скобка, начинающаяся с `no` или `empty`. Оба ограничения существуют не для красоты, а потому что трёхзначные числа из диапазона статусов встречаются в тексте постоянно и означают совсем другое: | Что | Пример | Почему не статус | |---|---|---| | порт | `attempted address: rustvy.xyz:443` | перед числом двоеточие, а не пробел | | длительность | `after 429 ms`, `idle timeout after 300000ms` | после числа единица измерения; у `300000` к тому же нет границы слова | | размер | `payload is 512 bytes` | после числа единица измерения | | счёт | `502 errors`, `there were 503 errors today` | после числа существительное, а не `status` и не скобка | | номер строки | `error at line 502 (see log)` | скобка есть, но начинается не с `no`/`empty` | | предел | `max retries 500`, `retry limit 503` | после числа конец фразы, а не один из трёх признаков | | версия | `v1.404 released` | перед числом точка | Отдельно стоит `429`: он распознаётся ещё и как «голое» число, но с собственным списком исключений (`BARE_RATE_LIMIT`), потому что `after 429 ms` — это тайм-аут, а не rate limit. Новый шаблон этот список не заменяет и не обходит, потому что они отвечают на разные вопросы: `BARE_RATE_LIMIT` определяет **вид** (`rate_limit`), а новый — **статус**. На `429: rate limit exceeded` срабатывают оба, и это правильно: вид `rate_limit`, статус 429, сбой не публикуется, потому что 429 не пятисотый. Шаблон измерен, а не подобран на глаз: 16 настоящих форм и 22 провокационных из таблицы выше, результат 16 из 16 распознано и 22 из 22 отвергнуто. Более широкая версия скобочной ветки — любая скобка после числа, а не только `no`/`empty` — распознавала все 16, но читала `error at line 502 (see log)` и `the batch of 443 (approx) failed` как статусы, и поэтому отвергнута. **`status: 0` означает «статуса нет», и на провод оно не уходит.** `reportPayload` включает поле только при значении от 100 до 599, поэтому у тайм-аута соединения в отчёте нет поля `status` вовсе, а не стоит ноль. Это то же правило, что и в картине аптайма: число, которое никто не вычислил, — это отсутствие числа, а не ноль, и потребитель не должен угадывать, какое из двух ему прислали. Длительность при этом сохраняется в `meta.ms`, так что «сколько это длилось» известно и без статуса. Растущая доля `unknown` — сигнал, что словарь отстал от жизни: появился новый текст сбоя, и его стоит превратить в отдельный вид. Для монитора `unknown` — такой же сбой, как остальные. Но всплеск `unknown` после всплеска 5xx означает другое: шлюз начал отвечать в форме, которую классификатор не читает, и статусы тонут в `unknown`. Так и было найдено число в начале фразы — живые 502 приходили, распознавались как инфраструктурный сбой и отправлялись, но на дашборде оседали в `unknown` вместо `http_502`, то есть картина была права в том, *что* сломалось, и неправа в том, *что именно*. ## Что никогда не отправляется В отчёте нет и не может появиться: текста запросов и ответов модели, содержимого сообщений и файлов, API-ключей и любых других секретов, идентификаторов чатов и сессий, имён пользователей и учётных записей, абсолютных путей на машине пользователя и имён их машин. Текст ошибки — единственное поле, которое приходит извне, и оно проходит через `scrubErrorText` перед тем, как попасть в отчёт. Гарантии, которые можно считать контрактом: - схлопываются пробелы и переводы строк; - удаляются ключи вида `sk-…`, значения `Bearer`, значения именованных полей (`api_key`, `token`, `authorization`, `client_secret`, …) и заголовки с учётными данными — вместо них ``, `Bearer `, ``, `
`; - абсолютные пути Windows (включая UNC `\\server\share\…`) и POSIX заменяются на `` **целиком**, в том числе когда в имени каталога есть пробел: `C:\Users\…\Programs\DeepSeek Harness\profiles\desktop` уходит как ``, а не как ` Harness\profiles\desktop`. Пробел пересекается только тогда, когда за ним в пределах 60 символов встречается разделитель пути, — поэтому `failed to open C:\foo bar baz qux and then the stream ended` теряет лишь `C:\foo` и сохраняет остаток фразы; - непрозрачные последовательности длиннее 40 символов (base64, JWT, дампы) заменяются на ``; - результат обрезается до 2000 символов — потолка самого приёмника; - адрес провайдера в тексте **сохраняется**: `rustvy.xyz:443` — не данные пользователя, а единственная подсказка о том, что именно не отвечало. Все шаблоны ограничены по длине, а входной текст обрезается до 4000 символов до обработки: сообщение приходит от внешнего сервиса через адаптер и считается враждебным, поэтому катастрофический backtracking на нём исключён. Единственное псевдонимное значение в отчёте — **uuid установки** в `meta.id`. Он создаётся один раз при первом запуске и лежит в `\.dsh-rustvy\install.json`. Сброс — удалить этот файл: следующий запуск создаст новый uuid, и установка станет для монитора другим человеком. Сборка отчётов выключается целиком настройкой «Собирать ошибки rustvy» или переменной окружения; выключенный репортёр не считает ничего и не отправляет ничего. ## Отчёт об успехах: одно окно, один документ Успехи уходят на провод, но не по одному запросу: они агрегируются в окно, и один документ описывает пятнадцать минут работы установки. Это вторая форма документа на том же адресе, и отличается она полем `kind`: ```json { "plugin": "dsh-rustvy", "version": "1.2.4", "provider": "rustvy", "kind": "ok", "window": { "from": 1793301147829, "to": 1793302047829 }, "c": [{ "m": "claude-opus-5-5", "n": 12 }], "meta": { "id": "86f39ac6-037b-45df-86c8-b8c8135ff052" } } ``` Конверт тот же, что у отчёта об ошибке — `plugin`, `version`, `provider`, `meta.id`, — поэтому приёмник разбирает одну обёртку и ветвится по `kind`, а не держит второй путь приёма. Имена внутри окна намеренно короткие (`c`, `m`, `n`): этот документ отправляет каждая установка в каждое окно, и пользователь прямо просил трафик минимизировать. **Длительности отправляются агрегатом окна, а не по запросу.** `ms` измеряется на стороне харнесса и приходит в `noteSuccess`; новых измерений не нужно — нужно было перестать их выбрасывать. Элемент окна несёт сумму и максимум рядом со счётчиком: ```json { "m": "claude-opus-5-5", "n": 8, "s": 20140, "x": 31204 } ``` `s` — сумма длительностей в миллисекундах, `x` — максимум. Последовательность отдельных длительностей не отправляется ни в каком виде: восемь выборок превращаются в два целых числа. Цена этого решения **измерена**, а не оценена — **774 байта** на документ при двенадцати моделях и **27,7 КиБ в сутки** с заголовками HTTP, +37 % к варианту только со счётчиками. Замер воспроизводится `scripts/traffic-measure.mjs`, и тот же скрипт проверяет, что реализованная форма стоит ровно столько: решение принималось по числу, а не по описанию, поэтому само число закреплено проверкой. **Правило отсутствия здесь противоположно правилу для `n`.** Отсутствие `n` читается как **1**: строка без счётчика свёртки есть ровно один отказ. Отсутствие `s` читается как **неизвестно** и никогда как 0: строки, записанные до этого изменения, несут `n` и не несут `s`, а нулевая сумма рядом с ненулевым счётчиком публикует среднее **0 мс** — мгновенный ответ, который никто не измерял. Это та же ловушка, что `Number(null) === 0` в нормализаторе, пришедшая с другой стороны. Отсюда же второе требование: среднее считается только по строкам, у которых `s` есть, и с соответствующим `n` в знаменателе. Смешать в одном знаменателе строки с суммой и без неё — значит занизить среднее ровно так же, как чтение отсутствия как нуля занижает число отказов. **Плагин не публикует частичную сумму.** Если у выборки в окне не оказалось годной длительности, `s` и `x` не отправляются вовсе, хотя `n` остаётся верным: сумма по семи выборкам рядом со счётчиком восемь занижает среднее, а занижение — успокаивающая сторона, которую читатель не может заметить. Отсутствие полей говорит правду; частичная сумма выглядит как данное. **Окно ведётся по маршруту.** Один документ называет ровно один `provider`, поэтому окна ведутся по маршруту, а не по установке целиком: установка с кольцом ключей на три маршрута отправит три документа за окно. Это по-прежнему ограниченное окнами число, а не числом запросов, — именно против последнего пользователь возражал. Слияние маршрутов сделало бы поле `provider` ложью, а поле, которое иногда верно, хуже трёх документов. **Пустое окно не отправляется вовсе.** Установка, на которой не работали, не платит ничего: это не округление до малого числа, а ноль байт и ноль запросов. **Счётчики, собранные при выключенной отправке, не уходят никогда.** Настройка читается в момент попытки, и выборка, отмеченная как не подлежащая отправке, просто не попадает в окно. Включить настройку позже — не значит задним числом получить разрешение на запрос, который уже произошёл: он ушёл под обещание, что машину не покинет. ### Замеренный объём Числа ниже **измерены** на настоящих документах, а не оценены: версия плагина 1.2.3, идентификатор установки — UUID из 36 символов, имена моделей — настоящие идентификаторы провайдеров. Воспроизводятся скриптом `scripts/traffic-measure.mjs`. | моделей в окне | байт на документ | | --- | --- | | 1 | 213 | | 3 | 271 | | 6 | 358 | | 12 | **534** | Постоянная часть **181 байт**, на модель **32 байта**; обе цифры получены как разность двух настоящих документов, а не арифметикой из оценки. За восьмичасовой рабочий день при минутном окне выходит **480 окон**, но окно существует только если в него попал успех. При 231 успехе за день занятыми оказываются **184 окна**; остальные пусты и не отправляются вовсе: | сценарий | документов | КиБ/сутки с заголовками | | --- | --- | --- | | 1 модель, одна выборка в окне | 184 | **61,6** | | 3 модели, по одной выборке | 184 | **78,5** | | 12 моделей, по одной выборке | 184 | 154,7 | | 12 моделей, по восемь выборок | 184 | 159,0 | | установка простаивала | 0 | 0 | Числа — замер `scripts/traffic-measure.mjs`, а не умножение прежней оценки, и прежнюю оценку они не подтверждают в одну сторону: минутное окно держит примерно одну выборку на модель, а не восемь, поэтому четырёхзначные `s` и `x` короче пятизначных и документ на двенадцать моделей стоит **750 байт**, а не 774. Занятых окон тоже меньше, чем всех: умножать на 480 значит опубликовать число, которое в бою не возникает. **Агрегация больше не убирает 86 % запросов.** При пятнадцатиминутном окне 231 успех попадал в 32 окна и экономия составляла 86 %. При минутном те же 231 успеха попадают в **184 окна**, потому что окно стало сравнимо с промежутком между успехами, и экономия составляет **21 %**. Это не дефект, а прямая следствие решения: пользователь менял задержку процента на объём, и 86 % были ценой пятнадцатиминутной задержки. Экономии в **объёме** при реалистичном числе моделей не осталось вовсе: 184 документа по 438 байт с заголовками это 78,5 КиБ, а прежняя оценка отправки по одному запросу на каждый успех давала 71,5 КиБ при 231 запросе. Сравнение приблизительное — размер отдельного документа об успехе никто не измерял, — но порядок таков. Что агрегация по-прежнему даёт: число запросов 184 вместо 231, частоту, ограниченную независимо от объёма трафика, и агрегат длительностей, который по одному запросу не отправить. **С заголовками HTTP** один документ стоит на 113 байт больше тела, и это число ближе к тому, что платит пользователь, чем размер тела, поэтому показывать на странице настроек стоит именно его. ### Сколько стоит правило неполной суммы Правило «окно с одной выборкой без длительности теряет среднее целиком» приемлемо, пока таких выборок мало, и тихо превращает функцию в почти сплошные прочерки, если их много: прочерк честен, но пользователь не узнает, что среднее у него есть и теряется. Размер этого не выводится из кода, поэтому он считается — **вне провода**, в `summary()` репортёра, а не в документе: платить байтами на каждой установке за диагностику означало бы купить решение задачи, которую никто не измерил. | счётчик | что считает | | --- | --- | | `successesWithoutDuration` | выборки, пришедшие без пригодной длительности | | `successWindowsWithoutDuration` | осевшие окна, где хотя бы одна модель потеряла агрегат | Читать как долю: первый — относительно `successes`, второй относительно `successWindows`. Оба — накопительные итоги без интервала рядом, поэтому ни один из них не является частотой, пока что-то не разделит его на прошедшее время. Три вещи, которые нужно знать до того, как читать число. **Счётчик слеп, пока неотнесённый успех приходит как `ms: 0`, а не как отсутствие**: ноль проходит проверку на пригодность, признак полноты окна остаётся истинным, и счётчик не растёт. Он считает по тому же пути, который делает признак полноты ложным, поэтому не зависит от формы вызова на стороне хоста, — но правка, после которой отсутствие становится настоящим, обязана войти раньше, иначе ноль в счётчике означает «не видно», а не «не бывает». **Счётчик считает чуть больше, чем неотнесённые успехи**: он растёт на любой выборке без пригодной длительности, включая отрицательную и не-число, откуда бы она ни пришла. И **популяция смещена к медленным запросам**: `sweepAttempts()` выбрасывает попытки старше десяти минут, поэтому запрос, идущий дольше, приходит неотнесённым, а длинные запросы — это ровно медленные. Значит потерянные выборки в среднем крупнее прочих, и занижение среднего хуже, чем доля неотнесённых сама по себе. Запасной вариант, если доля окажется большой, — счётчик выборок с длительностью `k` рядом с `n`, позволяющий коллектору усреднять по `k`, а не по `n`. Он **замерен**: +72 байта на документ, 846 байт, 30,0 КиБ в сутки с заголовками, +48 % вместо +37 %. Решение о нём принимается по показаниям счётчика, а не заранее. ### Ошибка своей сети не отправляется вовсе Решение пользователя: отказ, вызванный сетью **самой машины**, не уходит ни как ошибка, ни как успех — это проблема этой машины, а не провайдера. Вариант выбран по сигнатуре мгновенного отказа. **Правило заведомо может съесть настоящий сбой провайдера, и это не оговорка, а условие, на которое пользователь пошёл осознанно.** Изнутри плагина отказ своей сети и отказ шлюза принять соединение неразличимы по форме ошибки: разбор другой машины дал 92 отказа с длительностью 17–23 мс каждые десять минут, и это либо бан её IP на шлюзе, либо мёртвый локальный прокси — по самой ошибке не определить. Поэтому подавление всегда сопровождается счётчиком: без него настоящий сбой, случайно отказавший мгновенно, исчез бы без следа. Подавляется ошибка, у которой **одновременно** выполнены три условия. **1. Нет HTTP-статуса вовсе.** Наличие статуса — любого, включая 5xx — означает, что машина до шлюза дошла и шлюз ответил. Такую ошибку не подавлять никогда и ни при каком пороге. Это главное свойство правила, и именно оно защищает случай, из которого вырос весь монитор: `502: {"message":"Upstream request failed: fetch failed","type":"upstream_error"}` имела статус и тело и обязана уходить. **2. Классификатор уже назвал отказ транспортным, и не любым, а одним из четырёх видов, означающих, что машина ни до чего не дорвалась:** `dns`, `refused`, `unreachable`, `transport`. Выводы `classifyFailure` не меняются — подавление это вентиль в точке отправки, а не четвёртый вид классификации. Намеренно **не** входят, и причину каждого стоит сохранить: - `reset` покрывает `terminated`, `aborted`, `premature close` и `socket hang up`. Каждое из них требует установленного соединения, которое закрыла **другая** сторона, значит сеть этой машины работала. Случай «terminated» при живой сети — это действие сервера, и подавить его значило бы выбросить настоящее действие провайдера правилом, написанным для локальных бед. Он исключён **по виду**, а не по длительности, поэтому никакое значение порога не может вернуть его под правило. - `timeout` **подавляется**: это дедлайн запроса, за которым ничего не пришло — «Request timed out.» на настроенных десяти секундах. Пользователь доказал это сам, выключив прокси в настройках: те же записи ушли на сервер, а по его правилу это ошибка клиента. - `idle_timeout` — **новый вид в словаре**, выделяется до общего `timeout` точно так же, как `connect_timeout` выделяется до него. Это поток, замолчавший на середине ответа: `pi-ai stream idle timeout after 300000ms`, около 300 000 мс. Соединение открывалось, значит что-то ответило, и это поведение сервера, которое пользователь требует видеть. - `connect_timeout` исключён, хотя соединение и не состоялось, и причина — существующее утверждение контракта, а не мнение: у него **нет** HTTP-статуса вовсе, и набор проверок задолго до этого правила утверждал, что он отправляется, причём поле `status` отсутствует, а не равно нулю. Он ещё и неоднозначен так, как не неоднозначны включённые виды: SYN без ответа одинаково означает файрвол провайдера, шлюз, дропающий забаненный адрес, и локальную неисправность. И он медленный по природе, так что порог длительности всё равно отсёк бы настоящие случаи. - `server` называет словами механизм самого провайдера. - `tls` — сбой рукопожатия или сертификата. Это обычно ошибка конфигурации с одной из сторон, а не мёртвая сеть, и подавление спрятало бы неправильно настроенную установку. **3. Длительность измерена и ниже 500 мс.** Порог назван, а не подобран: известные точки — 17–23 мс у мгновенных отказов и 79 мс у локального обрыва этой машины, против примерно 300 000 мс у молчания стрима. 500 мс примерно в шесть раз выше наибольшей локальной точки, что оставляет запас на более медленную машину и на резолвер, который пробует дважды, и примерно в 600 раз ниже молчания стрима. Порог — **вторая** защита, а не несущая: набор видов уже исключает всё, что требует установленного соединения, поэтому длительности остаётся не дать медленному отказу локальной формы быть прочитанным как мгновенный. **Длительность обязана быть измеренной, и это отдельное условие, а не часть третьего.** `lib/host.js` сообщает `ms: 0` для отказа, которому не нашлось попытки, и ноль там означает «не измерено», а не «мгновенно». Прочесть такой ноль как длительность — значит подавить настоящий сбой, длину которого никто не знает: это `Number(null) === 0`, пришедшее с третьей стороны. Когда длительность неизвестна, ошибка отправляется. **Подавление касается только провода.** Локальные счётчики и состояние продолжают видеть ошибку: `attempts`, `failures` и счётчик по видам увеличиваются до вентиля, поэтому правило отличимо от «не заметить». Пропускаются только свёртка и запись — свёрнутая запись унесла бы число вхождений на провод внутри более поздней. **Подавленная ошибка не считается успехом.** Она не входит ни в числитель, ни в знаменатель процента, не закрывает свёрнутый отказ той же модели и не открывает окно успехов. **Счётчик `suppressedNetworkErrors`** публикуется в `summary()` рядом с `successWindowsLost` и обязателен, а не желателен: теряется событие, а не агрегат, и это опаснее потери окна. Он считает события и живёт в памяти процесса, поэтому описывает подавления за время жизни процесса. Строка в журнале пишется один раз на ревизию и намеренно не снимается: ничто наблюдаемое не доказывает, что локальная сеть восстановилась, — только то, что пришла другая ошибка, а мигающая строка хуже постоянной. ### Очередь на диске только для ошибок Окно успехов **не сохраняется на диск и не повторяется**. Оно доставляется в ближайшую отправку; если доставить не удалось, оно выбрасывается и считается. Очередь на диске остаётся за отчётами об ошибках, и их поведение не меняется: ожидание `enabled()`, повтор с отступлением, сохранение между перезапусками. Основание — природа данных, а не экономия. Ошибке нужна долговечность: это редкое событие и единственная запись о том, что авария была. Пятнадцатиминутному агрегату долговечность не нужна: потеря одного окна сдвигает процент аптайма на долю процента, а следующий приезд восстанавливает картину. Платить за это вытеснением отчёта об ошибке — плохой обмен, и он был возможен, потому что полная очередь выбрасывала самую старую запись независимо от типа. Теперь окно успехов не вытесняет ничего: если очередь полна, выбрасывается окно, а не отчёт. **Асимметрия намеренна, и её стоит назвать явно.** Отчёт об ошибке ждёт доставки и переживает перезапуск; окно успехов не делает ни того ни другого. Читатель, знающий поведение пути ошибок, по умолчанию припишет то же поведение пути успехов, поэтому асимметрия записана, а не оставлена на вывод. **Стоимость переходного отказа.** Кратковременная недоступность приёмника теперь стоит окна, а не повторной попытки: до пятнадцати минут знаменателя аптайма не откладываются, а исчезают. Знаменатель при этом остаётся честным — данных меньше, а не выдумано больше, — и именно это свойство делает обмен приемлемым. Потеря видна в счётчике `successWindowsLost` в `summary()`, опубликованном рядом с `successWindows` в тех же единицах: одно число говорит, сколько окон ушло, другое — сколько не ушло. Счётчик считает **окна**, а не выборки, и не считает окна, отброшенные из-за отказа приёмника понимать форму документа, — у того состояния своя единственная строка в журнале, и счёт окон там рос бы по одному на окно до конца процесса. Окно, открытое на момент выхода из процесса, теряется **без счёта**: счётчик живёт в памяти и умирает вместе с процессом. Честная формулировка поэтому такая — `successWindowsLost` описывает потери за время жизни процесса, а не за всё время. **Окна, уже лежащие на диске** от версии, которая их сохраняла, при восстановлении выбрасываются и считаются, а не доставляются: соблюдать текущее правило значит не считать их долгом, к тому же они могут быть старше на дни. ### Если приёмник не понимает эту форму Отказ с кодом 4xx, кроме 429, означает, что приёмник не понимает **вид документа**, и в пределах своей версии не поймёт. Повторять его каждые пятнадцать минут — значит производить 96 выброшенных документов в сутки и растущий счётчик вместо одной строки в журнале. Поэтому плагин объявляет об этом один раз и больше окон успехов не отправляет до перезапуска; отчёты об ошибках продолжают уходить как прежде. Отказ — состояние процесса и на диск не пишется: записанный, он пережил бы обновление приёмника, которое его устраняет. 429 и 5xx остаются повторяемыми, потому что это «приёмник занят», а не «приёмник не понимает». Отсюда порядок выпуска: **приёмник раньше плагина или вместе с ним**. Против приёмника, который знает только форму v2, каждое окно успехов отклоняется с 400 и выбрасывается; ничего не падает, и со стороны плагина это не выглядит ошибкой, а пользователь видит прочерк вместо процента без объяснения с любой из сторон. ## Что значит uptime с записями об успехах Доступность модели — это **отношение успешных запросов к попыткам**, а не отсутствие отчётов об ошибках в окне. Приёмник v3 считает её как `100 * ok / (ok + fails)` и публикует вместе с обоими слагаемыми, чтобы число можно было воспроизвести, а не только прочитать. Формула, три состояния процента (`null`, `0` и число — три разных утверждения, и схлопывание любых двух есть ложь на экране), правило «`fails` есть сумма `n`, а не число записей» и выравнивание корзин по окну успехов разобраны в [docs/uptime-view.md](). Здесь остаётся то, что касается самих отчётов. Прежнюю формулу приёмника v2 стоит знать только затем, чтобы не принять её за текущую: ```js uptime = errors === 0 ? 100 : +(100 * (48 - downSlots) / 48).toFixed(1) status = errors === 0 ? 'up' : (uptime >= 50 ? 'slow' : 'down') ``` Она считала долю корзин без ошибок, а не долю успешных запросов, потому что запросов приёмник не видел: одна ошибка за сутки давала 97,9 %, а интервал, в котором модель никто не спрашивал, выглядел точно так же, как интервал, в котором она отвечала всем. Словарь состояний v3 другой — `ok`, `single`, `issues`, `down`, — и нормализатор плагина приводит старый (`up → ok`, `slow → issues`), поэтому установка, говорящая с коллектором v2, продолжает читать. **Сколько людей.** Число различных `meta.id` на интервале. Без uuid установки тысяча отчётов от одного человека, который повторяет запрос, выглядит как тысяча пострадавших; с ним — как один. Обратная сторона: удаление `install.json` создаёт новый uuid, поэтому счётчик людей за длинный интервал слегка завышен. Отчёты без годного `meta.id` схлопываются в один общий ключ и публикуются отдельно как `anonymous`, чтобы занижение было видно, а не спрятано внутри числа установок. **Границы аварии.** Первый отчёт по данной модели — начало. Конец виден по тому, что отчёты перестали приходить; точную метку восстановления даёт приход свёрнутого отчёта сразу после успеха — плагин отправляет его в тот момент, когда модель снова ответила, а не по окончании окна. **Длительность и глубина.** Разность `ts` между первым и последним отчётом серии — длительность аварии; сумма `meta.n` — сколько попыток она стоила пользователям; `meta.ms` — последняя длительность запроса; `status` отличает «шлюз ответил 502» от «соединение не состоялось вовсе» (поля `status` нет); `code` говорит, где именно сломалось. **Что считать аварией.** Одиночный отчёт от одной установки — ещё не авария: у одного человека мог оборваться Wi-Fi. Устойчивое правило — несколько различных `meta.id` с одним `code` в одном интервале; сам порог — на усмотрение автора монитора, данные для него здесь есть. ## `ts` может быть намного старше доставки Свёртка не заканчивается вместе с процессом. Очередь и ещё не закрытые окна записываются на диск (`\.dsh-rustvy\errors-queue.json`) и читаются обратно при следующем запуске, поэтому отчёт приезжает со своим исходным `ts` и с тем `meta.n`, которое успело накопиться, — даже если между двумя половинами окна плагин был перезагружен. На практике это выглядит так: окно собрало `n: 22`, половина плагина перезагрузилась, и после перезагрузки тот же отчёт доехал до приёмника — при том что счётчики у нового экземпляра начинаются с нуля и показывают меньше, чем `meta.n`. Это не расхождение, а одно и то же окно, разделённое перезагрузкой. **Что с этим делает приёмник сейчас — и это важно знать читателю картины.** Наш `ts` сохраняется в документе как `clientTs`, но агрегация его не использует: `list()` проектирует наружу `serverTs` под именем `t`, и именно по нему `buildView` раскладывает отчёты по корзинам и сортирует события. То есть картина строится **по времени получения, а не по времени события**. Для отчёта, который дошёл сразу, разницы нет. Для отчёта из очереди она ровно та, которую описывает этот раздел: авария, длившаяся три часа и доставленная одним пакетом после восстановления, ложится в корзины момента доставки и выглядит как короткий всплеск там, где были три часа проблем. Оценить масштаб расхождения можно по разности `serverTs − clientTs` — она лежит в каждом документе и показывает, какая доля отчётов приезжает опоздавшей и насколько. Правильная группировка — по `clientTs`, и данные для неё сохранены; не хватает только того, чтобы `list()` их отдавал. Это отдельная правка на стороне приёмника, и она независима от той, что чинит пустую картину: даже когда отчёты начнут находиться, картина останется построенной по времени доставки, пока агрегация не начнёт использовать `clientTs`. И `meta.n` — число событий внутри окна отчёта, а не за время жизни одного процесса, поэтому сравнивать его со счётчиками плагина в `/state` не нужно. Возможен и повтор: если процесс завершился между принятым отчётом и записью очереди на диск, тот же отчёт уйдёт ещё раз. Дедупликация по `(meta.id, ts, model, code)` закрывает и это. ## Адрес `http://185.75.189.247:3789/api/report` — публичный приёмник и адрес по умолчанию в плагине. Он общий: на него отправляют отчёты все установки плагина, а не одна, и именно поэтому потолки частоты из раздела выше — не вежливость, а необходимость: много установок, один приёмник, и одна установка, которая шлёт без ограничений, портит картину всем остальным. Открытый HTTP по голому IP — осознанное решение на данный момент, а не недосмотр. Отчёт по содержанию публичен и обезличен (см. «Что никогда не отправляется»), поэтому шифрование защищает не тайну, а целостность и подлинность; как только появится доменное имя и сертификат, адрес стоит сменить на `https`. Выпуска для этого не нужно: настройка `uptimeEndpoint` в `\.dsh-rustvy\config.json` и переменная окружения `DSH_RUSTVY_UPTIME_ENDPOINT` перенаправляют отчёты на любой адрес, причём адрес перечитывается перед каждой отправкой, а не запоминается при монтировании. Переменная окружения важнее настройки, но обе проходят проверку на разборчивый http-адрес: значение, которое не разбирается, отбрасывается, и в силе остаётся предыдущее. Значит перенаправить или заглушить отчёты можно, а прислать плагину мусор вместо адреса — нет. Тот же `uptimeEndpoint` ведёт и читательскую половину: плагин берёт из него начало адреса и путь `/api/view`, а ссылкой на монитор служит то же начало с путём `/`. Подробности — в [docs/uptime-view.md](). Приёмник уже переезжал, и контракт от этого не изменился: на новом адресе наш запрос с нашим телом возвращает `200 {"ok":true,"id":1792683213484}`, `GET /api/health` отвечает `200 {"ok":true,"storage":"mongo"}`, а `GET /` отдаёт страницу «rustvy uptime». Форму ответа при переезде не меняли, так что вся таблица ответов выше описывает поведение боевого адреса, а не стенда. Формат, словарь `code`, поведение на кодах ответа и потолки уже реализованы и меняются только вместе с версией плагина, которая уходит в поле `version`. ### Куда приёмник кладёт отчёт Хранилище выбирается один раз при запуске: MongoDB, если в конфигурации задан `mongo.uri` (или переменная `MONGODB_URI`) **и** соединение установилось за отведённые 5 секунд; иначе файловое хранилище в `data/errors.json` с записью, отложенной на 400 мс. Какое из двух работает, видно без отправки отчёта — `GET /api/health` отвечает `{"ok":true,"storage":"mongo"}` или `{"ok":true,"storage":"file"}`, и то же значение приезжает в поле `storage` ответа `/api/view`. Для отправителя это диагностически важнее, чем кажется, и не в ту сторону, в которую подсказывает слово «fallback»: именно файловое хранилище работает правильно, а MongoDB — то, которое принимает отчёты и не находит их при чтении (причина разобрана в [docs/uptime-view.md]()). Значит `storage: "file"` на развёрнутом экземпляре означает «Mongo не поднялся, но данные читаются», а `storage: "mongo"` — «данные пишутся и не читаются». Срок хранения — 45 дней по `serverTs`, но обрезка выполняется **только в файловом хранилище**, которое фильтрует строки при каждом сохранении. В MongoDB не удаляется ничего: ни одной команды удаления в его коде нет, хотя их README обещает 45 дней в обоих случаях. Отправителю это ничего не меняет — отчёт принимается одинаково, — но оценивать объём накопленного по документации приёмника не стоит.