Каталог разрезов и измеренный род агрегации

- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
This commit is contained in:
av
2026-08-02 19:23:59 +03:00
parent 98e0772ec5
commit 03edf1087d
39 changed files with 4744 additions and 58 deletions
+34
View File
@@ -0,0 +1,34 @@
package httpapi
import (
"net/http"
"git.vakhrushev.me/av/healthlog/internal/catalog"
)
// catalogResponse — оболочка ответа каталога.
//
// Объект, а не голый массив: список метрик — не единственное, что каталогу
// когда-нибудь придётся отдать, а массив верхнего уровня расширить нечем.
type catalogResponse struct {
Metrics []catalog.Metric `json:"metrics"`
}
// handleMetrics отдаёт каталог разрезов с измеренным родом агрегации.
//
// Список приходит из домена уже непустым срезом: nil сериализуется в `null`, и
// пустая витрина отдавала бы клиенту `"metrics": null` вместо `[]`. Тест,
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
// подстраховка поверх подстраховки прячет отказ первой.
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
metrics, err := a.catalog.Metrics(r.Context())
if err != nil {
// Исход операции логирует доменный слой, транспорт только переводит его
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
// в нём имена колонок и форма запроса.
writeError(w, http.StatusInternalServerError, "каталог не собрался")
return
}
writeJSON(w, http.StatusOK, catalogResponse{Metrics: metrics})
}
+211
View File
@@ -0,0 +1,211 @@
package httpapi_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func getCatalog(t *testing.T, h http.Handler, auth string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// Пустая витрина отдаёт `[]`, а не `null`. Сравниваются БАЙТЫ: nil-срез
// сериализуется в `null`, и тест, сличающий разобранные структуры, этого не
// увидел бы — а клиент увидел бы сразу.
func TestКаталогПустойВитриныОтдаётПустойСписок(t *testing.T) {
h, _, _ := newAPITokens(t, nil, nil)
rec := getCatalog(t, h, "")
if rec.Code != http.StatusOK {
t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String())
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"metrics":[]}` {
t.Errorf("тело %q, ждали {\"metrics\":[]}", got)
}
}
// Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка
// `0001-01-01`: нулевое время в ответе неотличимо от данных.
func TestКаталогНеизмеренноеОкноОтдаётNull(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
Metric: "vo2_max", Layer: "raw", Units: "ml/(kg·min)",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":42}`)},
}}}, store.DeliveryRef{ID: "d"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
body := getCatalog(t, h, "").Body.String()
for _, want := range []string{`"style":"unknown"`, `"first_hour":null`, `"last_hour":null`, `"hours":0`} {
if !strings.Contains(body, want) {
t.Errorf("в ответе нет %s: %s", want, body)
}
}
}
func TestКаталогТребуетТокенЧтения(t *testing.T) {
write := []string{"write-token"}
read := []string{"read-token"}
cases := []struct {
name string
auth string
want int
}{
{"без заголовка", "", http.StatusUnauthorized},
{"токен приёма", "Bearer write-token", http.StatusUnauthorized},
{"голое значение без схемы", "read-token", http.StatusUnauthorized},
{"чужой токен", "Bearer nope", http.StatusUnauthorized},
{"токен чтения", "Bearer read-token", http.StatusOK},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h, _, _ := newAPITokens(t, write, read)
rec := getCatalog(t, h, c.auth)
if rec.Code != c.want {
t.Errorf("статус %d, ждали %d (тело %s)", rec.Code, c.want, rec.Body.String())
}
})
}
}
// Пустой список токенов чтения = проверка выключена. Это симметрия с приёмом, и
// цена её названа в конфиге: открытое чтение — выгрузка истории здоровья.
func TestКаталогБезТокеновОтдаётся(t *testing.T) {
h, _, _ := newAPITokens(t, []string{"write-token"}, nil)
if rec := getCatalog(t, h, ""); rec.Code != http.StatusOK {
t.Errorf("статус %d, ждали 200", rec.Code)
}
}
// Токен чтения — такой же секрет, как токен приёма: посланный на маршрут приёма
// заголовком с произвольным именем, он не имеет права осесть в учёте доставки.
func TestТокенЧтенияНеОседаетВУчётеДоставки(t *testing.T) {
h, st, _ := newAPITokens(t, nil, []string{"read-token"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Whatever", "read-token")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("статус %d", rec.Code)
}
d, err := st.LastDelivery(context.Background())
if err != nil {
t.Fatalf("доставка: %v", err)
}
if strings.Contains(d.Headers, "read-token") {
t.Errorf("токен чтения сохранён в заголовках доставки: %s", d.Headers)
}
}
// Форма ответа закреплена БАЙТАМИ на непустой витрине, а не подстроками.
// Wire-форма каталога — это доменные структуры с json-тегами, и переименование
// поля меняет публичный контракт без единого касания транспорта; страж у него
// один — этот литерал.
func TestКаталогОтдаётОжидаемыеБайты(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
var points []store.IncomingPoint
for i := range 4 {
at := base.Add(time.Duration(i) * time.Hour)
points = append(points, store.IncomingPoint{
Metric: "step_count", Layer: "hour", Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":6}`)},
})
for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
ts := at.Add(time.Duration(m) * time.Minute)
points = append(points, store.IncomingPoint{
Metric: "step_count", Layer: "minute", Units: "count",
Point: store.Point{Start: ts, End: ts, Raw: json.RawMessage(v)},
})
}
}
if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
want := `{"metrics":[{"metric":"step_count","units":["count"],` +
`"aggregation":{"style":"cumulative","hours":4,"compared":4,"agreeing":4,` +
`"conflicting":0,"first_hour":"2026-06-01T00:00:00Z","last_hour":"2026-06-01T03:00:00Z"},` +
`"layers":[{"layer":"hour","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:00:00Z","points":4},` +
`{"layer":"minute","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:02:00Z","points":12}]}]}`
if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != want {
t.Errorf("форма ответа изменилась:\n получили %s\n ждали %s", got, want)
}
}
// Два запроса подряд на неизменившейся витрине совпадают побайтово: порядок
// метрик и слоёв держится `ORDER BY` в чужом пакете, и снятие сортировки
// «раз индекс и так отсортирован» проявилось бы у клиента, а не в тестах.
func TestКаталогПовторяетсяПобайтово(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
var points []store.IncomingPoint
for i, metric := range []string{"step_count", "heart_rate", "active_energy"} {
for _, layer := range []string{"minute", "hour", "raw"} {
at := base.Add(time.Duration(i) * time.Hour)
points = append(points, store.IncomingPoint{
Metric: metric, Layer: layer, Units: "count",
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
})
}
}
if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
first := getCatalog(t, h, "").Body.String()
second := getCatalog(t, h, "").Body.String()
if first != second {
t.Errorf("два ответа на неизменившейся витрине разошлись:\n %s\n %s", first, second)
}
if !strings.Contains(first, `"metric":"active_energy"`) {
t.Fatalf("в ответе нет ожидаемых метрик: %s", first)
}
}
// Отказ хранилища переводится в 500 с человекочитаемым сообщением: текст ошибки
// наружу не уходит — в нём имена колонок и форма запроса.
func TestКаталогОтвечает500НаОтказХранилища(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
rec := getCatalog(t, h, "")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("статус %d, ждали 500", rec.Code)
}
if body := rec.Body.String(); strings.Contains(body, "sql") || strings.Contains(body, "bucket") {
t.Errorf("наружу уехали внутренности: %s", body)
}
}
+32 -13
View File
@@ -14,14 +14,17 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/ingest"
)
// Options — зависимости и настройки транспорта.
type Options struct {
Ingest *ingest.Service
Catalog *catalog.Service
Log *slog.Logger
WriteTokens []string
ReadTokens []string
MaxBodyMB int
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
@@ -31,8 +34,10 @@ type Options struct {
type api struct {
ingest *ingest.Service
catalog *catalog.Service
log *slog.Logger
writeTokens []string
readTokens []string
maxBody int64
ingestBudget time.Duration
}
@@ -41,8 +46,10 @@ type api struct {
func New(o Options) http.Handler {
a := &api{
ingest: o.Ingest,
catalog: o.Catalog,
log: o.Log,
writeTokens: o.WriteTokens,
readTokens: o.ReadTokens,
maxBody: int64(o.MaxBodyMB) << 20,
ingestBudget: o.IngestWriteBudget,
}
@@ -53,7 +60,8 @@ func New(o Options) http.Handler {
r.Get("/healthz", a.handleHealthz)
r.Route("/api/v1", func(r chi.Router) {
r.With(a.requireWriteToken).Post("/ingest", a.handleIngest)
r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest)
r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics)
})
return r
}
@@ -64,21 +72,32 @@ func (a *api) handleHealthz(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
// requireWriteToken проверяет токен приёма. Пустой список токенов = проверка
// requireToken проверяет токен контура. Пустой список токенов = проверка
// выключена: локальный запуск в доверенной сети. О выключенной проверке
// сервис предупреждает на старте.
func (a *api) requireWriteToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(a.writeTokens) == 0 {
//
// Проверка ОДНА на оба контура, параметризованная списком. Копия отличалась бы
// одним полем и несла бы три решения сразу — сравнение за постоянное время,
// «пустой список = выключено» и текст 401; правка любого из них в одном месте
// не дала бы ни ошибки компиляции, ни красного теста, а речь о контуре чтения
// данных о здоровье.
//
// Контуры при этом раздельны: списки разные, и токен приёма маршрут чтения не
// открывает. Схема строгая — токеном считается только значение после `Bearer `.
func requireToken(tokens []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(tokens) == 0 {
next.ServeHTTP(w, r)
return
}
if !tokenAllowed(bearer(r), tokens) {
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
return
}
next.ServeHTTP(w, r)
return
}
if !tokenAllowed(bearer(r), a.writeTokens) {
writeError(w, http.StatusUnauthorized, "неверный или отсутствующий токен")
return
}
next.ServeHTTP(w, r)
})
})
}
}
func bearer(r *http.Request) string {
+14 -1
View File
@@ -14,6 +14,7 @@ import (
"time"
"git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/httpapi"
"git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/store"
@@ -284,6 +285,15 @@ func TestПриёмРаботаетНаТранспортеБезДедлайн
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
t.Helper()
h, st, _ := newAPITokens(t, writeTokens, nil)
return h, st
}
// newAPITokens собирает роутер с обоими контурами. Отдельно от newAPI, чтобы не
// переписывать два десятка вызовов ради одного параметра.
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
@@ -298,14 +308,17 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
}
log := slog.New(slog.DiscardHandler)
cat := catalog.New(st, log)
h := httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, nil, log),
Catalog: cat,
Log: log,
WriteTokens: writeTokens,
ReadTokens: readTokens,
MaxBodyMB: 1,
// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
IngestWriteBudget: time.Minute,
})
return h, st
return h, st, cat
}
+11 -1
View File
@@ -103,11 +103,21 @@ var secretHeaders = map[string]bool{
// redacted подменяет значение, которое оказалось секретом.
const redacted = "[redacted]"
// isSecretValue — совпало ли значение заголовка с каким-нибудь настроенным
// токеном, всё равно какого контура.
func (a *api) isSecretValue(v string) bool {
return tokenAllowed(v, a.writeTokens) || tokenAllowed(v, a.readTokens)
}
// safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
//
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
// совпавшее с настроенным токеном, подменяется — токен можно положить в
// заголовок с произвольным именем, и угадать его мы не можем.
//
// Сверяется с токенами ОБОИХ контуров. Токен чтения так же секрет, как токен
// приёма, и посланный на этот маршрут произвольным заголовком осел бы в базе
// доставок навсегда.
func (a *api) safeHeaders(r *http.Request) map[string][]string {
out := make(map[string][]string, len(r.Header)+1)
for name, values := range r.Header {
@@ -117,7 +127,7 @@ func (a *api) safeHeaders(r *http.Request) map[string][]string {
}
safe := make([]string, len(values))
for i, v := range values {
if tokenAllowed(v, a.writeTokens) || tokenAllowed(strings.TrimPrefix(v, "Bearer "), a.writeTokens) {
if a.isSecretValue(v) || a.isSecretValue(strings.TrimPrefix(v, "Bearer ")) {
safe[i] = redacted
continue
}