httpapi: точки метрики за период отдаются одним запросом

- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
This commit is contained in:
av
2026-08-04 18:46:45 +03:00
parent b819b77f62
commit 29ca8d415c
36 changed files with 4721 additions and 58 deletions
+316
View File
@@ -0,0 +1,316 @@
package httpapi
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/points"
)
// ФОРМА ПРОВОДА ТОЧЕК. Объявлена здесь и только здесь — доменные типы
// `internal/points` `json`-тегов не несут и до сериализации не доезжают.
// Образец и его цена — `internal/httpapi/catalog.go`; выбирать заново не нужно.
// pointsResponse — оболочка ответа точек.
//
// Поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен
// выводить исход наличием или отсутствием поля. Отсюда указатели у `layer` и
// `bucket` — `null` означает «слоя нет» и «свёртки не было», а не пустую
// строку, которая была бы законным именем слоя.
type pointsResponse struct {
Metric string `json:"metric"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Layer *string `json:"layer"`
// Bucket — сетка свёртки. Всегда `null` в этой версии маршрута: свёртку
// делает соседняя задача. Поле объявлено сразу, потому что менять форму
// конверта после того, как его скопировали четыре маршрута, — не развилка,
// а археология.
Bucket *string `json:"bucket"`
Aggregation seriesAggregation `json:"aggregation"`
Points []pointWire `json:"points"`
}
// seriesAggregation — род свёртки вместе с его применимостью и свежестью.
//
// `applicable` не украшение: род — свойство МЕТРИКИ, слой — свойство ОТДАННОГО
// РЯДА, и сочетание `{"layer": "raw", "style": "cumulative"}` законно, штатно и
// прямо приглашает потребителя сложить интерполяцию самому. Система при этом
// ничего не складывает, а решение у потребителя уже принято по завышенному
// втрое числу.
//
// `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в
// ОБЩИХ часах, а не в часах календаря: выключенная минутная автоматизация HAE
// останавливает их пополнение, окно замирает и продолжает объявлять род.
// Единственный след этого — вот это поле.
type seriesAggregation struct {
Style string `json:"style"`
Applicable bool `json:"applicable"`
LastHour *time.Time `json:"last_hour"`
}
// pointWire — точка ряда на проводе.
//
// `values` уезжает СЫРЫМ JSON: точки хранятся дословно, и переписывать их в тип
// провода значило бы нарушить инвариант. Конверт вокруг значения при этом
// объявлен и нормализован, как того требует «форма Apple не транслируется».
//
// `ts_end` есть потому, что идентичность точки — интервал, а не метка: под
// одной меткой лежит до трёх записей сна. У точки-измерения он равен `ts`.
type pointWire struct {
TS time.Time `json:"ts"`
TSEnd time.Time `json:"ts_end"`
TZOffset int `json:"tz_offset"`
Units string `json:"units"`
Values json.RawMessage `json:"values"`
}
// pointsWire переводит ряд домена в форму провода.
//
// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни
// часов. Версия снимка сюда не идёт — она уезжает в `ETag`.
func pointsWire(s points.Series) pointsResponse {
out := pointsResponse{
Metric: s.Metric,
From: s.From,
To: s.To,
Aggregation: seriesAggregation{
Style: s.Style.String(),
Applicable: s.Applicable,
// Указатель переносится КАК УКАЗАТЕЛЬ: разыменование дало бы
// `0001-01-01T00:00:00Z` там, где окна не было, а правдоподобная
// дата в ответе неотличима от настоящей.
LastHour: s.LastHour,
},
// Непустой срез, а не nil: nil сериализуется в `null`, и клиент
// прочитал бы «поля нет» вместо «точек нет».
Points: make([]pointWire, 0, len(s.Points)),
}
if s.Layer != "" {
layer := s.Layer
out.Layer = &layer
}
for _, p := range s.Points {
out.Points = append(out.Points, pointWire{
TS: p.TS,
TSEnd: p.End,
TZOffset: p.OffsetSeconds,
Units: p.Units,
Values: p.Raw,
})
}
return out
}
// pointsScope — область действия метки ответа: ХЕШ канонизированной формы
// запроса.
//
// Живёт В ТРАНСПОРТЕ, и это решение, а не случайность: у каталога область тоже
// транспортная (`scopeMetrics`), а помощник `etag` прямо просит маршрут положить
// сюда канонизированную форму запроса. Считай её домен — у одного понятия
// оказалось бы два дома, и три следующих маршрута выбирали бы между ними
// монетой. Заодно домен перестал бы носить транспортный артефакт: префикс и hex
// — это форма метки HTTP, а форму провода объявляет транспорт (ADR).
//
// Метка действительна в пределах ОДНОГО набора данных, а ответ этого маршрута
// есть функция параметров. Метка без них однажды подтвердила бы неизменность
// чужого набора — молча и без следов.
//
// Канонизация: границы приводятся к UTC в RFC 3339 с ПОЛНОЙ точностью. Полной,
// а не посекундной, потому что отбор точек идёт по полной метке: два запроса,
// различающиеся долей секунды, дают разные ряды, и одна область на них
// означала бы `304` на чужом наборе данных. Зона при этом канонизируется —
// одно и то же время, записанное разными смещениями, даёт одну область.
//
// Слой берётся ЗАПРОШЕННЫЙ, а не выбранный: областью является запрос, а смену
// выбранного слоя от новых данных ловит версия витрины.
//
// ХЕШ, а не сама форма, и обе причины из `docs/security.md`. Имя метрики
// приходит из чужого тела дословно и ничем не ограничено — в заголовке ответа
// оно дало бы `ETag` в тысячи байт, притом что тот же вход в лог уезжает
// обрезанным. И оно законно содержит кавычку, которая по RFC 9110 кончает
// метку: собственный `scanETag` проекта обрезал бы её ровно там, и условный
// запрос по такой метрике не сработал бы никогда, а симптом увёл бы отладку в
// помощника. Длина впереди остаётся внутри хешируемой строки: без неё имя с
// разделителем склеилось бы с соседним полем.
func pointsScope(req points.Request) string {
canonical := fmt.Sprintf("%d:%s|%s|%s|%s",
len(req.Metric), req.Metric,
req.From.UTC().Format(time.RFC3339Nano),
req.To.UTC().Format(time.RFC3339Nano),
req.Layer)
sum := sha256.Sum256([]byte(canonical))
return "points." + hex.EncodeToString(sum[:scopeBytes])
}
// scopeBytes — сколько байтов хеша попадает в область.
//
// Шестнадцать: 128 бит, то есть столкновение неотличимо от невозможного, а
// заголовок остаётся короче метки каталога. Хеш здесь не криптографический
// секрет — он ограничитель длины и экранирование разом.
const scopeBytes = 16
// handlePoints отдаёт точки метрики за период.
func (a *api) handlePoints(w http.ResponseWriter, r *http.Request) {
req, msg := parsePointsRequest(r)
if msg != "" {
// Отказ до чтения витрины: невозможный запрос не должен стоить снимка.
// В сообщении нет ни одного значения из запроса — оно уедет клиенту, а
// в запросе имя метрики и границы периода.
writeError(w, http.StatusBadRequest, msg)
return
}
series, err := a.points.Series(r.Context(), req)
if err != nil {
// Исход операции логирует доменный слой, транспорт только переводит его
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
// в нём имена колонок и форма запроса.
writeError(w, http.StatusInternalServerError, "ряд точек не собрался")
return
}
// Область действия метки — канонизированная форма запроса: ответ этого
// маршрута есть функция параметров, и метка без них однажды подтвердила бы
// неизменность чужого набора данных. Горизонт измерения в метке уже есть —
// его кладёт домен вместе с версией витрины.
setReadHeaders(w, etag(pointsScope(req), series.Version))
if err := writeJSON(w, http.StatusOK, pointsWire(series)); err != nil {
// Тело оборвалось на середине: дедлайн записи, ушедший клиент, полный
// буфер посредника. Код ответа уже отдан, и `accessLog` напишет `200` —
// то есть единственный канал наблюдаемости сообщит успех о неотданном
// ответе. Ряд ничем не ограничен по размеру (предел — соседняя задача),
// поэтому случай не гипотетический: неделя нижнего слоя это 280 МиБ.
//
// WARN, а не DEBUG, и это исправление находки эксплуатационного прохода:
// боевой уровень логирования — `info` (`config.docker.toml`), то есть
// запись уровня `DEBUG` не прошла бы фильтр НИКОГДА, и единственный
// признак недоставленного тела не существовал бы в проде вовсе.
// Обесценивания уровня здесь нет: событие не периодическое — оно
// означает, что потребитель получил битый JSON.
//
// Значений точек и границ запроса в записи нет; имя метрики обрезано.
a.log.WarnContext(r.Context(), "points response truncated",
"capability", "query", "metric", catalog.ClipMetric(req.Metric), "error", err)
}
}
// parsePointsRequest разбирает параметры маршрута точек.
//
// Второй возврат — человекочитаемая причина отказа; пустая строка означает, что
// запрос принят. Строка, а не ошибка: она целиком уезжает клиенту, поэтому
// обязана быть составлена здесь и не содержать ни одного значения из запроса.
func parsePointsRequest(r *http.Request) (points.Request, string) {
q := r.URL.Query()
// Свёртка по сетке ещё не поддержана, и молчать об этом нельзя: клиент,
// попросивший суточную сетку и получивший полный минутный ряд, заметил бы
// подмену, только пересчитав точки. Это зеркало того самого промаха, ради
// которого «сетка задана явно» вообще различается.
if q.Has("bucket") {
return points.Request{}, "свёртка по сетке ещё не поддержана: параметр bucket не принимается"
}
from, msg := parseBound(q, "from")
if msg != "" {
return points.Request{}, msg
}
to, msg := parseBound(q, "to")
if msg != "" {
return points.Request{}, msg
}
if !from.Before(to) {
return points.Request{}, "период пуст: from должен быть строго раньше to"
}
// `Has`, а не `Get() != ""`, и симметрично `bucket`. Пустое значение
// (`?layer=`) — самый частый способ промахнуться: шаблон клиента с
// невыставленной переменной. Разбор по непустоте молча включил бы
// автоматический выбор, и клиент, спросивший разрез поимённо, не отличил бы
// свой промах от ответа по существу.
layer := q.Get("layer")
if q.Has("layer") && !hae.Known(hae.Layer(layer)) {
return points.Request{}, "неизвестный слой: допустимы " + knownLayers
}
return points.Request{Metric: metricFromPath(r), From: from, To: to, Layer: layer}, ""
}
// parseBound разбирает границу периода.
//
// Только RFC 3339 и только с явным смещением зоны. Голая дата отвергается не из
// строгости: у неё нет зоны, а вопрос «в какой зоне считать сутки» в проекте
// открыт отдельной задачей. Принять её значило бы выбрать зону за клиента молча.
func parseBound(q url.Values, name string) (time.Time, string) {
raw := q.Get(name)
if raw == "" {
return time.Time{}, "параметр " + name + " обязателен"
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, "параметр " + name +
": ожидается метка времени RFC 3339 с явным смещением зоны, например 2026-01-01T00:00:00Z"
}
// Год после приведения к UTC обязан остаться четырёхзначным, и это не
// придирка к календарю. Хранилище адресует объекты строкой RFC 3339, а
// сравнение границ идёт лексикографически: `9999-12-31T23:00:00-07:00`
// превращается в `10000-01-01T06:00:00Z`, который меньше любой настоящей
// метки как строка, — и запрос молча отдал бы пустой ряд при непустых
// данных. Отказ здесь честнее пустоты: тот же разряд ловится и на входе
// приёма, но там он унаследован и лечится не тут.
if y := t.UTC().Year(); y < 1 || y > 9999 {
return time.Time{}, "параметр " + name + ": год после приведения к UTC вне диапазона 1–9999"
}
return t.UTC(), ""
}
// knownLayers — перечень допустимых слоёв ДЛЯ СООБЩЕНИЯ КЛИЕНТУ, собранный из
// того же словаря, что и проверка. Текст отказа уезжает наружу и потому обязан
// перечислять ровно то, что принимается: разъехавшись, он врал бы клиенту.
var knownLayers = func() string {
out := make([]string, 0, len(hae.Layers))
for _, l := range hae.Layers {
out = append(out, string(l))
}
return strings.Join(out, ", ")
}()
// metricFromPath достаёт имя метрики из пути.
//
// Решение принимается по `RawPath`, а НЕ по успеху декодирования, и это ровно
// то место, где легко ошибиться. `chi` сопоставляет по сырому пути только когда
// тот непуст, то есть когда `net/url` увидел в пути экранирование; тогда
// `{metric}` приезжает закодированным и его надо декодировать. Когда `RawPath`
// пуст, `chi` отдаёт уже декодированное имя — и второе декодирование испортило
// бы его молча.
//
// Цена ошибки построена и прогнана враждебным проходом: метрика с именем
// `a%41b` (имена приходят из тела доставки дословно) кодируется клиентом в
// `a%2541b`, `net/url` декодирует это в `a%41b` и оставляет `RawPath` пустым —
// а второе декодирование давало `aAb`, то есть маршрут отвечал `200` и данными
// ДРУГОЙ метрики.
//
// Отказ декодирования не является отказом маршрута: берём имя как есть — оно
// всё равно не совпадёт ни с одной метрикой витрины, и клиент получит честный
// пустой ряд, а не `400` на своё же имя.
func metricFromPath(r *http.Request) string {
raw := chi.URLParam(r, "metric")
if r.URL.RawPath == "" {
return raw
}
if decoded, err := url.PathUnescape(raw); err == nil {
return decoded
}
return raw
}