- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
86 lines
7.3 KiB
Markdown
86 lines
7.3 KiB
Markdown
# Ответ точек несёт измеренный род и его применимость к отданному ряду
|
||
|
||
- **Дата:** 2026-08-04
|
||
- **Источник:** openspec/changes/archive/2026-08-04-tochki-metriki-za-period/design.md
|
||
|
||
## Решение
|
||
|
||
Конверт ответа маршрута точек несёт `aggregation` **объектом**
|
||
`{style, applicable, last_hour}`, а не строкой с применённой свёрткой:
|
||
|
||
- `style` — измеренный род метрики, тот же словарь и то же имя, что у каталога;
|
||
- `applicable` — применим ли объявленный род к **отданному ряду**;
|
||
- `last_hour` — ярлык самого свежего часа окна измерения.
|
||
|
||
`docs/architecture.md` до этого изменения обещал `"aggregation": "sum"` —
|
||
строку. Решение её **пересматривает**: строка называет применённое и молчит об
|
||
основании.
|
||
|
||
Отвергнуто и названо поимённо: поле `applied` с именем применённой свёртки
|
||
(выводится из `style` и `bucket` тем же инвариантом; как строка неверно
|
||
описывает свёртку мгновенной метрики, у которой по архитектуре «среднее с
|
||
`min`/`max` рядом»); полное основание каталога (`hours`, `compared`, `agreeing`,
|
||
`conflicting`, `first_hour`) в конверте точек — второй экземпляр факта, обязанный
|
||
сходиться с первым.
|
||
|
||
## Почему
|
||
|
||
**Род есть свойство метрики, а слой — свойство ряда, и их сочетание бывает
|
||
опасным.** Конверт `{"layer": "raw", "style": "cumulative"}` законен и штатен:
|
||
правило выбора слоя при равном охвате предпочитает самый мелкий. Инвариант
|
||
«нижний слой HAE не суммируется никогда» система соблюдает, ничего не складывая,
|
||
— но потребитель об инварианте не знает, а сумма по нижнему слою завышает втрое
|
||
(находка 34 разведки). Разрыв построен проходом `review-rubric` на предложении,
|
||
до кода:
|
||
|
||
> Конверт `{"layer": "raw", "aggregation": {"style": "cumulative"}}` законен,
|
||
> штатен — и он прямо приглашает главного потребителя (агента с ограниченным
|
||
> контекстом) сложить ряд самому. Система при этом свёртки не делает, инвариант
|
||
> формально цел; результат у потребителя завышен, а решение по нему уже принято.
|
||
|
||
`applicable: false` — та самая оговорка, которая едет вместе с данными.
|
||
|
||
**`last_hour` — единственный след замершего окна.** Род считается по 48 самым
|
||
свежим **общим** часам, а не по последним 48 часам календаря: выключенная
|
||
минутная автоматизация HAE останавливает пополнение общих часов, окно замирает и
|
||
продолжает объявлять род.
|
||
|
||
**Литература расколота, и обе стороны названы.** Род **вместе с данными**:
|
||
Google Cloud Monitoring объявляет `metricKind` и `valueType` в каждом объекте
|
||
`TimeSeries` ответа, а не только в дескрипторе метрики; CloudWatch
|
||
`GetMetricData` кладёт `StatusCode` (`Complete` / `PartialData`) рядом с рядом —
|
||
оговорка едет с данными, а не оставляется клиенту на вывод; Home Assistant
|
||
`statistics_during_period` держит `start` и `end` в ответе **всегда**,
|
||
независимо от запрошенных `types`. Род **отдельно от данных**: Prometheus отдаёт
|
||
`{resultType, result}` без единого слова о типе, а тип живёт в
|
||
`/api/v1/metadata`; Graphite render не объявляет ничего. Второе отвергнуто по
|
||
измеримой причине: клиент обязан сделать второй запрос, а до тех пор не
|
||
отличает «род известен» от «род не измерен», — и согласованности между двумя
|
||
ответами всё равно нет, потому что род есть функция **окна**, а окно едет с
|
||
часами. Принцип HealthKit `HKStatistics` («род не тот — свёртки нет») взят,
|
||
механизм неприменим: у нас стиль источником не объявлен.
|
||
|
||
## Последствия
|
||
|
||
- `+` Потребитель видит не только число, но и на каком основании его можно
|
||
сворачивать, без второго запроса и без знания инвариантов проекта.
|
||
- `+` Форма объявлена **до** того, как её скопируют свёртка по сетке, порог
|
||
неполного ведра, тренировки, записи и MCP. После копирования это была бы не
|
||
развилка, а археология.
|
||
- `−` Поле `applicable` избыточно по построению: клиент, знающий правило «нижний
|
||
слой HAE не суммируется», вывел бы его из `style` и `layer`. Взято сознательно
|
||
— правило принадлежит нам, и молчаливо перекладывать его на потребителя
|
||
дороже, чем поле.
|
||
- `−` Чтобы разобрать, **почему** род `unknown`, придётся спросить каталог:
|
||
полное основание живёт там в одном экземпляре.
|
||
- `−` Род в конверте точек и род в каталоге считаются в разные моменты и у
|
||
клиента, сравнивающего два ответа, могут разойтись. Это свойство измерения, а
|
||
не дефект; ровно поэтому `last_hour` едет вместе с родом.
|
||
|
||
## Открыто, решает владелец
|
||
|
||
**Машинно-различимый код причины отказа.** Тело отказа несёт только
|
||
человекочитаемую строку, и агент не отличит «зона не указана» от «слой
|
||
незнаком» иначе, чем разбором русского текста. Правило общее для всех маршрутов
|
||
и меняет `errorWire`, то есть и контракт приёма, — сюда не взято.
|