- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
505 lines
26 KiB
Go
505 lines
26 KiB
Go
// Package catalog — каталог разрезов и измеренный род агрегации.
|
||
//
|
||
// Отвечает на два вопроса потребителя: «что у тебя вообще есть» (метрики,
|
||
// единицы, слои с границами и числом точек) и «какая свёртка по этой метрике
|
||
// осмысленна» (род агрегации). Оба ответа производны от витрины и ничего в неё
|
||
// не пишут.
|
||
//
|
||
// Род **измеряется**, а не размечается. Форма точки его не выдаёт: `Avg`/`Min`/
|
||
// `Max` есть только у `heart_rate`, заведомо мгновенные `walking_speed` и
|
||
// `blood_oxygen_saturation` приходят в `qty` ровно так же, как шаги (находка
|
||
// 40); заголовок доставки про род молчит; единицы дают процентов девяносто и
|
||
// ломаются на краях. Зато витрина содержит собственную сверку: одна метрика
|
||
// лежит в минутном и часовом разрезе одновременно, и часовое значение либо
|
||
// равно сумме минутных, либо их среднему.
|
||
//
|
||
// Ни один известный проект род не измеряет — HealthKit зашивает его в тип
|
||
// метрики, Home Assistant получает `state_class` от интеграции, Graphite
|
||
// выводит регуляркой по имени, Prometheus принимает от отправителя. У всех у
|
||
// них есть привилегия, которой нет у нас: поставщик объявляет тип на входе.
|
||
package catalog
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"math"
|
||
"sort"
|
||
"time"
|
||
|
||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||
)
|
||
|
||
// Style — род агрегации метрики.
|
||
//
|
||
// Нулевое значение — `unknown`, и это не заглушка: «род не измерен» и есть
|
||
// честное состояние по умолчанию, а забытое присваивание не имеет права
|
||
// выглядеть как измеренный род.
|
||
type Style int
|
||
|
||
// Роды агрегации. Их два, а не четыре, и это следствие измерения, а не
|
||
// упрощения. HealthKit различает `cumulative`, `discreteArithmetic`,
|
||
// `discreteTemporallyWeighted` (пульс) и `discreteEquivalentContinuousLevel`
|
||
// (аудиоэкспозиция) — но часовой слой HAE считается арифметически, а не по
|
||
// Apple: у `environmental_audio_exposure`, которую Apple усредняет
|
||
// логарифмически, часовое значение сошлось с обычным арифметическим средним
|
||
// минутных в 59 часах из 62. Стили, которые в наших данных ничем не
|
||
// проявляются, можно было бы только разметить руками — то есть вернуться к
|
||
// тому, от чего уходит вся конструкция.
|
||
const (
|
||
Unknown Style = iota
|
||
Cumulative
|
||
Instant
|
||
)
|
||
|
||
func (s Style) String() string {
|
||
switch s {
|
||
case Cumulative:
|
||
return "cumulative"
|
||
case Instant:
|
||
return "instant"
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|
||
|
||
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
|
||
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
|
||
// словаре нет.
|
||
func (s Style) MarshalJSON() ([]byte, error) {
|
||
return []byte(`"` + s.String() + `"`), nil
|
||
}
|
||
|
||
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
|
||
// потому что от них зависят счётчики основания в ответе.
|
||
const (
|
||
// Window — сколько самых свежих общих часов метрики участвует в сверке.
|
||
// Ограничивает стоимость: полный обход рос бы вместе с журналом, а окно
|
||
// держит работу в пределах 2×48 объектов на метрику. Измерено, что на живом
|
||
// корпусе окно даёт те же вердикты, что и полный обход.
|
||
Window = 48
|
||
|
||
// MinAgreeing — сколько согласных часов нужно, чтобы объявить род. Один
|
||
// совпавший час остаётся свидетельством одного часа, а на этом роде потом
|
||
// суммируют год. Цена порога измерена: он уводит в `unknown` ровно одну
|
||
// метрику корпуса, у которой согласный час был единственным.
|
||
MinAgreeing = 3
|
||
|
||
// coarsePoints и minFinePoints — структурные условия пригодности часа. Они
|
||
// же уходят в хранилище предварительным отбором: содержимое заведомо
|
||
// непригодного часа разжимать незачем.
|
||
coarsePoints = 1
|
||
minFinePoints = 2
|
||
|
||
// horizonSlack — насколько метка часа может опережать текущее время и всё
|
||
// ещё считаться свидетельством.
|
||
//
|
||
// Час объекта берётся из метки в теле доставки, а тело мы не контролируем:
|
||
// без верхней границы одна доставка с метками в будущем занимает окно
|
||
// целиком и подменяет измеренный род метрики (построено и прогнано:
|
||
// мгновенная метрика объявлялась накопительной при нуле противоречащих
|
||
// часов). Запас — на расхождение часов телефона и сервера; данные из
|
||
// будущего сверх него свидетельством не являются.
|
||
horizonSlack = time.Hour
|
||
|
||
// maxUnitsReported — сколько различных единиц метрики попадает в ответ.
|
||
//
|
||
// Единицы приходят из тела дословно и ничем не ограничены, а число
|
||
// различных значений равно числу объектов метрики: сотня доставок с разными
|
||
// строками единиц раздувает одну запись каталога на десятки килобайт.
|
||
// Событие невозможное по наблюдениям (единицы не менялись ни разу) и
|
||
// поэтому не имеющее естественного потолка — потолок ставится здесь.
|
||
maxUnitsReported = 8
|
||
|
||
// maxMetricInLog — предел длины имени метрики в записи лога. Тот же предел и
|
||
// та же причина, что у координат столкновения в хранилище: имя приходит из
|
||
// тела дословно при пределе приёма в 64 МиБ, а запись повторяется на каждый
|
||
// запрос каталога.
|
||
maxMetricInLog = 64
|
||
|
||
// tolerance — относительный допуск ВСЕХ сравнений измерения.
|
||
//
|
||
// Величина названа числом, потому что от неё зависят счётчики основания:
|
||
// вердикты метрик на живом корпусе одинаковы при допуске от 1e-9 до 1e-3, а
|
||
// число согласных часов у `heart_rate` при этом меняется с 29 на 49.
|
||
// Взято строгое: канонизация содержимого округляет числа до 12 значащих
|
||
// цифр, значит всё крупнее 1e-12 представлением не объясняется; 1e-9
|
||
// оставляет три порядка запаса и остаётся на шесть порядков строже любого
|
||
// содержательного расхождения — сумма и среднее при n ≥ 2 различаются не
|
||
// меньше чем вдвое.
|
||
tolerance = 1e-9
|
||
)
|
||
|
||
// closeEnough — ЕДИНСТВЕННЫЙ предикат сравнения чисел в измерении.
|
||
//
|
||
// Один на все три сравнения намеренно. Напиши «различимость суммы и среднего»
|
||
// точным неравенством, а «сходимость с гипотезой» — с допуском, и появится час,
|
||
// подтверждающий обе гипотезы сразу; его исход молча определил бы порядок веток
|
||
// `if`. При одном предикате такой час невыразим.
|
||
//
|
||
// Допуск относительный, абсолютного порога нет намеренно: второй константы,
|
||
// которую пришлось бы объяснять, задача не заводит. Цена названа вслух: при
|
||
// обоих нулях предикат истинен, и от нулевого часа защищает не он, а проверка
|
||
// различимости в verdictOf. Полагаться здесь на «около нуля не сходится»
|
||
// нельзя — ровно наоборот.
|
||
func closeEnough(a, b float64) bool {
|
||
if a == b {
|
||
return true
|
||
}
|
||
return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance
|
||
}
|
||
|
||
func clipMetric(metric string) string {
|
||
if len(metric) <= maxMetricInLog {
|
||
return metric
|
||
}
|
||
return metric[:maxMetricInLog] + "…"
|
||
}
|
||
|
||
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
|
||
// разности были осмысленны: `Hours − Compared` — часы, отброшенные проверкой
|
||
// пригодности, `Compared − Agreeing − Conflicting` — часы, не сошедшиеся ни с
|
||
// одной гипотезой.
|
||
//
|
||
// Одного числа не хватало: «часов было 48, а пригодным не оказалось ни одного»
|
||
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
|
||
// второго запроса.
|
||
type Basis struct {
|
||
Hours int `json:"hours"`
|
||
Compared int `json:"compared"`
|
||
Agreeing int `json:"agreeing"`
|
||
Conflicting int `json:"conflicting"`
|
||
FirstHour *time.Time `json:"first_hour"`
|
||
LastHour *time.Time `json:"last_hour"`
|
||
}
|
||
|
||
// Aggregation — род вместе с основанием.
|
||
type Aggregation struct {
|
||
Style Style `json:"style"`
|
||
Basis
|
||
}
|
||
|
||
// LayerRange — разрез метрики в ответе каталога.
|
||
//
|
||
// Границы — метки первой и последней точки слоя, включительно, и это границы
|
||
// ДАННЫХ, а не обещание покрытия: внутри диапазона законно есть дыры. Числа
|
||
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
|
||
// знает.
|
||
type LayerRange struct {
|
||
Layer string `json:"layer"`
|
||
From time.Time `json:"from"`
|
||
To time.Time `json:"to"`
|
||
Points int `json:"points"`
|
||
}
|
||
|
||
// Metric — запись каталога.
|
||
type Metric struct {
|
||
Metric string `json:"metric"`
|
||
// Units — множество различных единиц метрики, отсортированное. Массив, а не
|
||
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
|
||
// для обоих случаев честнее строки, которая при расхождении молча выберет
|
||
// одно из двух.
|
||
Units []string `json:"units"`
|
||
Aggregation Aggregation `json:"aggregation"`
|
||
Layers []LayerRange `json:"layers"`
|
||
}
|
||
|
||
// Service собирает каталог по витрине.
|
||
type Service struct {
|
||
store *store.Store
|
||
log *slog.Logger
|
||
}
|
||
|
||
// New собирает сервис каталога.
|
||
func New(st *store.Store, log *slog.Logger) *Service {
|
||
return &Service{store: st, log: log}
|
||
}
|
||
|
||
// Metrics отдаёт каталог: разрезы всех метрик и измеренный род каждой.
|
||
//
|
||
// Род нигде не хранится и считается заново на каждый запрос. Хранимое значение
|
||
// было бы вторым производным состоянием рядом с витриной — его пришлось бы
|
||
// пересчитывать после каждой свёртки, переносить или не переносить пересборкой
|
||
// и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит
|
||
// ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина —
|
||
// функция журнала, и устаревать в нём нечему.
|
||
func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
|
||
horizon := store.Now().Add(horizonSlack)
|
||
snap, err := s.store.ReadCatalog(ctx, store.CatalogWindow{
|
||
Fine: string(hae.LayerMinute),
|
||
Coarse: string(hae.LayerHour),
|
||
Hours: Window,
|
||
Horizon: horizon,
|
||
CoarsePoints: coarsePoints,
|
||
MinFinePoints: minFinePoints,
|
||
})
|
||
if err != nil {
|
||
// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
|
||
// ответ и второй раз её не пишет.
|
||
//
|
||
// Отмена снаружи и занятость базы означают «не сделано», а не «не
|
||
// выходит»: клиент, оборвавший запрос по своему тайм-ауту, не должен
|
||
// давать владельцу ERROR — иначе единственный канал, по которому видно
|
||
// настоящий сбой хранилища, забивается штатными событиями. Правило и его
|
||
// определение живут в store и читаются уже третьим местом.
|
||
if store.Transient(err) {
|
||
s.log.DebugContext(ctx, "catalog interrupted", "capability", "query", "error", err)
|
||
} else {
|
||
s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
out := make([]Metric, 0, len(snap.Layers))
|
||
for _, group := range groupLayers(snap.Layers) {
|
||
style, basis := Measure(snap.Pairs[group.metric])
|
||
// Единственный чекпоинт этой границы: род — свойство, на котором Read
|
||
// API строит арифметику года, и его смена не имеет права проходить
|
||
// молча. Уровень WARN, потому что адресат — владелец, а лечится это
|
||
// настройкой автоматизаций HAE, а не кодом. Значений точек в записи нет:
|
||
// данные о здоровье чувствительнее токенов. Имя метрики обрезано — оно
|
||
// приходит из тела дословно, а запись повторяется на каждый запрос.
|
||
if basis.Conflicting > 0 {
|
||
s.log.WarnContext(ctx, "aggregation style conflict",
|
||
"capability", "query",
|
||
"metric", clipMetric(group.metric),
|
||
"hours", basis.Hours,
|
||
"compared", basis.Compared,
|
||
"agreeing", basis.Agreeing,
|
||
"conflicting", basis.Conflicting)
|
||
}
|
||
// Данные, помеченные будущим, в измерение не попадают вовсе — но молчать
|
||
// о них нельзя: это либо сбитые часы телефона, либо чужое тело в приёме,
|
||
// и оба случая лечатся не кодом. Видно это по разрезам, а не по окну:
|
||
// окно такие часы уже отбросило.
|
||
if to := group.latest(); to.After(horizon) {
|
||
s.log.WarnContext(ctx, "future data",
|
||
"capability", "query",
|
||
"metric", clipMetric(group.metric),
|
||
"last_ts", store.FormatTime(to),
|
||
"horizon", store.FormatTime(horizon))
|
||
}
|
||
out = append(out, Metric{
|
||
Metric: group.metric,
|
||
Units: group.units,
|
||
Aggregation: Aggregation{Style: style, Basis: basis},
|
||
Layers: group.layers,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
type metricGroup struct {
|
||
metric string
|
||
units []string
|
||
layers []LayerRange
|
||
unitSet map[string]bool
|
||
byLayer map[string]int
|
||
}
|
||
|
||
// latest — самая поздняя метка данных метрики по всем её слоям.
|
||
func (g metricGroup) latest() time.Time {
|
||
var out time.Time
|
||
for _, l := range g.layers {
|
||
if l.To.After(out) {
|
||
out = l.To
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// groupLayers схлопывает строки выборки в записи каталога.
|
||
//
|
||
// Схлопывание поручено явно, потому что выборка группируется ВМЕСТЕ с
|
||
// единицами: у метрики, чьи объекты разошлись единицами, на один слой придут две
|
||
// строки. Оставь это на самотёк — клиент получит два элемента с одинаковым
|
||
// `layer` ровно в тот единственный день, ради которого единицы и сделаны
|
||
// множеством. Различие при этом не теряется: оно видно множеством единиц
|
||
// метрики.
|
||
func groupLayers(rows []store.LayerRange) []metricGroup {
|
||
var out []metricGroup
|
||
var cur *metricGroup
|
||
|
||
for _, r := range rows {
|
||
// Указатель, а не сравнение имени с пустой строкой: пустое имя — законное
|
||
// значение колонки, и сентинел выбрасывал бы такую метрику из каталога
|
||
// молча. Каталог отвечает на вопрос «что у тебя вообще есть»; терять на
|
||
// нём то, что в витрине лежит, нельзя.
|
||
if cur == nil || r.Metric != cur.metric {
|
||
if cur != nil {
|
||
out = append(out, *cur)
|
||
}
|
||
cur = &metricGroup{metric: r.Metric, byLayer: map[string]int{}, unitSet: map[string]bool{}}
|
||
}
|
||
if r.Units != "" {
|
||
cur.unitSet[r.Units] = true
|
||
}
|
||
i, ok := cur.byLayer[r.Layer]
|
||
if !ok {
|
||
cur.layers = append(cur.layers, LayerRange{
|
||
Layer: r.Layer, From: r.From, To: r.To, Points: r.Points,
|
||
})
|
||
cur.byLayer[r.Layer] = len(cur.layers) - 1
|
||
continue
|
||
}
|
||
l := &cur.layers[i]
|
||
if r.From.Before(l.From) {
|
||
l.From = r.From
|
||
}
|
||
if r.To.After(l.To) {
|
||
l.To = r.To
|
||
}
|
||
l.Points += r.Points
|
||
}
|
||
if cur != nil {
|
||
out = append(out, *cur)
|
||
}
|
||
|
||
for i := range out {
|
||
out[i].units = sortedKeys(out[i].unitSet)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// sortedKeys отдаёт множество строк отсортированным и обрезанным по потолку:
|
||
// число различных единиц у метрики равно числу её объектов, и без потолка одна
|
||
// запись каталога растёт вместе с витриной.
|
||
func sortedKeys(set map[string]bool) []string {
|
||
out := make([]string, 0, len(set))
|
||
for k := range set {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
if len(out) > maxUnitsReported {
|
||
out = out[:maxUnitsReported]
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Measure выводит род метрики сверкой минутного и часового слоёв.
|
||
//
|
||
// Час ПРИГОДЕН, когда у часового объекта ровно одна точка со значением и её
|
||
// метка совпадает с началом часа, у минутного не меньше двух точек со значением,
|
||
// а сумма минутных отличима от их среднего.
|
||
//
|
||
// Требование выравнивания часовой метки закрывает зоны с неполночасовым
|
||
// смещением: слой выводится по выравниванию метки в исходной зоне, а объект
|
||
// адресуется часом UTC, поэтому в зоне +0530 часовая точка описывает не тот
|
||
// интервал, который покрывают минутные точки того же объекта.
|
||
//
|
||
// Требование различимости обязательно: в часе, где все значения нули, сумма
|
||
// равна среднему, и совпадение с любой из гипотез не значит ничего. Без него
|
||
// `walking_asymmetry_percentage` на живом корпусе давала 4 часа «накопительная»
|
||
// против 3 «мгновенная» — конфликт из одних нулевых часов.
|
||
//
|
||
// Вердикт метрики — не меньше трёх согласных часов и НИ ОДНОГО противоречащего.
|
||
// Единогласие, а не большинство: противоречащий час означает, что одна из
|
||
// гипотез для этой метрики ложна, и объявлять род при известном контрпримере
|
||
// нельзя.
|
||
func Measure(pairs []store.HourPair) (Style, Basis) {
|
||
basis := Basis{Hours: len(pairs)}
|
||
if len(pairs) == 0 {
|
||
return Unknown, basis
|
||
}
|
||
|
||
// Часы приходят от свежих к старым; границы окна отдаём по возрастанию.
|
||
first, last := pairs[len(pairs)-1].Hour, pairs[0].Hour
|
||
basis.FirstHour, basis.LastHour = &first, &last
|
||
|
||
var cumulative, instant int
|
||
for _, p := range pairs {
|
||
verdict, ok := verdictOf(p)
|
||
if !ok {
|
||
continue
|
||
}
|
||
basis.Compared++
|
||
switch verdict {
|
||
case Cumulative:
|
||
cumulative++
|
||
case Instant:
|
||
instant++
|
||
}
|
||
}
|
||
|
||
// Согласные — часы преобладающей гипотезы, противоречащие — часы другой.
|
||
// Разложение одно и то же независимо от того, объявлен род или нет: при
|
||
// объявленном роде преобладающая гипотеза им и является, а противоречащих
|
||
// ноль по определению правила. Иначе `agreeing` пришлось бы толковать
|
||
// по-разному в двух ветках, и клиент читал бы одно поле двумя способами.
|
||
basis.Agreeing, basis.Conflicting = cumulative, instant
|
||
if instant > cumulative {
|
||
basis.Agreeing, basis.Conflicting = instant, cumulative
|
||
}
|
||
|
||
if basis.Conflicting > 0 || basis.Agreeing < MinAgreeing {
|
||
return Unknown, basis
|
||
}
|
||
if cumulative > instant {
|
||
return Cumulative, basis
|
||
}
|
||
return Instant, basis
|
||
}
|
||
|
||
// verdictOf оценивает один час. Второй возврат — был ли час пригоден.
|
||
func verdictOf(p store.HourPair) (Style, bool) {
|
||
// Единицы обеих сторон обязаны совпасть. Иначе сверка сравнивает величины
|
||
// разного масштаба: мгновенная метрика, приехавшая в `count/min` минутным
|
||
// слоем и в `count/hour` часовым, даёт `часовое = 60 · среднее = сумма` в
|
||
// полном часе — то есть УВЕРЕННЫЙ ложный `cumulative` при нуле
|
||
// противоречащих часов. Правило единогласия этот случай не ловит по
|
||
// построению: противоречия нет, есть молчание.
|
||
if p.FineUnits != p.CoarseUnits {
|
||
return Unknown, false
|
||
}
|
||
if len(p.Coarse) != 1 {
|
||
return Unknown, false
|
||
}
|
||
if !p.Coarse[0].Start.Equal(p.Hour) {
|
||
return Unknown, false
|
||
}
|
||
coarse, ok := hae.PointValue(p.Coarse[0].Raw)
|
||
if !ok {
|
||
return Unknown, false
|
||
}
|
||
|
||
sum, n := sumFine(p.Fine)
|
||
if n < 2 {
|
||
return Unknown, false
|
||
}
|
||
mean := sum / float64(n)
|
||
if closeEnough(sum, mean) {
|
||
return Unknown, false
|
||
}
|
||
|
||
switch {
|
||
case closeEnough(coarse, sum):
|
||
return Cumulative, true
|
||
case closeEnough(coarse, mean):
|
||
return Instant, true
|
||
default:
|
||
return Unknown, true
|
||
}
|
||
}
|
||
|
||
// sumFine складывает значения минутных точек в порядке возрастания метки, чтобы
|
||
// вердикт не зависел от порядка точек внутри объекта.
|
||
func sumFine(points []store.Point) (float64, int) {
|
||
ordered := make([]store.Point, len(points))
|
||
copy(ordered, points)
|
||
sort.SliceStable(ordered, func(i, j int) bool {
|
||
return ordered[i].Start.Before(ordered[j].Start)
|
||
})
|
||
|
||
var sum float64
|
||
n := 0
|
||
for _, p := range ordered {
|
||
v, ok := hae.PointValue(p.Raw)
|
||
if !ok {
|
||
continue
|
||
}
|
||
sum += v
|
||
n++
|
||
}
|
||
return sum, n
|
||
}
|