# Аптайм моделей: контракт с `GET /api/view` Приёмник, которому плагин отправляет отчёты об ошибках, — тот же сервер, с которого он читает статус назад. Этот документ описывает вторую половину провода: что приходит в ответ на `GET /api/view`, что эти поля значат и что плагин (`lib/uptime.js`) делает с ними перед тем, как отдать настройкам. Документ описывает **контракт v2** — состояние вместо процента — и заодно то, как плагин читает v1, потому что v1 сейчас развёрнут. Нормативный текст v2 лежит в `tmp/uptime-contract-v2.md` и обязателен для четырёх сторон сразу: Go-коллектора (`rustvy-uptime/`), этого модуля, половины хоста и клиента. Там, где ниже сказано «контракт», имеется в виду он; там, где сказано «текущий сервер», имеется в виду Node-приёмник, который ещё работает. ## Почему процент вернулся В v2 процента не было, и это было **верно** для данных, которые тогда существовали. Мы собирали только ошибки: ни успешных запросов, ни объёма трафика, поэтому «процент доступности» был неизмерим в принципе — у него не было знаменателя. Всё, что прежняя формула `100 * (48 - downSlots) / 48` считала на самом деле, — это доля корзин без ошибок, и у такого числа три свойства, из-за которых его нельзя показывать человеку как доступность. Оно **зависит от периода**: один и тот же поток событий даёт 97,9 % на `24h` и другое число на `1h`, потому что ширина корзины меняется. Оно **насыщается**: 47 ошибок в корзине и одна ошибка в корзине вносят одинаковый вклад. И оно **слепо к широте**: сбой у одного человека и авария у провайдера выглядят одинаково, хотя это разные события, требующие разной реакции. Ни одно из трёх свойств никуда не делось. Изменилось то, что их больше не приходится терпеть: плагин v3 отправляет успехи, агрегированные в пятнадцатиминутные окна (форма и замер объёма — в [docs/error-report.md]()), поэтому у процента появился настоящий знаменатель. ``` uptime = 100 * ok / (ok + fails) ``` где `ok` — число успешных запросов, а `fails` — число **сбоев**, а не записей о сбоях. ### Почему `fails` — сумма `n`, а не число записей Отказ свёртывается на стороне плагина: пять попыток одной модели с одним видом ошибки внутри минутного окна уходят **одной** записью со счётчиком `meta.n = 5`. Запись одна, сбоев пять. Считать знаменатель по записям — значит заменить пять отказавших запросов одним и завысить аптайм, и ошибка уходит в успокаивающую сторону, то есть в ту, ради которой страницу и открывают. Это самое вероятное место, где будущий читатель захочет «упростить» код, поэтому правило вынесено в отдельный заголовок. Отсутствие `n` в записи означает **ровно 1**, а не 0: поле присутствует только когда счётчик больше единицы. Сумма по `n ?? 1` — единственное корректное чтение; сумма по `n ?? 0` на шести учтённых отказах даёт пять, и расхождение видно только если сравнить процент со счётчиком. Рядом стоят два числа, которые легко смешать: `errors` у модели и `count` у корзины — это число **записей**, на них держатся правило состояния и полоса истории; `fails` — сумма `n`. Там, где ничего не свёртывалось, они равны, и именно поэтому смешение долго не проявляется. ### Три состояния процента, которые рендер обязан различать `null`, `0` и число — три разных утверждения, и схлопывание любых двух из них есть ложь на экране. - **`null`** — знаменателя не было, `ok + fails == 0`: никто не измерял. Рисуется прочерком. - **`0`** — измеряли, и не ответил ни один запрос. Рисуется как `0 %`. - **число** — измеряли, и оно такое, какое есть. Профиль с выключенной отправкой успехов даёт `null`, а не `0` и не `100`. Установка, которая не обновилась и успехов не отправляет вовсе, тоже даёт `null` — на время деплоя это популяция, а не крайний случай, и она же была причиной, по которой v2 держал оба словаря состояний. Отсюда же запрет, закреплённый в контракте: `null` в **глобальной** сводке нельзя подписывать «процент не считается, успехи выключены». `/api/view` агрегирует все установки, поэтому `null` означает, что знаменателя не было ни у кого, а флаг одной установки — возможный вклад, а не причина. ### Точность принадлежит приёмнику Процент проходит через нормализатор плагина **без округления**, только с ограничением по диапазону 0..100. Округление на этой стороне — способ получить два разных числа на двух экранах из одной нагрузки. На границе оно ещё и выдумывает утверждение: 99,96 становится 100,0 и говорит, что затронуты все, хотя приёмник сказал обратное. То же правило действует для `affectedPct`. ### Время отклика: среднее и худший случай `msMean` и `msMax` приходят в миллисекундах на уровне модели, сводки и корзины. Корзина несёт среднее специально: «среднее за последние 15 минут» читается из последней корзины, а не пересчитывается на стороне страницы, потому что второй пересчёт одной нагрузки — это второе место, где можно ошибиться, и два экрана начнут расходиться. Правило отсутствия здесь **противоположно** правилу для `n` в отчёте об ошибках, и это не асимметрия ради асимметрии. Отсутствие счётчика повторов означает ровно один отказ; отсутствие суммы длительностей означает, что их никто не измерял. Нулевая сумма рядом с ненулевым счётчиком публикует среднее **0 мс** — мгновенный ответ, которого не было. Это та же ловушка `Number(null) === 0`, которая уже рисовала 1970 год, пришедшая с другой стороны, и нормализатор поэтому отдаёт `null`, а не `0`. Из того же основания среднее считается **только по строкам, у которых сумма есть**, и с соответствующим `n` в знаменателе. Строки, записанные до появления длительностей, несут счётчик и не несут сумму; смешать их в одном знаменателе — значит занизить среднее ровно так же, как чтение отсутствия как нуля занижает число отказов. Длительности не округляются — по тому же основанию, по которому не округляется процент: точность принадлежит приёмнику, а округление на этой стороне даёт два разных числа на двух экранах из одной нагрузки. Эти поля **не** гейтятся по `contractVersion`, как не гейтятся `ok` и `fails`: ключи новые, прежнего смысла не имеют, и нагрузка, которая их несёт, однозначна без маркера. Гейтится только процент, потому что только `uptime` поменял смысл между версиями. ### Версию контракта решает поле, а не догадка У поля `uptime` одно имя и два смысла: в v2 оно несло долю корзин, в v3 несёт доступность. Дискриминатор — `contractVersion` верхнего уровня, числом и без `omitempty`; процент рисуется только когда поле присутствует и не меньше 3, иначе прочерк. Строка `"3"` версией не считается: потребитель сравнивает `>= 3`, и то, что `"3" >= 3` в JavaScript истинно, — случайность языка, а не условие контракта. **Угадывать версию по присутствию `ok`/`fails` нельзя.** Соблазн понятен: эти поля появились в v3, поэтому их наличие как будто сообщает версию. Причины, по которым это не так, структурные и не зависят от того, найдётся ли сегодня нагрузка, на которой угадывание ошибается. Такой вывод привязывает **смысл** поля к тому, какие поля оказались заполнены, а не к тому, что производитель имел в виду. Он не отличает v3 от будущего v4, который переопределит `uptime` ещё раз, — а это ровно тот случай, ради которого дискриминатор существует. Он ломается молча: в первый же раз, когда любая версия опубликует эти поля с другой целью, вывод даст уверенный ответ про версию, о которой ничего не знает. И он заставляет каждого потребителя заново выводить факт, который производителю уже известен, тогда как явный маркер описывает себя сам и стоит одно целое число. Стоит сказать и то, чего этот аргумент **не** утверждает. На нагрузках, которые существовали на этой машине во время разработки, угадывание дало бы правильный ответ: сборка без маркера, считавшая семантику v3 целиком, получила бы прочерк — тот же, что рисует страница, — а настоящий приёмник v2 не публикует ни `ok`, ни `fails` и получил бы честное «не v3». Эмпирически вывод по присутствию не опровергнут. Отвергнут он не за это: правило должно держать на нагрузке, которой ещё нет, а не на двух, которые уже есть. Обоснование, которое оказывается неверным на первой же проверке, вреднее отсутствия обоснования, потому что следующий читатель найдёт работающий случай угадывания и решит, что маркер избыточен. ### Корзины выравниваются по окну успехов, а не по периоду Процент невозможен для корзины уже окна успехов: в пятиминутной корзине знаменателя нет, а распределить успехи пятнадцатиминутного окна по трём корзинам можно только выдумав распределение. Поэтому ширина корзины не мельче окна: | период | корзин | ширина | | --- | --- | --- | | `1h` | 4 | 15 минут | | `24h` | 48 | 30 минут | | `7d` | 48 | 3,5 часа | | `30d` | 48 | 15 часов | Часовой период в v3 даёт **4 корзины по 15 минут, а не 60 по минуте**. Подпись полосы обязана брать ширину из массива корзин, а не выводить её из периода: рисунок, утверждающий минутное разрешение при пятнадцатиминутных данных, врёт, и выглядит он при этом правдоподобнее настоящего. ### Порогов в оформлении нет Состояние модели (`ok`, `single`, `issues`, `down`) приходит от приёмника, а цвета и подписи не зависят ни от какого числового порога процента — §6 контракта пороги откладывает. Искать в стилях «меньше 95 — жёлтый» не нужно: такого правила нет, и его отсутствие намеренно. ### Предел объёма, который первым упирается в обычное использование `maxListRows` приёмника поднят с 20 000 до 50 000. Причина не в авариях: строки успехов растут от **времени**, а не от неприятностей. Пятнадцатиминутное окно даёт 96 строк на маршрут на установку в сутки, поэтому три маршрута и две установки — около 26 000 строк за срок хранения, и прежний предел достигался **двумя установками на кольце ключей** при полностью работающих провайдерах. Это первый предел в системе, который упирается в нормальное использование, и он стоит здесь именно поэтому. ## Что измеряется хорошо и осталось от v2 **Широта** — сколько различных установок (`meta.id`) сообщили об ошибках по модели в окне. Это самый сильный сигнал из доступных: одна установка — скорее всего её сеть, ключ или регион; две и больше — сбоит сам провайдер. **Свежесть** — когда пришла последняя ошибка и приходят ли ещё. **Объём** — число ошибок с учётом `meta.n`, нашей свёртки повторов. И **знаменатель охвата** — сколько различных установок вообще что-то присылало за долгий справочный период в 30 дней; это то, что делает честным `affectedPct`: «затронуто 3 из 47 установок». Это доля пострадавших, а не доступность, и называется она поэтому `affectedPct`, а не `uptime` — теперь, когда настоящий `uptime` существует, различие имён несёт смысл, а не историю. ## Запрос ```http GET /api/view?range=1h HTTP/1.1 Host: 185.75.189.247:3789 Accept: */* User-Agent: dsh-rustvy ``` `range` обязан быть одним из четырёх значений, и это белый список приёмника, а не предпочтение плагина: | `range` | Период | Корзин | Ширина корзины | |---|---|---|---| | `1h` | час | 60 | 1 минута | | `24h` | сутки | 48 | 30 минут | | `7d` | неделя | 48 | 3,5 часа | | `30d` | месяц | 48 | 15 часов | Число корзин разное, и это намеренно: час разбивается поминутно, потому что за час тридцатиминутная корзина не показывает ничего. Клиент масштабирует полосу по фактической длине массива, поэтому разное число корзин безопасно, — но **подпись полосы обязана говорить ширину корзины**, иначе полоса на неделю читается как полоса на час. **Границы корзин — полуоткрытый интервал `[from, from + width)`**, а самая свежая корзина закрывается на `now`, а не на `from + width`. Вторая половина соглашения важнее первой: без неё отчёт, принятый в эту же миллисекунду, не попал бы ни в одну корзину и картина потеряла бы самое свежее событие. Соглашение стоит знать и потому, что на нём легко ошибиться на единицу — событие, возраст которого ровно кратен ширине корзины, при закрытом слева и открытом справа интервале уходит в соседний слот, а в окне `1h` это каждый отчёт на минутной границе, то есть случай обычный, а не редкий. Следствие для потребителя: **номер корзины читается из её собственного `from`, а не вычисляется из метки времени.** Два способа согласованы только при этом соглашении о границах, и вычисленный индекс молча разойдётся с присланным, если коллектор когда-нибудь сдвинет начало окна или поменяет ширину. Плагин индексов не вычисляет вовсе: `bucketStats` находит крайние значения `from` сравнением, а не берёт их с концов массива, поэтому `first` всегда означает самое раннее время, а `last` — самое позднее, независимо от того, в каком порядке пришли корзины. Полоса и её подпись не могут разъехаться с данными, которые рисуют. **Порядок корзин зафиксирован контрактом: по возрастанию `from`, старые первыми, а последняя корзина заканчивается на `now`.** Причина и требование к проверке принадлежат контракту (`tmp/uptime-contract-v2.md`, раздел про окна и корзины) и здесь намеренно не пересказываются — у свойства должен быть один владелец. Одно различие стоит знать точно, потому что его легко принять наоборот. Перевёрнутый массив **громок**, а не тих: у него `from` убывает, шаг равен минус ширине, и любая проверка направления его ловит. Автор коллектора измерял это мутацией собственного кода, а не рассуждением, и проверка направления упала с `bucket span = -3540000 ms, want 3540000`. Тихий случай другой: **правильно упорядоченный массив с зеркально переставленными счётчиками** — `from` по возрастанию, шаги равномерные, оба края точные, и каждый счётчик не в своей корзине. Все структурные проверки проходят на нём чисто; ловят его только семантические — ошибка из «сейчас» обязана оказаться в последней корзине, ошибка на левой границе окна в нулевой. Ровно это и покупает семантический тест коллектора. Для потребителя отсюда следует вывод, который противоречит первому побуждению: ни проверка возрастания `from`, ни защитная сортировка **не покрывают** опасный случай. Массив с зеркальными счётчиками уже правильно упорядочен, поэтому сортировать в нём нечего, а проверка направления проходит чисто. Обнаружить расхождение на своей стороне нечем и в принципе. Единственное число, которое потребитель способен перепроверить, — сумма корзин против `errors`, — и оно вообще не является проверкой размещения: соотношение здесь **неравенство** `sum(buckets.count) <= errors`, а не равенство, потому что отчёт с часами впереди попадает в `errors`, но не попадает ни в одну корзину. Сверка суммы существует, чтобы ловить перекос часов, и размещение она не ловила никогда. Зеркальная перестановка к тому же сохраняет сумму — она и есть перестановка, а сумма перестановочна, — поэтому молчит и этот слабый детектор. Оба измерения, проведённых независимо мутацией коллектора, дали одно и то же: `errors = 4` и сумма корзин неизменны до мутации и после, края массива и равномерность шага не сдвинулись, а структурный тест `TestBucketCountsPerRange` прошёл на мутанте чисто. Двинулось только то, **какая корзина держит какой счётчик**: ошибка трёхчасовой давности переехала из корзины 42 в корзину 5. Из этого следует и то, чего ждать от семантической проверки, потому что частичная семантическая проверка тоже обманывается. При зеркальной перестановке самая старая и самая свежая ошибки **меняются концами**, так что набор непустых корзин сохраняет и нулевой, и последний элемент, и проверка вида «есть ли свежая ошибка в последней корзине» прошла бы по совпадению. Ловит мутанта только привязка каждого события к его собственной корзине: событие из «сейчас» в последней корзине, событие у левого края окна в нулевой, и индексы, следующие за возрастом событий. Поэтому семантический тест коллектора засеивает три различимых возраста, а не один, — и упрощать его до одного события нельзя, даже если так короче. Это один из немногих случаев, когда правильная защита находится не у читателя, и знать это стоит хотя бы затем, чтобы не писать сортировку «на всякий». Нормализатор плагина передаёт `buckets` в том порядке, в каком они пришли, и **не пересортировывает их**; клиент поступает так же и рисует полосу в порядке элементов массива. Теперь это решение обосновано точно, а не на всякий случай: сортировка не чинит ни громкий случай — перевёрнутый массив ловит проверка направления у самого коллектора, — ни тихий, где порядок уже верный. Хуже того, она превратила бы громкий сбой производителя в тихую поправку потребителя, и единственным оставшимся сигналом был бы набор тестов коллектора, за которым никто из читателей этого файла не следит. Защита, которая оставляет сигнал там, где его никто не смотрит, — это не защита. `first` и `last` в `bucketStats` от направления не зависят по построению: они находятся сравнением, а не берутся с концов массива, поэтому `first` — это самое раннее время окна, а `last` — самое позднее, какой бы порядок ни прислал коллектор. При закреплённом контрактом возрастании это совпадает с первым и последним элементом массива, но совпадение здесь следствие, а не механизм. Итог стоит назвать прямо, потому что он объясняет, почему два читателя одного массива защищены по-разному: громкий читатель (полоса) опирается на обещание коллектора и нарушается вместе с ним, а тихий (`first`/`last`) не нарушается вовсе — и молча инвертированное поле с именем `first` было бы опаснее громкой ошибки, поэтому оно сделано не зависящим от порядка. На любое другое значение приёмник отвечает: ```http 400 {"error":"range must be one of: 1h, 24h, 7d, 30d"} ``` В отказах `/api/view` нет поля `ok` — только `error`. Форма `{ok, error}` принадлежит `/api/report`; у чтения она другая, и это стоит знать тому, кто разбирает оба ответа одним кодом. Поэтому `isUptimeRange` проверяется **до** запроса: неверный диапазон отклоняется локально, не тратя ни обращения к сети, ни строки в журнале. Два окна используются одновременно и по разным поводам: значки в списке моделей и на кнопке выбранной модели берут `1h`, а панель «Аптайм моделей» по умолчанию берёт `24h`. Это не дублирование, а два разных вопроса — «что происходит сейчас» и «что произошло за день», — и переключение панели на 30 дней не должно перекрашивать значки, у которых в подсказке написано «за последний час». ## Состояние вместо процента `status` вычисляет коллектор по широте и свежести, одним правилом: | `status` | Условие | Смысл | |---|---|---| | `ok` | `errors == 0` | в окне ошибок не было | | `down` | `installs >= 3 && minutesSinceLast <= 5` | лежит прямо сейчас | | `issues` | `errors > 0 && installs >= 2` | провайдер сбоит у нескольких | | `single` | `errors > 0 && installs == 1` | одна установка — вероятно локально | Порядок проверки — `ok`, затем `down`, затем `issues`, затем `single` — и он важен: `down` проверяется раньше `issues`, чтобы свежая авария от трёх установок не опустилась в «сбои». `single` — последняя ветвь, поэтому недостижимого состояния у правила нет, и **v2 выдаёт только эти четыре значения**. Пороги — 2 установки, 3 установки, 5 минут — **выбранные, а не наблюдаемые**. Их нельзя вывести из данных, потому что данных о том, при какой широте сбой становится заметным пользователю, у нас нет. Они живут в одном месте коллектора и в одном комментарии, чтобы их можно было поменять, не трогая правило. Три следствия, которые читатель картины должен знать, иначе числа покажутся ему неправильными. **`down` — редкое состояние, и оно означает «идёт сейчас», а не «было много».** Чтобы его получить, ошибки должны приходить свежими (последняя не старше пяти минут) и минимум от трёх установок. Авария, которая длилась три часа и закончилась, `down` не даст никогда — и это правильно, а не пропуск: она и была три часа назад. **`issues` — обычное состояние «что-то случилось».** Две и больше установок с ошибками в окне, независимо от того, идёт ли сбой сейчас. Прежняя `slow` означала примерно то же, поэтому сопоставление `slow → issues` ничего не теряет. **Средняя доступность в `summary.uptime` устарела и осталась только для старого клиента.** Это среднее арифметическое по моделям, так что модель без трафика вносит в него свои честные 100 % наравне с моделью под нагрузкой. Рисовать это как «аптайм провайдера» нельзя: число означает «в среднем по моделям доля корзин без ошибок», что не является ничьей доступностью. **На окне `30d` сводный `affectedPct` равен `null`, а не 100, и это арифметика, а не дефект.** Знаменатель доли — справочный период в 30 дней, и когда окно совпадает со справочным периодом, числитель и знаменатель становятся одним и тем же множеством: доля тождественно равна 100 % и не несёт никакой информации. «Затронуто 100 % установок» на месячном окне читается как приговор, хотя означает только «мы посмотрели на тот же период, по которому считали базу». `null` здесь значит ровно «эта доля не имеет смысла», и клиент рисует прочерк — не ноль и не сто. Коллектор считает это честно и так и отправляет, поэтому правило живёт на стороне чтения: увидев `null` в сводке на месяце, не надо искать ошибку. Помодельный `affectedPct` этого ограничения **не** наследует и на `30d` остаётся осмысленным: у него в знаменателе общая база установок за 30 дней, а в числителе установки одной модели, то есть «4,3 % установок видели ошибки именно этой модели». Общее правило, которое объясняет оба случая и которое стоит запомнить вместо двух исключений: **доля бессмысленна только тогда, когда числитель и знаменатель — одно и то же множество.** Из него сразу видно, почему сводный процент на `30d` вырождается, а помодельный нет, и оно же удерживает от соблазна «починить» одно из двух чисел под другое — они разные не из-за несогласованности, а потому что считают разные доли. ## Отчёт без идентификатора установки Это место контракта стоит понимать, потому что из него растёт целое поле. `meta` может отсутствовать, может быть отброшена целиком при превышении 8 КБ (приём не обрезает её, а выбрасывает), и её может не присылать сторонний плагин. В текущих данных это не гипотеза: в `data/errors.json` много строк с `"meta": null`. Без идентификатора установка неразличима, и наивный подсчёт дал бы `errors > 0` при `installs == 0` — состояние, которое не описывается ни одной ветвью правила. Правило поэтому такое: пустой или отсутствующий `meta.id` считается **одной анонимной установкой** — одним сторожевым идентификатором, одинаково по модели, в `installs.window` и в `installs.reference`. Отсюда инвариант: из `errors > 0` следует `installs >= 1`, а вместе с `single` в качестве последней ветви — и недостижимость пятого состояния. Занижение здесь настоящее, и его надо видеть: авария у провайдера, о которой сообщили десять плагинов без `meta.id`, прочитается как `installs: 1` и `status: single`, то есть «вероятно локально». Десять пострадавших превратятся в одного, и правило сработает ровно так, как написано, — просто написано оно про различимые установки. Цена принятого решения ограничена: при смешении опознанных и анонимных отчётов счёт завышается ровно на единицу (5 настоящих идентификаторов плюс анонимный дают 6), и автор коллектора счёл ограниченное завышение на единицу лучшим, чем неинтерпретируемый ноль. Именно поэтому `anonymous` — число отчётов в окне без пригодного `meta.id` — опубликовано и на модель, и в `installs`. Без него читатель видит «1 установка» и не имеет способа узнать, что широта не равна единице, а неизвестна. Это поле из тех, которые удаляет человек, не знающий, от чего оно защищает: строка «3 ошибки от 1 установки, все 3 анонимные» и строка «3 ошибки от 1 установки» — разные утверждения, и только первая из них честная. Наш собственный плагин считает `meta.id` требованием корректности, а не аккуратностью, по той же причине. **Два числа рядом в одном объекте считают разные вещи, и вычитать их друг из друга нельзя.** `anonymous` — это **отчёты**, строки; `installs` — это **различные идентификаторы**, множества. Анонимные отчёты уже входят в `installs` как одна сторожевая установка, поэтому `installs - anonymous` не даёт ни «опознанных установок», ни чего-либо ещё осмысленного: из трёх анонимных отчётов и `installs: 1` получится минус два. Сравнивать `anonymous` можно с `errors`, который тоже считает строки, — и клиент рисует его именно против `errors`, поэтому надпись «3 ошибки, все 3 анонимные» согласована по единицам измерения. Правило общее и стоит того, чтобы его запомнить: прежде чем вычитать один счётчик из другого, проверь, что они считают одно и то же. ## Время события — единственные часы всего вида Весь вид строится по времени события, `clientTs ?? serverTs`: не только корзины, но и `total`, `byCode`, `firstAt`/`lastAt`, `minutesSinceLast`, `installs.window` и `installs.reference`. Иначе точки событий рисуются на оси корзин, построенной по другим часам, и график врёт. Это исправление второй из двух известных ошибок текущего сервера. Сейчас он разбивает вид по `serverTs`, поэтому отчёт, простоявший в очереди всю аварию и ушедший после восстановления, попадает в корзину момента доставки: трёхчасовая авария рисуется коротким всплеском на выходе. Подробности и диагноз — в разделе про пустую картину ниже. **Защита от кривых часов.** `clientTs` используется, только когда он правдоподобен: не позже `serverTs` более чем на 5 минут и не старше 45 дней хранения. Иначе берётся `serverTs`. Без этого часы клиента, уехавшие в 2020 год, выкинули бы настоящую аварию за пределы всех четырёх окон, и она исчезла бы с дашборда совсем — худший из возможных исходов, потому что неотличим от «ошибок нет». Отсюда следствие, которое надо понимать, а не обходить: **время в ответе может не совпадать с тем, которое прислал отправитель.** Отчёт с неправдоподобными часами появится в том моменте, когда его приняли, и это сделано намеренно — исчезнувшая авария хуже аварии, показанной не в своём окне. Плагин ничего не пересчитывает и не поправляет: он рисует то время, которое ему назвали. Два свойства ответа, которые выглядят как ошибки, но являются следствием этого выбора, и их стоит знать тому, кто будет их искать: Сумма корзин может быть меньше числа ошибок (`sum(buckets.count) <= errors`). Окно — полуоткрытый интервал `[now - span, now]` по времени события, поэтому отчёт с часами впереди попадает в `errors` и `total`, но не попадает ни в одну корзину. Плагин это не чинит: `bucketStats` возвращает сумму корзин отдельным числом, а настоящее число ошибок читается из `errors`. Полоса — это форма, а не итог. `firstAt` и `lastAt` могут превышать `generatedAt` по той же причине. Сырые метки сохраняются как присланные, чтобы ошибка часов оставалась видимой и диагностируемой, а производная величина `minutesSinceLast` ограничена нулём снизу, потому что она участвует в правиле состояния: для отчёта, пришедшего только что от клиента с часами впереди, ответ на вопрос «приходят ли ещё ошибки» — безусловно да, и `null` означал бы неизвестность, которой нет. Нормализатор плагина тоже не пропускает отрицательное значение, так что отрицательное «минут назад» не доедет до экрана ни с какой стороны. ## Ответ Верхний уровень: | Поле | Тип | Смысл | |---|---|---| | `generatedAt` | int, epoch ms | когда коллектор собрал ответ | | `range` | string | период, за который собран ответ | | `storage` | string | `'mongo'` или `'file'` — какое хранилище отвечает | | `healthy` | bool | **`rows.length === 0`**, то есть «за период не было ни одной ошибки» | | `total` | int | число отчётов за период | | `installs` | object | `{ window, reference, anonymous }` — см. ниже | | `summary` | object | десять счётчиков, см. ниже | | `byCode` | array | `[{ code, count }]`, по убыванию `count` | | `models` | array | по одной записи на модель, в порядке приёмника | **`healthy` — не про здоровье хранилища.** Поле вычисляется как `rows.length === 0`, то есть `true` означает «ошибок не было», а `false` — «ошибки были». Здоровье хранилища видно в поле `storage` и в `/api/health`, а не здесь. Их собственная страница это поле вообще не читает, так что единственный, кого оно может ввести в заблуждение, — потребитель API, то есть мы. Рисовать `healthy: false` как аварию приёмника нельзя: это ровно тот случай, когда приёмник отработал правильно и ему есть что показать. `installs`: | Поле | Тип | Смысл | |---|---|---| | `window` | int \| null | различных `meta.id` с ошибками в этом окне | | `reference` | int \| null | различных `meta.id`, присылавших что-либо за 30 дней — знаменатель охвата | | `anonymous` | int | отчётов в окне без пригодного `meta.id` | **`installs` — это два разных типа под одним именем, и это стоит знать заранее.** На верхнем уровне `installs` — объект `{ window, reference, anonymous }`, а внутри записи модели — целое число. Контракт намеренно оставил одно имя: в Go это два разных типа (`InstallsTotals` и `int`), и переименование верхнего уровня в разгар работы стоило бы расхождения четырём сторонам. Но в JSON это ловушка для читателя, поэтому она описана здесь, а не оставлена на догадку. `summary`: | Поле | Тип | Смысл | |---|---|---| | `totalModels` | int | размер «вселенной» моделей: каталог плюс встреченные в отчётах | | `operational` | int | сколько из них в состоянии `ok` | | `single` | int \| null | сколько в состоянии `single` | | `issues` | int \| null | сколько в состоянии `issues` | | `down` | int | сколько в состоянии `down` | | `slow` | int | устаревшее, равно `issues` — для клиента, написанного под v1 | | `uptime` | number | устаревшее среднее по моделям — для клиента, написанного под v1 | | `totalErrors` | int | то же, что `total` верхнего уровня | | `installsAffected` | int \| null | равно `installs.window` | | `affectedPct` | number \| null | `installs.window / installs.reference`, 0..100; `null`, когда знаменатель нулевой **или когда окно совпадает со справочным периодом** (`30d`) | Одна запись `models`: | Поле | Тип | Смысл | |---|---|---| | `id` | string | id модели; для отчётов без `model` — строка `unknown` | | `name` | string | имя из каталога; для `unknown` — «Без модели», для прочих новых — сам id | | `errors` | int | отчётов у этой модели за период | | `installs` | int \| null | различных `meta.id` по этой модели в окне | | `anonymous` | int | отчётов по этой модели в окне без пригодного `meta.id` | | `status` | string | `ok`, `single`, `issues` или `down` | | `firstAt` | int \| null | первая ошибка в окне; `null`, когда ошибок нет | | `lastAt` | int \| null | последняя ошибка в окне; `null`, когда ошибок нет | | `minutesSinceLast` | int \| null | минут с последней ошибки, не меньше нуля; `null`, когда ошибок нет | | `affectedPct` | number \| null | `installs / installs.reference`, 0..100; `null`, когда знаменатель нулевой | | `uptime` | number | устаревшее, доля корзин без ошибок | | `buckets` | array | `{ from: epoch ms, count: int }` — 60 для `1h`, 48 для остальных; по возрастанию `from`, но контракт направление не оговаривает | | `events` | array | до 500 записей `{ t, code, message, status, plugin, version, id }` | | `lastError` | object \| null | самая свежая запись той же формы, либо `null` | Время в событии — время события, а не время приёма, как и всё остальное в ответе. `id` — анонимный UUID установки; он появился в событиях намеренно, потому что «от скольких людей» ровно тот вопрос, ради которого введён `installs`, и прятать его внутри списка событий бессмысленно. Сам UUID уже публичен по замыслу отчёта: он уходит в `meta.id` каждого сообщения и служит только для счёта пострадавших. Плагин сохраняет из события шесть полей и отбрасывает `version`: ни значок, ни строка последней ошибки, ни подсказка версию плагина не печатают, а события — самый дорогой массив ответа (до 500 на модель у коллектора). Это одна строка в `toEvent`, если когда-нибудь понадобится вопрос «какие версии ошибались». ## Как это рисуется Значок в списке моделей и на кнопке выбранной модели берётся из окна `1h` и показывает **процент**, а не точку состояния. Три значения обязаны различаться текстом, а не только цветом, потому что это три разных утверждения: | что пришло | что нарисовано | что это значит | |---|---|---| | `uptime: null` | `аптайм —` | не измерялось: знаменателя не было | | `uptime: 0` | `аптайм 0%` | измеряли, и не ответил ни один запрос | | `uptime: 96.8` | `аптайм 96,8%` | измеряли, и вот число | | коллектор модель не назвал | ничего | про эту модель ничего не известно | Последняя строка важнее остальных: отсутствие значка означает «про эту модель ничего не известно», а не «у неё всё хорошо». Коллектор называет только те модели, о которых что-то знает, и число — это утверждение, которое он сделал. Ровно поэтому прочерк и ноль не могут быть одним значением: «нет данных» и «отказало всё» — разные факты, и первый не должен выглядеть вторым. Панель «Аптайм моделей» в настройках по умолчанию берёт `24h` и переключается между `1 час | 24 часа | 7 дней | 30 дней`. Строка модели показывает слово состояния, «ошибок N от M установок», отдельно долю анонимных сбоев, когда они есть, «последняя X назад», `аптайм N%` и — когда знаменатель охвата есть — `affectedPct` подписью **«затронуто N % установок», никогда не «аптайм N %»**. Это две разные доли, и подписи обязаны их различать: первая про запросы, вторая про людей. Шапка читается как «моделей N · без ошибок N · локально N · сбои N · лежат N · ошибок за период N · затронуто M из K установок». Ссылка на полный монитор остаётся. `affectedPct: null` рисуется прочерком, а не нулём. Это не оформление: 0 % означает «пострадавших нет», а прочерк — «делить не на что», и второе бывает у коллектора, который за 30 дней не видел ни одного отчёта. **Прочерк не объясняется причиной.** `uptime: null` в сводке — факт о агрегате приёмника по всем установкам: знаменателя не было у него, а не у вас. Признак `collectSuccesses` вашей установки — один из возможных вкладов, а не причина, поэтому страница не пишет «процент не считается, потому что успехи выключены». Утверждение «эта установка не участвует в подсчёте» живёт в пояснении к собственному выключателю, потому что про свою установку страница знает правду, а про глобальный агрегат — нет. ### Страница самого приёмника Она рисует иначе, и её правила стоит знать, потому что два экрана про одни данные не должны расходиться молча. Процент — главное число: в общем баннере и в каждой карточке, теми же тремя различимыми текстом состояниями (`—` с подписью «нет данных: не измерялось», `0%` с подписью «измерили: отказало всё», и число с подписью «N из M запросов»). Полоса истории собрана из **сегментов**, а не из столбчатой диаграммы, — в карточке, в свёрнутой карточке и в тридцатидневной полосе; сегмент окрашен состоянием корзины, а наведение показывает окно, успехи, сбои и процент. Подпись полосы берёт ширину корзины из массива, а не выводит её из периода. Есть список инцидентов на уровне страницы — записи отказов с временем, кодом, статусом, сообщением и идентификатором установки, — ограниченный 60 записями, и предел назван на странице, а не спрятан. Светлая и тёмная темы переключаются, и выбор запоминается в `localStorage`. Параметр `sort=errors` сортирует по `fails` под подписью «По числу сбоев»; ключ в URL сохранён прежний, поэтому старые ссылки работают. **Порогов в оформлении нет ни одного.** Состояние приходит от приёмника, а цвета и подписи не зависят от числового порога процента: §6 контракта пороги откладывает, и искать в стилях правило «меньше 95 — жёлтый» не нужно. Его отсутствие намеренно, и это стоит сказать явно, потому что читатель будет его искать. Плагин перенимает оттуда цвета и подпись полосы, а словарь состояний — нет: тот, что на странице приёмника, бывает и v2, и приводится нормализатором. ## Что плагин делает с ответом Ответ приходит с удалённого сервера и рисуется в браузере, поэтому считается враждебным по умолчанию. `normalizeUptimeView` возвращает один из двух вариантов, и вызывающая сторона смотрит только на `ok`: ```js { ok: true, range, generatedAt, healthy, storage, total, installs, summary, byCode, models } { ok: false, error: '<фраза по-русски>' } ``` Отказ возможен ровно в трёх случаях: ответ не объект, в ответе нет массива `models`, и ответ не удалось разобрать вовсе. Всё остальное **чинится, а не отбраковывается**: модель с испорченным процентом всё равно стоит показать строкой, а вот строку, которую невозможно опознать, показывать не стоит. Главное правило нормализации, и оно объясняет половину таблицы выше: **число, которое коллектор не вычислял, приходит как `null`, а не как `0`.** `installs`, `installs.window`, `installs.reference`, `summary.single`, `summary.issues` (когда нет и `slow`), `summary.installsAffected`, `affectedPct` на обоих уровнях, `firstAt`, `lastAt` и `minutesSinceLast` — все они могут быть `null`, и это не признак ошибки, а признак того, что отвечал сервер v1, который ничего из этого не считает. Ноль означает «посчитали, получилось ноль», `null` — «не считали». Клиент рисует второе прочерком. Единственное исключение — `anonymous`, которое приходит нулём и при отсутствии поля: ноль здесь честен в обоих случаях, а отличить «анонимных не было» от «сервер их не считал» можно по соседнему `installs`, который для v1 равен `null`. Прочие правила: - `range` — только из белого списка, иначе `24h`. Значение, которого приёмник не присылал, не должно доехать до подписи «период: …». - `generatedAt`, `total` и счётчики `summary` — целые неотрицательные, с потолком в миллион: v2 добавил числа, которые коллектор **утверждает**, а не только сообщает, и «от 1000000000 установок» — это предложение, которое рендерер напечатал бы дословно. - `healthy` — `false` только если приёмник сказал `false` явно; смысл значения описан выше. - `storage` — строка до 128 символов, при отсутствии пустая. - `byCode` — передаётся **как есть**, если это массив (копия, до 100 элементов), иначе пустой массив. Плагин не рисует разбивку по кодам, а чужой агрегат, пересчитанный на своей стороне, — это второй источник правды там, где он не нужен. - `id` модели обязателен: запись без него выпадает из массива. `name` при отсутствии становится равным `id`. Порядок моделей сохраняется — он приходит от приёмника. - `status` приводится к нижнему регистру, сверяется с объединением двух словарей и всегда возвращается в словаре v2: `up → ok`, `slow → issues`, `down → down`. Всё незнакомое становится `ok`, а при ненулевом `errors` — `single`, потому что зелёное состояние рядом с «ошибок 3» противоречит числу, которое печатается в той же строке. v2 четыре состояния гарантированы контрактом, так что ветка достижима только для v1, опечатки, враждебного ответа или коллектора новее этого модуля. - `buckets` не дополняются и не обрезаются до канонической длины: `1h` приходит с шестьюдесятью, остальные с сорока восемью. Запись без числового `from` выпадает — у неё нет места на оси времени, и придуманное место было бы ложью. Отсутствующий `count` — это ноль, то есть спокойная корзина. - `events` читаются строго: запись без числового `t` выпадает; `status` сохраняется только как настоящий HTTP-статус (100..599), потому что коллектор хранит `null` вместо статуса, а ноль нарисовался бы статусом, не знача ничего; необязательные поля **отсутствуют**, а не приходят пустыми строками. `lastError` той же формы и становится `null`, если у него нет числового `t` или нечего напечатать. - `uptime` сохраняется с точностью коллектора, до одного знака: округление 97,9 до 97 исказило бы число v1 так, что расхождение нельзя было бы отнести ни к чему. **Потолки.** Ответ рисуется в браузере, поэтому ни один массив не проходит насквозь: не больше 50 моделей, 200 корзин на модель (приходит 60 или 48), 25 событий на модель (коллектор отдаёт до 500), 100 элементов `byCode`, сообщение события до 500 символов, прочие строки до 128, любой счёт до миллиона, `minutesSinceLast` до года. Наблюдаемое из этих чисел — «60 корзин для часа и 48 для остальных» и «до 500 событий»; размер каталога в `config.json` текущего сервера — 12 моделей, но это его конфигурация, а не свойство контракта, поэтому потолок в 50 оставляет место росту. Важен не каждый потолок, а их произведение: худший ответ, который модуль способен вернуть, — около полутора мегабайт JSON, то есть то, что браузер переживёт. Модуль ничего не бросает в вызывающую сторону ни при каком входе: все четыре публичные функции защищены, в том числе от объекта с бросающим геттером. Тело, разобранное из JSON, такого содержать не может, — и обещание намеренно не зависит от того, что вызывающая сторона принесёт именно его. ## Откуда берётся список моделей «Вселенная» моделей — это каталог из `config.json` приёмника **плюс любой id модели, встретившийся в отчётах за период**. В каталоге текущего сервера двенадцать записей, поэтому `totalModels: 12` — это размер его конфигурации, а не наблюдение за трафиком. Отчёт без поля `model` не пропадает: он получает id `unknown` и имя «Без модели», и эта строка живёт в картине столько же, сколько любой другой отчёт. А вот отчёт с **выдуманным** id ведёт себя иначе, и это важно для отправляющей стороны: id, которого нет в каталоге, добавляется во вселенную и становится отдельной строкой на публичном дашборде — со своим именем, равным id, и со своим состоянием, — и остаётся там до 45 дней. Один диагностический запрос с собственным именем модели стоит публичному дашборду лишней строки на полтора месяца, поэтому отправлять стоит либо id из каталога провайдера, либо ничего. ## Почему картина текущего сервера пуста: диагноз **Node-приёмник принимает каждый отчёт и не находит ни одного при чтении.** Это не догадка по пустым ответам: расхождение видно в двух соседних функциях его `server.mjs`. Запись кладёт в документ `serverTs: Date.now()` — **число**. Чтение спрашивает `col.find({ serverTs: { $gte: new Date(sinceMs) } })` — **дату**. Сравнение по диапазону в MongoDB не пересекает границы BSON-типов, поэтому число никогда не удовлетворяет условию против даты, запрос возвращает пустой набор, и `buildView` честно рисует зелёную картину по нулю строк. Индекс `{ serverTs: -1 }` при этом создан и не помогает: расходится не индекс, а тип операнда в фильтре. Файловое хранилище той же болезни не имеет — оно сравнивает число с числом, — поэтому локальный прогон данные показывает, а развёрнутый нет. Проверка, а не рассуждение: `server.mjs` запускался локально с его собственным `config.json`, где `mongo.uri` пуст, то есть на файловом хранилище, и запрос `/api/view?range=30d` против 21 документа в `data/errors.json` вернул `storage: "file"`, `total: 21`, `healthy: false`, `totalModels: 14`. Тот же код, тот же путь данных — отличается только хранилище. Развёрнутый экземпляр на те же вопросы отвечает `storage: "mongo"`, `total: 0`. Дефект пережил переезд приёмника, значит дело было не в развёртывании и не в адресе, а в коде, который переехал вместе с ними. **Go-коллектор исправляет это построением, а не правкой.** Время хранится и сравнивается одним типом — целыми миллисекундами, — и в Go это проверяется компилятором, поэтому сама ошибка невыразима: положить число и спросить датой не получится. Заодно вид строится по времени события, что закрывает и вторую ошибку, описанную выше. Первый тест из списка покрытия v2 — «принятый отчёт немедленно виден в `/api/view`» — это регрессия именно на неё. Исходник коллектора: `rustvy-uptime/`. Два следствия для плагина, и они не меняются оттого, что причина найдена. Первое: **пустой зелёный ответ — это обычный ответ, а не вырожденный**, и именно его видит каждый пользователь первым, пока Go-коллектор не развёрнут. Страница настроек обязана рисовать его честно: «ошибок: 0», все модели в состоянии `ok`, ни одной последней ошибки. Плагин терпит дефект, а не обходит его — не досчитывает ничего от себя, не подменяет данные приёмника собственными локальными счётчиками и не выдаёт догадку за наблюдение. Локальные счётчики (`/state`) и данные приёмника описывают разные вещи: первые — эту установку, вторые — всех сразу, и смешивать их на одном экране значит показывать человеку число, которое не означает ни того, ни другого. Второе: формы `events`, `byCode`, `lastError` и всех новых полей зафиксированы контрактом, поэтому развёртывание Go-коллектора **не потребует выпуска плагина**. Заполненные события, состояния и `installs` доедут до настроек как есть — ровно в том виде, в котором их описывает этот документ. Нормализатор уже читает обе версии, так что переход происходит на стороне сервера и незаметен для пользователя. ## Документация приёмника расходится с его кодом `README.md` текущего сервера описывает `/api/view` как ответ с полями `healthy`, `total`, `byCode`, `byPlugin`, `buckets` (таймлайн из 48 корзин) и `recent` (последние 200 ошибок с описанием). Его `buildView` не производит ни `byPlugin`, ни `buckets` на верхнем уровне, ни `recent`: корзины лежат внутри каждой модели, а списка последних ошибок нет вовсе. След этого расхождения виден и в самом `server.mjs` — константа `MAX_RECENT = config.maxRecent ?? 200` читается из конфигурации и больше нигде не используется. Их README неточен и про срок хранения: «история обрезается до 45 дней» верно только для файлового хранилища, которое фильтрует строки при каждом сохранении; в MongoDB не удаляется ничего. Поэтому авторитетное описание ответа — код, а не README: для текущего сервера это `buildView` в `server.mjs`, для v2 — `tmp/uptime-contract-v2.md` и реализация в `rustvy-uptime/`. Читателю, который придёт сюда из их README, стоит знать, что список полей там описывает версию, которой уже нет. ## `/api/health` — дешёвая проверка доступности ```http GET /api/health 200 {"ok":true,"storage":"mongo"} ``` `storage` — это `'mongo'` или `'file'`. Выбор делается один раз при запуске: Mongo, если задан `mongo.uri` (или переменная `MONGODB_URI`) **и** соединение установилось за отведённые 5 секунд; иначе файловое хранилище с записью, отложенной на 400 мс. `storage: "file"` на развёрнутом экземпляре означает, что Mongo не поднялся, — и у текущего сервера это диагностически ценнее, чем кажется: именно файловое хранилище работает правильно, а Mongo теряет данные при чтении. Этот запрос стоит использовать там, где нужно отличить «приёмник недоступен» от «приёмник работает, но ошибок нет»: он дешевле `/api/view` и не несёт данных о моделях. Важно, что он **не** говорит об агрегации: живой и здоровый приёмник по-прежнему отвечает `total: 0` при принятых отчётах, так что `ok: true` нельзя читать как «данные есть». ## Адрес Отдельной настройки для чтения нет: все три адреса выводятся из одного `uptimeEndpoint`, в котором лежит адрес **отправки** отчётов. По умолчанию это `http://185.75.189.247:3789/api/report`, и из него получается: | Что | Как выводится | Адрес по умолчанию | |---|---|---| | отправка отчётов | `uptimeEndpoint` как есть | `http://185.75.189.247:3789/api/report` | | чтение статуса | то же начало, путь заменяется на `/api/view` | `http://185.75.189.247:3789/api/view` | | ссылка на монитор | то же начало плюс `/` | `http://185.75.189.247:3789/` | Отсюда два полезных свойства. Одна правка настройки перенаправляет отправку, чтение и ссылку на монитор одновременно, и они не могут разъехаться. И порт в примере — не часть контракта: вывод работает от разобранного адреса, поэтому переезд приёмника на другой хост и порт ничего не ломает. Это не предположение — приёмник уже переезжал, и форма ответов от переезда не изменилась. Настройка `uptimeEndpoint` в `\.dsh-rustvy\config.json` и переменная окружения `DSH_RUSTVY_UPTIME_ENDPOINT` перенаправляют всё это без выпуска; адрес перечитывается, а не запоминается при монтировании. Переменная окружения важнее настройки, но обе проходят проверку на разборчивый http-адрес: значение, которое не разбирается, отбрасывается, и в силе остаётся предыдущее. Сам вывод адресов живёт в половине хоста (`lib/host.js`); в `lib/uptime.js` работы с адресами нет вовсе, и модуль проверяет только `range`. Поскольку значки читают `1h`, а панель `24h`, два запроса к двум окнам идут постоянно, и оба кэшируются на стороне хоста по отдельности: один слот на окно означал бы, что в момент аварии панель гасит значки или наоборот. Открытый HTTP по голому IP — осознанное решение на данный момент, по той же причине, что и в [docs/error-report.md](): данные публичны и обезличены, поэтому шифрование защищает здесь целостность и подлинность, а не тайну. Как только появятся домен и сертификат, все три адреса стоит перевести на `https` одной правкой настройки. ## Что должно быть покрыто тестами Из списка покрытия контракта плагину принадлежат три утверждения, и они стоят здесь, потому что каждое из них легко потерять при правке: нормализатор принимает и новый словарь состояний, и старый (`up`/`slow`/`down`), и неизвестное значение; значки берутся из окна `1h`, а панель по умолчанию из `24h`, и переключение панели на 30 дней не перекрашивает значки, у которых в подсказке «за последний час»; `affectedPct: null` рисуется прочерком, а не нулём.