Files
healthlog/openspec/changes/archive/2026-08-04-tochki-metriki-za-period/specs/points/spec.md
T
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

434 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## ADDED Requirements
### Requirement: Точки метрики за период отдаются одним запросом
Система SHALL отдавать значения одной метрики за запрошенный период по
`GET /api/v1/metrics/{name}` с обязательными параметрами `from` и `to`, не
требуя от потребителя доступа к файлу базы и не требуя второго запроса за
смыслом отданных чисел.
Имя метрики берётся из пути **ровно один раз декодированным** и далее
дословно: система его не нормализует и не сверяет со списком известных — имена
приходят из тела доставки и хранилищу не принадлежат. Повторное декодирование
MUST NOT происходить: имя, само содержащее процентную последовательность
(`a%41b`), после второго декодирования становится именем **другой** метрики, и
маршрут отвечает `200` с её данными. Имя, которое путём не
выражается (пустое), маршрутом недостижимо; каталог такую метрику показывает, и
это названная цена адресации именем в пути, а не молчание.
Маршрут закрыт токеном чтения тем же контуром, что и каталог, и MUST NOT
перехватывать маршрут каталога `GET /api/v1/metrics`.
#### Scenario: Период запрошен
- **GIVEN** метрика, у которой в витрине есть объекты внутри периода
- **WHEN** потребитель запрашивает `GET /api/v1/metrics/{name}?from=…&to=…` с
действующим токеном чтения
- **THEN** ответ `200` несёт значения этой метрики за этот период
#### Scenario: Каталог остаётся достижим
- **WHEN** потребитель запрашивает `GET /api/v1/metrics`
- **THEN** отвечает каталог, а не маршрут точек
#### Scenario: Имя метрики закодировано в пути
- **GIVEN** метрика, чьё имя содержит символ, требующий процентного кодирования
- **WHEN** потребитель запрашивает её точки, закодировав имя
- **THEN** отвечают точки этой метрики, а не пустой ряд
#### Scenario: Имя метрики само содержит процентную последовательность
- **GIVEN** метрики с именами `a%41b` и `aAb` в витрине
- **WHEN** потребитель запрашивает `a%41b`, закодировав имя
- **THEN** отвечают точки `a%41b`, а не точки `aAb`
#### Scenario: Токен чтения отсутствует
- **WHEN** запрос точек приходит без действующего токена чтения при непустом
списке токенов чтения
- **THEN** ответ `401`, и тело ответа собрано объявленным типом транспорта
### Requirement: Конверт ответа объявляет слой, род свёртки, его применимость и границу окна измерения
Система SHALL сопровождать точки конвертом, в котором ВСЕГДА присутствуют поля
`metric`, `from`, `to`, `layer`, `bucket`, `aggregation` и `points`, а объект
`aggregation` MUST всегда нести поля `style`, `applicable` и `last_hour`. Поле,
которому нечего сообщить, MUST уезжать как `null` и MUST NOT исчезать из ответа
и MUST NOT подменяться нулевым значением своего типа.
Смысл полей:
- `metric` — имя метрики, как оно пришло путём после декодирования;
- `from` и `to` — фактически применённые границы периода, нормализованные к UTC
в RFC 3339;
- `layer` — слой, из которого собран ряд;
- `bucket` — сетка свёртки; `null`, когда свёртки не было;
- `aggregation.style` — измеренный род метрики (`cumulative` / `instant` /
`unknown`) тем же правилом и тем же окном, что у каталога;
- `aggregation.applicable` — применим ли объявленный род к **отданному ряду**;
- `aggregation.last_hour` — ярлык самого свежего часа окна измерения; `null`,
когда окно пусто;
- `points` — ряд, пустой коллекцией `[]`, а не `null`.
`last_hour` обязателен именно потому, что окно измерения считается в **общих
часах**, а не в часах календаря: выключенная минутная автоматизация HAE
останавливает пополнение общих часов, окно замирает и продолжает объявлять род.
Без `last_hour` у клиента нет ни одного способа это увидеть.
#### Scenario: Свёртки не было
- **WHEN** маршрут отвечает на запрос без сетки
- **THEN** в ответе присутствуют `metric`, `from`, `to`, `layer`, `bucket`,
`aggregation` со всеми тремя полями и `points`, причём `bucket` равен `null`
#### Scenario: Окно измерения замерло
- **GIVEN** метрика, чьи самые свежие общие часы старше конца запрошенного
периода
- **WHEN** маршрут отвечает
- **THEN** `aggregation.last_hour` называет ярлык самого свежего общего часа, а
не конец периода и не текущее время
#### Scenario: Окна измерения нет вовсе
- **GIVEN** метрика, у которой нет ни одного общего часа минутного и часового
слоёв
- **WHEN** маршрут отвечает
- **THEN** `aggregation.style` равен `"unknown"`, а `aggregation.last_hour`
равен `null`
### Requirement: Род, объявленный в ответе, едет вместе со своей применимостью к отданному ряду
Система SHALL объявлять `aggregation.applicable` равным `false` всегда, когда
объявленный род нельзя применить к отданному ряду, и MUST считать неприменимым
род `cumulative` на **нижнем слое HAE** (`raw`), род `unknown` и любой род при
отсутствии выбранного слоя. Система MUST NOT досчитывать свёртку по
неизмеренному роду и MUST NOT выражать неизвестность отсутствием поля.
Поле существует потому, что род — свойство **метрики**, а слой — свойство
**отданного ряда**, и их сочетание бывает опасным: нижний слой HAE это
интерполяция, а не сэмплы, и сумма по нему завышает втрое. Конверт, объявляющий
`cumulative` рядом с рядом из `raw` и молчащий о неприменимости, приглашает
потребителя сложить интерполяцию самостоятельно — система при этом не
складывает ничего, а решение у потребителя уже принято по завышенному числу.
#### Scenario: Род метрики не измерен
- **GIVEN** метрика, у которой род агрегации не измерен
- **WHEN** потребитель запрашивает её точки за период
- **THEN** ответ `200` несёт точки как есть, `aggregation.style` равен
`"unknown"`, а `aggregation.applicable` равен `false`
#### Scenario: Накопительная метрика отдана нижним слоем
- **GIVEN** метрика с измеренным родом `cumulative`, ряд которой собран из слоя
`raw`
- **WHEN** маршрут отвечает
- **THEN** `aggregation.style` равен `"cumulative"`, а
`aggregation.applicable` равен `false`
#### Scenario: Род применим
- **GIVEN** метрика с измеренным родом, ряд которой собран из слоя `minute`,
`hour` или `sample`
- **WHEN** маршрут отвечает
- **THEN** `aggregation.applicable` равен `true`
### Requirement: Слой выбирается по охвату точек внутри периода и не меняется внутри ответа
Система SHALL собирать ряд ровно из одного слоя и MUST NOT склеивать в одном
ответе точки разных слоёв. При отсутствии параметра `layer` система SHALL брать
слой с наибольшим **охватом** внутри запрошенного периода, а при равном охвате —
самый мелкий слой в порядке `sample`, `raw`, `minute`, `hour`, `day`.
**Охват меряется метками точек, а не часами объектов.** Охват слоя есть длина
пересечения отрезка `[первая метка слоя, последняя метка слоя]` с запрошенным
периодом; слой с пустым пересечением из выбора MUST выбывать. Мера названа
именно так потому, что объекты адресуются часом, а ряд отбирается точной меткой:
на периоде короче часа множество «слоёв с объектами» и множество «слоёв с
точками» расходятся, и слой, выбранный по часам, отдал бы пустой ряд при
непустых данных соседнего слоя.
Охват сравнивается между слоями, а не с запрошенным периодом: границы данных
законно короче запроса и законно имеют дыры внутри.
Заданный параметр `layer` отменяет правило целиком: система SHALL отдавать
запрошенный разрез, в том числе пустым, и SHALL называть его в ответе.
#### Scenario: Мелкий слой охватывает меньше крупного
- **GIVEN** метрика, у которой внутри периода нижний слой покрывает несколько
дней, а часовой — весь период
- **WHEN** потребитель запрашивает период без параметра `layer`
- **THEN** ряд собран из часового слоя, и `layer` называет его
#### Scenario: Охваты равны
- **GIVEN** метрика, у которой два слоя охватывают внутри периода одно и то же
- **WHEN** потребитель запрашивает период без параметра `layer`
- **THEN** ряд собран из более мелкого слоя
#### Scenario: Период короче часа
- **GIVEN** период внутри одного часа, в котором у крупного слоя есть объект без
единой точки внутри периода, а у мелкого — точки внутри периода
- **WHEN** потребитель запрашивает период без параметра `layer`
- **THEN** ряд собран из мелкого слоя и не пуст
#### Scenario: Слой задан явно
- **WHEN** потребитель задаёт `layer` явно
- **THEN** ряд собран из этого слоя, даже если другой слой охватывает период
шире, и `layer` в ответе равен запрошенному
#### Scenario: Имя слоя незнакомо
- **WHEN** параметр `layer` присутствует, а его значение не является одним из
`sample`, `raw`, `minute`, `hour`, `day`
- **THEN** ответ `400`, и умолчание молча не подставляется
#### Scenario: Значение слоя пусто
- **WHEN** параметр `layer` присутствует с пустым значением
- **THEN** ответ `400`, а не автоматический выбор слоя
### Requirement: Период задаётся явно и разбирается строго
Система SHALL требовать оба параметра `from` и `to`, SHALL принимать их только в
формате RFC 3339 с явным смещением зоны и SHALL толковать период как
полуинтервал `[from, to)`. Отсутствующий параметр, неразбираемое значение,
значение без явной зоны, `from >= to` и граница, чей год после приведения к UTC
выходит за диапазон 1–9999, MUST давать `400` с человекочитаемым сообщением,
которое MUST NOT содержать значений из запроса, и MUST NOT подменяться
умолчанием.
Граница за пределами четырёхзначного года отвергается потому, что объекты
адресуются строкой RFC 3339 и границы сравниваются лексикографически:
`9999-12-31T23:00:00-07:00` становится `10000-01-01T06:00:00Z`, который как
строка меньше любой настоящей метки, — и запрос молча отдал бы пустой ряд при
непустых данных.
Полуинтервал взят потому, что соседние окна обязаны склеиваться без двойного
счёта граничной точки. Явная зона обязательна потому, что вопрос «в какой зоне
считать сутки» в проекте открыт: принять голую дату значило бы выбрать зону за
клиента молча.
Параметр `bucket` этой версией маршрута не поддержан: система SHALL отвечать
`400` на его присутствие с любым значением и MUST NOT игнорировать его молча.
Молчаливое игнорирование дало бы клиенту, попросившему суточную сетку, полный
минутный ряд — зеркало того самого промаха, ради которого соседняя задача
различает «указали сетку» как информацию и как защиту. Прочие незнакомые
параметры запроса система игнорирует.
#### Scenario: Параметр периода отсутствует
- **WHEN** в запросе нет `from` или нет `to`
- **THEN** ответ `400`, и период умолчанием не подставляется
#### Scenario: Метка времени без зоны
- **WHEN** значение `from` или `to` записано без явного смещения зоны
- **THEN** ответ `400`
#### Scenario: Границы периода вывернуты
- **WHEN** `from` не раньше `to`
- **THEN** ответ `400`
#### Scenario: Граница выходит за четырёхзначный год
- **WHEN** граница периода после приведения к UTC попадает в год за пределами
диапазона 19999
- **THEN** ответ `400`, а не `200` с пустым рядом
#### Scenario: Запрошена сетка свёртки
- **WHEN** в запросе присутствует параметр `bucket`
- **THEN** ответ `400`, называющий, что свёртка ещё не поддержана
#### Scenario: Точка стоит ровно на границе
- **GIVEN** точки с метками ровно в `from` и ровно в `to`
- **WHEN** маршрут отвечает
- **THEN** точка на `from` в ответе есть, а точка на `to` — нет
### Requirement: Значение точки уезжает дословно, нормализовано только время
Система SHALL отдавать каждую точку объектом `{ts, ts_end, tz_offset, units,
values}`, где `values` MUST быть содержимым точки ровно в том виде, в каком его
сохранило хранилище, без переименования полей, пересчёта единиц, отбрасывания
незнакомого и **без экранирования**. `ts` и `ts_end` MUST быть нормализованными
к UTC метками начала и конца координаты точки; у точки-измерения `ts_end` равен
`ts`. `tz_offset` и `units` MUST принадлежать самой точке: смещение исходной
зоны — её собственное, единицы — того объекта, из которого точка прочитана.
Точки MUST идти по возрастанию `(ts, ts_end)`. Порядок этим определён
однозначно: пара `(начало, конец)` внутри одного слоя одной метрики есть ключ
идентичности, и двух точек с равной парой в витрине не существует.
Принадлежность точки периоду определяется её **началом**: точка-интервал,
начавшаяся раньше `from`, в ответ не входит, даже если её конец лежит внутри
периода. Правило то же, каким час объекта берётся по началу точки; цена названа
вслух — запрос «сон за ночь с полуночи» не увидит эпизод, начавшийся до неё.
`ts_end` присутствует потому, что идентичность точки — интервал, а не метка: под
одной меткой лежит до трёх записей сна, и конверт с одним `ts` предлагал бы
клиенту различать их, разбирая дословное содержимое.
#### Scenario: Точка-интервал
- **GIVEN** точка, у которой конец координаты отличается от начала
- **WHEN** маршрут отвечает
- **THEN** `ts_end` отличается от `ts` и называет конец координаты
#### Scenario: Содержимое не переписывается
- **WHEN** точка уезжает клиенту
- **THEN** `values` побайтово совпадает с сохранённым содержимым точки
#### Scenario: Содержимое несёт символы, которые сериализатор склонен экранировать
- **GIVEN** точка, чьё содержимое несёт `&`, `<` или `>`
- **WHEN** маршрут отвечает
- **THEN** эти символы уезжают как есть, а не escape-последовательностями
### Requirement: Пустота периода — успех, а не отсутствие ресурса
Система SHALL отвечать `200` на запрос метрики, у которой нет данных в
запрошенном периоде, и MUST отдавать `points` пустым списком. Система MUST NOT
отвечать `404` по признаку «нет данных».
`layer` равен `null` **только** тогда, когда слой выбирала система и выбирать
было не из чего. Заданный клиентом `layer` уезжает в ответе всегда, даже когда
ряд пуст: иначе клиент, спросивший разрез поимённо, не отличил бы «этого разреза
за период нет» от «маршрут проигнорировал параметр».
Различать опечатку в имени метрики и честную пустоту — работа каталога: список
имён маршруту точек не принадлежит.
#### Scenario: Данных за период нет и слой не задан
- **WHEN** у метрики нет ни одной точки внутри периода и параметр `layer` не
задан
- **THEN** ответ `200`, `points` равен `[]`, `layer` равен `null`
#### Scenario: Данных за период нет, а слой задан
- **WHEN** у метрики нет точек запрошенного слоя внутри периода
- **THEN** ответ `200`, `points` равен `[]`, `layer` равен запрошенному
#### Scenario: Имени метрики в витрине нет вовсе
- **WHEN** запрошено имя метрики, которого в витрине нет
- **THEN** ответ `200` с пустым `points`, а не `404`
### Requirement: Ответ снимается одним снимком витрины
Система SHALL читать охваты слоёв, точки выбранного слоя и объекты окна
измерения **в одной транзакции чтения**. Смесь «слой выбран до коммита свёртки,
точки прочитаны после» и «род измерен на третьем состоянии» дала бы ответ,
внутренне противоречивый и неотличимый от обычного свежего; пара проб версии
такой ответ обнаруживает, но не предотвращает — она снимает метку, а тело всё
равно уезжает.
#### Scenario: Свёртка коммитит во время чтения
- **WHEN** фоновая свёртка коммитит в витрину между выбором слоя и чтением точек
- **THEN** ответ собран из одного снимка витрины, а не из двух состояний
### Requirement: Метка ответа включает канонизированную форму запроса и горизонт измерения
Система SHALL помечать ответ меткой, область действия которой MUST быть
функцией канонизированной формы запроса — имени метрики, границ периода и слоя —
и которая MUST включать **горизонт измерения**, огрублённый до часа, наравне с
версией витрины. При невозможности подписать ответ система SHALL отдавать его
без метки, а не отказом.
Область MUST быть **ограничена по длине и не выносить значения запроса наружу**.
Имя метрики приходит из чужого тела дословно и ничем не ограничено: положенное
в метку как есть, оно даёт заголовок в тысячи байт, а кавычка внутри имени по
HTTP кончает метку — разбор обрежет её там, и условный запрос по такой метрике
не сработает никогда. Тот же вход в лог уезжает обрезанным, и у метки предел
обязан быть по той же причине.
Канонизация границ MUST сохранять точность, по которой отбираются точки: два
запроса, различающиеся долей секунды, дают разные ряды, и одна метка на них
подтвердила бы неизменность чужого набора данных.
Горизонт входит в метку по той же причине, по какой он входит в метку каталога:
род объявляется по окну, ограниченному сверху `текущее время + запас`, и метка
из будущего, лежащая в витрине, въезжает в окно **без единого коммита**, меняя
`aggregation.style` и `last_hour`. Метка без горизонта подтвердила бы
неизменность ответа, в котором род уже перевернулся. Форма запроса входит в
область действия потому, что ответ этого маршрута есть функция параметров: метка
без них однажды подтвердила бы неизменность чужого набора данных. Род и метка
MUST сниматься с одного горизонта.
Цена названа: один полный ответ в час на потребителя при неизменившейся витрине.
#### Scenario: Запросы различаются параметром
- **WHEN** два запроса при одном состоянии витрины различаются метрикой,
границей периода или слоем
- **THEN** метки их ответов различны
#### Scenario: Запросы различаются только формой записи
- **WHEN** два запроса при одном состоянии витрины задают одно и то же разной
записью смещения зоны
- **THEN** метки их ответов совпадают
#### Scenario: Границы различаются долей секунды
- **WHEN** два запроса при одном состоянии витрины различаются границей периода
на долю секунды и дают разные ряды
- **THEN** метки их ответов различны
#### Scenario: Имя метрики длинное или содержит кавычку
- **WHEN** запрошена метрика, чьё имя длиной в тысячи символов или содержит
кавычку
- **THEN** метка ответа остаётся короткой и не содержит имени метрики
#### Scenario: Горизонт перешёл через час
- **WHEN** горизонт измерения перешёл через границу часа при неизменной версии
витрины
- **THEN** метка ответа изменилась
#### Scenario: Витрина изменилась во время чтения
- **WHEN** пробы версии витрины разошлись
- **THEN** ответ уходит целиком и без метки
### Requirement: Исход маршрута точек наблюдаем, а данные о здоровье в лог не уезжают
Система SHALL писать один логирующий чекпоинт исхода на доменной границе
маршрута и MUST NOT доводить до записи лога значения точек, содержимое `values`
и границы запроса; имя метрики MUST быть обрезано по тому же пределу, что у
каталога. Отмена запроса клиентом и занятость базы MUST писаться уровнем
`DEBUG`, а настоящий отказ хранилища — уровнем `ERROR`.
Предупреждения измерения — противоречащий род и данные, помеченные будущим, —
остаются привилегией каталога: маршрут точек их MUST NOT повторять. Агент
опрашивает по расписанию, и `WARN` на каждый опрос обесценил бы уровень ровно
так же, как обесценила бы его строка на каждый `304`.
#### Scenario: Клиент оборвал запрос
- **WHEN** потребитель обрывает запрос точек по своему тайм-ауту
- **THEN** запись об этом уходит уровнем `DEBUG`, а не `ERROR`
#### Scenario: Метрика с противоречащим родом запрошена многократно
- **WHEN** потребитель повторно запрашивает точки метрики, у которой род
противоречив
- **THEN** маршрут точек предупреждений об этом не пишет
#### Scenario: Тело ответа оборвалось на середине
- **WHEN** запись тела ответа отказала после того, как код ответа уже отдан
- **THEN** отказ записи оставляет собственный чекпоинт и не проходит молча под
видом успешного ответа