добавлены документация проекта и каркас разработки
- README, CLAUDE.md, docs: назначение и границы, архитектура, конвенции, план - docs/local-research.md — 36 находок по формату Health Auto Export, снятых на живых данных; документация приложения местами расходится с тем, что оно шлёт - Taskfile, .golangci.yml, самодокументируемый config.example.toml
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
# Архитектура
|
||||
|
||||
## Назначение
|
||||
|
||||
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
|
||||
(далее HAE), хранит их и отдаёт другим приложениям. Он не обрабатывает данные:
|
||||
не считает агрегаты, не переименовывает поля, не интерпретирует значения.
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Один статический бинарь** (`CGO_ENABLED=0`), доставка — docker-образом.
|
||||
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
|
||||
в каком их прислал HAE — без переименований, пересчётов и отбрасывания
|
||||
незнакомых полей. Поэтому хранилище само по себе является полной копией
|
||||
данных, а не производной выжимкой.
|
||||
- **Сырой архив — страховка разбора, а не вечный склад.** Тело запроса ложится
|
||||
на диск до разбора и живёт ограниченный срок (по умолчанию две недели):
|
||||
этого хватает, чтобы пережить ошибку в нашем разборе и пересобрать
|
||||
хранилище (`healthlog reindex`). Дальше источником истины остаются часовые
|
||||
объекты — прямое следствие пункта выше, см. «Хранилище».
|
||||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
|
||||
(см. «Приём»).
|
||||
- **Ничего не теряем молча.** Идентичность — хеш канонизированного
|
||||
содержимого: повтор не меняет ничего, различие сохраняется. Схлопывания «на
|
||||
всякий случай» нет.
|
||||
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
|
||||
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
|
||||
см. «Модель синхронизации».
|
||||
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
|
||||
нормализовано только время.
|
||||
- **Своей агрегации нет — есть разрезы.** Метрика хранится в тех слоях
|
||||
подробности, в которых пришла (`raw`/`minute`/`hour`); сводить их к одному
|
||||
или досчитывать свои значило бы принимать предметные решения, которых
|
||||
хранилище принять не может.
|
||||
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
|
||||
внешних зависимостей.
|
||||
|
||||
## Формат Health Auto Export
|
||||
|
||||
Документация формата скудная: [help.healthyapps.dev](https://help.healthyapps.dev/en/health-auto-export/automations/rest-api/)
|
||||
и [wiki Lybron/health-auto-export](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format).
|
||||
Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам.
|
||||
|
||||
Автоматизация HAE шлёт **POST** с JSON-телом и своими заголовками:
|
||||
`automation-name`, `automation-id`, `automation-aggregation`,
|
||||
`automation-period`, `session-id`. Свои заголовки (токен) добавляются в
|
||||
настройках автоматизации. Большой экспорт может приехать несколькими
|
||||
запросами (**Batch Requests**) — поэтому идемпотентность нужна на уровне
|
||||
точки, а не пакета.
|
||||
|
||||
Мета-информации в **теле нет вообще** — только `{"data": {…секции…}}`. Из
|
||||
заголовков в коде опираемся лишь на `automation-id` (стабильный UUID
|
||||
автоматизации) и `session-id`: `automation-aggregation` и `automation-period`
|
||||
называют настройку, а не фактический режим, и значение `Default` соответствует
|
||||
трём разным поведениям (находка 31). Гранулярность и охват определяем по самим
|
||||
данным. Полный набор заголовков сохраняется в `delivery.headers` — документация
|
||||
заведомо неполна, и именно из незадокументированного вышли самые полезные
|
||||
находки.
|
||||
|
||||
```
|
||||
{"data": {"metrics": [...], "workouts": [...], "stateOfMind": [...],
|
||||
"medications": [...], "symptoms": [...], "cycleTracking": [...],
|
||||
"ecg": [...], "heartRateNotifications": [...]}}
|
||||
```
|
||||
|
||||
Метрика — `{"name": "heart_rate", "units": "count/min", "data": [...]}`.
|
||||
**Форма точки зависит от метрики**: обычная — `{qty, date}`, пульс —
|
||||
`{Min, Avg, Max, date}`, давление — `{systolic, diastolic}`, сон — набор
|
||||
интервалов и фаз, глюкоза — плюс `mealTime`. Единой формы значения нет;
|
||||
общее — только момент времени.
|
||||
|
||||
Вопреки документации, в точке **есть поле `source`** — какие устройства
|
||||
вложились в значение (составное, через `|`). Что ещё документация описывает
|
||||
неверно и как поток выглядит на самом деле — [local-research.md](local-research.md).
|
||||
|
||||
Даты приходят строкой с офсетом: `2026-07-31 12:00:00 +0300` — не RFC 3339.
|
||||
|
||||
Тренировка (v2) несёт стабильный `id` из HealthKit, `start`/`end`/`duration`,
|
||||
опционально `route` (точки GPS) и `heartRateData`.
|
||||
|
||||
**Наша настройка:** несуммированные данные (переключатель «Суммировать
|
||||
данные» выключен, группировка при этом недоступна). Причина — суммированные
|
||||
значения досчитываются задним числом: минутное ведро уезжает неполным и в
|
||||
следующей доставке приезжает полным
|
||||
([local-research.md](local-research.md), находка 10). На несуммированных
|
||||
данных расхождений не наблюдалось (находка 3), поэтому идентичность по
|
||||
содержимому работает без оговорок. Заодно сохраняются детали, которые
|
||||
группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19).
|
||||
|
||||
Чего это **не** даёт: настоящих сэмплов. Накопительные метрики (энергия,
|
||||
шаги, дистанция) в любом режиме приходят посекундной сеткой — нарезкой
|
||||
реальных сэмплов длиной 1–12 секунд, с сохранением итога и потерей границ
|
||||
интервала. Порядка 135 тысяч точек в сутки (находки 20, 23).
|
||||
|
||||
Поля `start`/`end` есть только у дискретных метрик (пульс, сатурация, сон,
|
||||
HRV); у накопительных — только `date`. Поэтому точка относится к часу **по
|
||||
`date`**, а вопрос о сэмплах, пересекающих границу часа, касается сотой доли
|
||||
данных (находка 21).
|
||||
|
||||
## Модель синхронизации
|
||||
|
||||
Данные приходят **тремя проходами разной глубины** — три автоматизации HAE с
|
||||
одинаковыми настройками данных, различающиеся только расписанием и периодом:
|
||||
|
||||
| проход | расписание | период | зачем |
|
||||
|---|---|---|---|
|
||||
| быстрый | каждые 5 минут | Since Last Sync | свежесть |
|
||||
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
|
||||
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное |
|
||||
|
||||
Средний и глубокий проходы используют **фиксированные окна, а не метку
|
||||
синхронизации** — это принципиально. Инкрементальный режим проверен и
|
||||
**теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла
|
||||
13 961 точку, включая фазы сна за три часа и весь глубокий сон той ночи
|
||||
(находка 29). Фиксированное окно идемпотентно по построению и не зависит ни от
|
||||
какой метки.
|
||||
|
||||
Расписание — пожелание, а не гарантия: iOS не даёт приложению запускаться в
|
||||
заданное время, а к данным Health доступа нет вовсе, пока телефон заблокирован
|
||||
(находка 28). Поэтому проходы привязываются к моментам, когда телефон заведомо
|
||||
разблокирован (триггер из Shortcuts по времени суток), а поток считается
|
||||
пачечным: тишина ночью, всплеск утром.
|
||||
|
||||
Гарантия починки:
|
||||
|
||||
```
|
||||
дыра моложе суток → закроется в течение часа
|
||||
дыра моложе недели → закроется в течение суток
|
||||
дыра старше недели → не закроется; лечится `healthlog import`
|
||||
```
|
||||
|
||||
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
|
||||
переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
|
||||
почти все из которых сойдутся, и записи не будет.
|
||||
|
||||
**Обязательное правило: настройки данных у всех трёх проходов одинаковы** —
|
||||
тот же набор метрик, то же суммирование, та же группировка. Иначе одна метрика
|
||||
приезжает из разных источников с разной гранулярностью, значения по одному
|
||||
ключу расходятся и проходы начинают перетирать друг друга (находка 14).
|
||||
|
||||
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
|
||||
иначе `automation-name` приходит пустым (находка 12).
|
||||
|
||||
## Компоненты
|
||||
|
||||
| Пакет | Ответственность |
|
||||
| ---------- | ------------------------------------------------------ |
|
||||
| `config` | загрузка и валидация TOML-конфига |
|
||||
| `logging` | сборка slog-логгера |
|
||||
| `ident` | генерация и разбор ULID |
|
||||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен |
|
||||
| `hae` | разбор формата HAE, канонизация, хеш содержимого |
|
||||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи |
|
||||
| `httpapi` | приём и read API |
|
||||
|
||||
## Приём
|
||||
|
||||
```
|
||||
запрос → токен → лимит тела, gzip → проверка формы JSON
|
||||
→ запись тела в архив → строка в delivery → 200
|
||||
→ разбор → запись в витрину
|
||||
```
|
||||
|
||||
Код ответа определяется **доставкой**, не разбором:
|
||||
|
||||
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
|
||||
Это проблема транспорта (обрыв, обрезанное тело), и отправителю о ней
|
||||
надо сказать.
|
||||
- **200** — тело сохранено в архив. Дальше даже полный провал разбора
|
||||
(незнакомая метрика, новая форма точки) не меняет ответ: данные уже в
|
||||
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
|
||||
`/stats`, а доразобрать их можно командой `reindex`.
|
||||
|
||||
Причина такого разделения: неизвестно, шлёт ли HAE отклонённый пакет
|
||||
повторно при периоде «Since Last Sync». Если не шлёт, строгий приём означал
|
||||
бы дыру в истории. Многоуровневая синхронизация страхует тот же риск с другой
|
||||
стороны — но полагаться только на неё нельзя: она чинит дыры за неделю, а не
|
||||
за год.
|
||||
|
||||
## Хранилище
|
||||
|
||||
### Сырой архив — короткая страховка
|
||||
|
||||
`raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется.
|
||||
Живёт **ограниченный срок** (`storage.raw_retention`, по умолчанию 14 дней),
|
||||
после чего удаляется.
|
||||
|
||||
Смысл срока: архив нужен, чтобы пережить ошибку в **нашем** разборе и
|
||||
пересобрать хранилище (`healthlog reindex`). Двух недель на это заведомо
|
||||
хватает. Вечно хранить его незачем — часовые объекты держат те же точки
|
||||
дословно, так что архив дублировал бы данные, а не страховал их.
|
||||
|
||||
**Это осознанная смена источника истины.** Пока тело в архиве, истина — оно;
|
||||
после удаления истиной остаются часовые объекты. Инвариант, который держит
|
||||
конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то
|
||||
отбрасывать или нормализовать внутри точки, срок хранения архива станет
|
||||
сроком жизни данных.
|
||||
|
||||
### Часовые объекты метрик
|
||||
|
||||
Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за
|
||||
один час UTC**.
|
||||
|
||||
```
|
||||
delivery(id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, parse_status, points,
|
||||
headers)
|
||||
|
||||
bucket(metric, layer, hour_utc, hash, points_count, first_ts, last_ts,
|
||||
units, payload BLOB, first_delivery_id, updated_at, sealed)
|
||||
PK (metric, layer, hour_utc)
|
||||
|
||||
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec,
|
||||
payload JSON, delivery_id, updated_at)
|
||||
|
||||
record(id PK, kind, ts_utc, tz_offset, payload JSON,
|
||||
delivery_id, updated_at)
|
||||
INDEX (kind, ts_utc)
|
||||
```
|
||||
|
||||
Зачем пачками:
|
||||
|
||||
- **Строк на два порядка меньше** — 26 метрик × 24 часа = 624 объекта в сутки
|
||||
вместо ~155 тысяч точек. За год 228 тысяч строк вместо 55 миллионов.
|
||||
- **Дедупликация дешевеет во столько же раз.** Повторная доставка того же часа
|
||||
— одно сравнение хеша вместо тысяч поисков по точкам. Это и делает широкие
|
||||
проходы синхронизации почти бесплатными.
|
||||
- **Хранение сжимается.** `payload` — gzip-BLOB: наблюдаемое сжатие такого
|
||||
JSON — примерно 25 раз, то есть ~2 МБ в сутки вместо ~50 МБ. Цена: внутрь
|
||||
объекта не заглянуть SQL-функциями, разбор только в приложении. Для
|
||||
хранилища, которое отдаёт диапазоны точек, это не потеря.
|
||||
|
||||
### Слои гранулярности
|
||||
|
||||
Одна и та же метрика может приходить с разной подробностью: несуммированной,
|
||||
минутной, часовой. Мы **не сводим их к одной** и не агрегируем сами — храним
|
||||
разрезами и говорим клиенту, какие разрезы есть.
|
||||
|
||||
```
|
||||
sample настоящие сэмплы HealthKit с интервалами start/end — только из
|
||||
ручного экспорта Apple Health, HAE такого не отдаёт (находка 34)
|
||||
raw метки на произвольной секунде heart_rate 00:02:07
|
||||
minute метки выровнены на минуту heart_rate 00:02:00
|
||||
hour метки выровнены на час heart_rate 00:00:00
|
||||
```
|
||||
|
||||
Почему не своя агрегация: сложить точки в часовые средние нетрудно, но
|
||||
правильный способ зависит от метрики (сумма для энергии, среднее для пульса,
|
||||
максимум для чего-то ещё), и это уже предметное знание, которого у хранилища
|
||||
нет. Разрезы — честнее.
|
||||
|
||||
**Слой — это режим выгрузки, которым пришли данные**, а не измеренное
|
||||
разрешение каждой метрики. Различие принципиально: частота метрик разная —
|
||||
пульс идёт секундами, VO₂ max случается раз в неделю, — и выводить слой из
|
||||
частоты значило бы дробить редкие метрики между слоями без всякого смысла.
|
||||
Режим же общий для доставки, и редкая метрика просто наследует его.
|
||||
|
||||
**Определяется по данным, а не по заголовку.** `automation-aggregation`
|
||||
непригоден: значение `Default` соответствует трём разным режимам сразу
|
||||
(находка 31). Правило:
|
||||
|
||||
1. **Плотная метрика** (не меньше десяти точек в доставке) классифицируется
|
||||
**сама по себе** по выравниванию своих меток. У десяти несуммированных
|
||||
точек шанс всем лечь на ровную минуту исчезающе мал.
|
||||
2. **Редкая метрика** (меньше десяти точек) наследует **преобладающий слой
|
||||
доставки** — самый мелкий среди плотных. У неё выравнивание ничего не
|
||||
доказывает, а Apple многие редкие показатели пишет прямо на границе часа.
|
||||
3. Плотных метрик в доставке нет вовсе — слой наследуется от предыдущей
|
||||
доставки той же автоматизации; если её не было, берём заголовок
|
||||
(`Minutes` → `minute`, `Hours` → `hour`, иначе `raw`).
|
||||
|
||||
Классифицировать доставку целиком нельзя: при перенастройке автоматизации
|
||||
приезжают **смешанные доставки**, где часть метрик уже минутная, а часть ещё
|
||||
посекундная. Одна такая доставка, отнесённая к слою целиком, сложила минутные
|
||||
точки с посекундными и удвоила сумму за час (находка 35).
|
||||
|
||||
Заголовок сохраняем и сверяем с выведенным; расхождение и смену режима у
|
||||
автоматизации пишем `WARN` — так видна перенастройка, а не тихий дребезг.
|
||||
|
||||
Почему не по метрике отдельно (проверено на живых данных, находка 33): одна
|
||||
доставка законно содержит метрики разной подробности — `apple_stand_hour`
|
||||
почасовой по своей природе, `sleep_analysis` в минутном режиме превращается в
|
||||
суточный агрегат на `00:00:00`, а `heart_rate` рядом с ними идёт с секундной
|
||||
точностью. Классификация каждой по отдельности растащила бы одну выгрузку по
|
||||
трём слоям.
|
||||
|
||||
Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на
|
||||
приёме: это ещё одна причина держать сырой архив.
|
||||
|
||||
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
|
||||
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
|
||||
не смешиваются в одном ряду; если же две автоматизации шлют одну метрику с
|
||||
одинаковой гранулярностью, это честный дубликат, и его схлопывает хеш.
|
||||
|
||||
Слои считаются **вниз, но не вверх**: из `raw` получается `hour`, обратно —
|
||||
нет. Поэтому самый мелкий слой стоит держать, пока он не станет дорог; цена
|
||||
измерена — около 730 МБ в год против 20 МБ у минутного. Страховка на случай,
|
||||
если мелкий слой всё-таки выключат: **ручной экспорт из Apple Health**
|
||||
восстанавливает нижний слой целиком через `healthlog import`.
|
||||
|
||||
**Идентичность точки — координаты, а не содержимое.**
|
||||
|
||||
```
|
||||
ключ: метрика + слой + метка времени
|
||||
значения: qty / Min / Avg / Max / source / … ← перезаписываются
|
||||
```
|
||||
|
||||
Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали
|
||||
задвоение на живых данных:
|
||||
|
||||
- **числа сериализуются нестабильно** — 45 507 из 71 730 повторно приехавших
|
||||
точек различались последним разрядом double (`0.09523182962471353` против
|
||||
`…52`), то есть 63% повторов выглядели новыми (находка 30);
|
||||
- **`source` нестабилен** — то же измерение с тем же значением приезжает то
|
||||
как `Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`:
|
||||
Health переосмысливает атрибуцию задним числом. Минутный слой за 31 июля
|
||||
оказался задвоен целиком, 120 точек в часе вместо 60 (находка 36).
|
||||
|
||||
Хеш при этом остаётся — но как **детектор изменений**, а не как ключ: совпал с
|
||||
сохранённым, значит писать нечего. Считается он по канонической форме с
|
||||
рекурсивной сортировкой ключей и округлением чисел до ~12 значащих цифр
|
||||
(иначе, см. выше, «изменилось» будет срабатывать всегда).
|
||||
|
||||
Объект целиком тоже адресуется хешем — им сравниваются часовые пачки, чтобы
|
||||
широкий проход не переписывал неизменившееся.
|
||||
|
||||
**Запись — слияние, а не вставка.** Приход новых точек за уже существующий час
|
||||
означает: прочитать объект, влить точки (объединение по хешу точки),
|
||||
отсортировать по времени, записать обратно. Точки из объекта не удаляются
|
||||
никогда.
|
||||
|
||||
**`sealed`** отмечает часы, которые уже не должны меняться (старше окна
|
||||
досчёта). Изменение запечатанного объекта — не отказ, а **сигнал**: пишем
|
||||
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
|
||||
эксплуатации, а не из предположений.
|
||||
|
||||
### Тренировки и прочие секции
|
||||
|
||||
Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она
|
||||
может приехать повторно, когда доедет маршрут. `record` держит секции с
|
||||
собственными идентификаторами (`stateOfMind`, `ecg`, `symptoms`,
|
||||
`cycleTracking`, `medications`, `heartRateNotifications`) — модель та же.
|
||||
|
||||
Пачками они не хранятся: у них есть естественный ключ, они редки, и
|
||||
группировать их по часам незачем.
|
||||
|
||||
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
|
||||
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
|
||||
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
|
||||
таблицы значило бы решить за Apple, что в ней главное.
|
||||
|
||||
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри
|
||||
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
|
||||
смешиваются.
|
||||
|
||||
### Время
|
||||
|
||||
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
|
||||
адресации и выборок используется нормализованное время: `hour_utc` у объекта,
|
||||
`ts_utc` + офсет исходной зоны у записей с собственным ключом. Офсет нужен,
|
||||
чтобы клиент мог считать сутки и по UTC, и по местному времени: без него
|
||||
суточные ряды незаметно поехали бы после смены часового пояса.
|
||||
|
||||
**Формат даты зависит от секции пакета**: метрики и тренировки шлют
|
||||
`2026-07-31 21:03:51 +0300`, `stateOfMind` — RFC 3339 в UTC (`…T18:03:51Z`).
|
||||
Одного парсера недостаточно (находка 16).
|
||||
|
||||
## Read API
|
||||
|
||||
```
|
||||
GET /api/v1/metrics каталог: имя, units, слои с диапазонами
|
||||
GET /api/v1/metrics/{name}?layer&from&to&cursor точки метрики
|
||||
GET /api/v1/workouts?from&to заголовки тренировок
|
||||
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
|
||||
GET /api/v1/records/{kind}?from&to прочие секции
|
||||
GET /api/v1/schema схемы всего, что есть в хранилище
|
||||
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики
|
||||
GET /stats последняя доставка, счётчики, тишина по потоку
|
||||
GET /healthz
|
||||
```
|
||||
|
||||
Хранение пачками на контракт не влияет: `GET /metrics/{name}` собирает ответ
|
||||
из часовых объектов, попавших в диапазон, и отдаёт точки. Клиент про объекты
|
||||
не знает — это деталь хранения, а не API.
|
||||
|
||||
**Слои, наоборот, часть контракта.** Каталог показывает, какие разрезы есть и
|
||||
за какой период:
|
||||
|
||||
```json
|
||||
{"metric": "heart_rate",
|
||||
"layers": [
|
||||
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078},
|
||||
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203}
|
||||
]}
|
||||
```
|
||||
|
||||
Параметр `layer` выбирает разрез. Если он не указан — отдаём **самый мелкий
|
||||
слой, покрывающий весь запрошенный диапазон**, и в ответе всегда называем,
|
||||
какой слой отдан. Молча переключать слой на границе периода нельзя: ряд поедет
|
||||
незаметно для клиента.
|
||||
|
||||
Форма точки в ответе — нормализованная оболочка, сырое содержимое:
|
||||
|
||||
```json
|
||||
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
|
||||
"values": {"qty": 8}}
|
||||
```
|
||||
|
||||
Время приведено к единому виду, значения отданы как пришли: ни
|
||||
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
|
||||
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
|
||||
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
|
||||
|
||||
## Самоописание
|
||||
|
||||
Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен
|
||||
угадывать структуру по выборке — он запрашивает схему и сразу знает, что
|
||||
лежит в наборе. Слоя два.
|
||||
|
||||
**Схема контракта** — форма конверта, который отдаёт API (`ts`, `tz_offset`,
|
||||
`units`, `values`, заголовок тренировки, ошибка). Наша, статичная, пишется
|
||||
руками.
|
||||
|
||||
**Каталог разрезов** — какие слои есть у метрики и за какие периоды. Отвечает
|
||||
на вопрос «что вообще можно спросить», прежде чем клиент спросит.
|
||||
|
||||
**Схема содержимого** — что лежит внутри `values` у конкретной метрики.
|
||||
**Выводится из данных**, а не ведётся вручную: метрик у Apple больше сотни, и
|
||||
рукописный каталог описывал бы документацию HAE, а не то, что он реально
|
||||
прислал. Выведенная схема производна ровно так же, как витрина: считается тем
|
||||
же проходом разбора, инкрементально при приёме и целиком при `reindex`.
|
||||
Незнакомая метрика описывает себя сама, без релиза.
|
||||
|
||||
Схема отдаётся **вместе со статистикой** — для потребителя она важнее
|
||||
формального типа:
|
||||
|
||||
```json
|
||||
{"metric": "heart_rate", "points": 412355,
|
||||
"first_ts": "2019-03-02T…", "last_ts": "2026-07-31T…",
|
||||
"units": ["count/min"],
|
||||
"fields": {"Min": {"type": "number", "presence": 1.0},
|
||||
"Avg": {"type": "number", "presence": 1.0},
|
||||
"Max": {"type": "number", "presence": 1.0}}}
|
||||
```
|
||||
|
||||
Вывод ограничен по глубине вложенности — иначе схема тренировки с маршрутом
|
||||
разрослась бы до размеров самих данных. Форма точки маршрута при этом
|
||||
описывается: блоб трека не непрозрачен, это массив однотипных объектов.
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Статический токен в заголовке `Authorization: Bearer …`; список допустимых
|
||||
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
|
||||
|
||||
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
|
||||
данные, не может писать.
|
||||
|
||||
## Деплой
|
||||
|
||||
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
|
||||
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
|
||||
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
|
||||
экспорт копится и уезжает пачкой при возвращении домой.
|
||||
|
||||
Сборка — на локальной машине: статический бинарь и docker-образ; на сервер
|
||||
едет готовый образ. Go-тулчейн на сервере не нужен.
|
||||
|
||||
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
|
||||
(с токенами) — отдельно, `0600`.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
|
||||
@@ -0,0 +1,109 @@
|
||||
# Конвенции кода
|
||||
|
||||
Как пишем код (How), а не что система делает (What — в
|
||||
[architecture.md](architecture.md)). Перенесено из jellybit и сжато под
|
||||
масштаб этого проекта.
|
||||
|
||||
## Язык
|
||||
|
||||
- Документация, комментарии, сообщения коммитов — **русский**.
|
||||
- Код и идентификаторы — **английский**.
|
||||
|
||||
## Ошибки
|
||||
|
||||
- Только стандартный `errors` + `fmt.Errorf`. Сторонних пакетов ошибок нет:
|
||||
контекст несёт `slog`, стек-трейсы для домашнего сервиса избыточны.
|
||||
- Контекст добавляем обёрткой `%w` — это дефолт, чтобы `errors.Is`/`As`
|
||||
работали сквозь слои. `%v` — только когда причину сознательно не
|
||||
раскрываем.
|
||||
- Стиль сообщения: со строчной, без точки, без «failed to». Контекст —
|
||||
операция или субъект (`"open archive: %w"`), каждый слой добавляет **свой**
|
||||
смысл, не повторяя нижний.
|
||||
- Граничные ошибки транслируем в доменные у источника: `sql.ErrNoRows` →
|
||||
`store.ErrNotFound` внутри `store`, чтобы выше не торчал `database/sql`.
|
||||
- **Sentinel** (`var ErrNotFound = errors.New(...)`) — для условий, на которые
|
||||
ветвится код. **Типизированная ошибка** — когда вызывающему нужны данные
|
||||
ошибки. Не плодим типы там, где хватает sentinel.
|
||||
- Наружу (HTTP) отдаём человекочитаемое сообщение по доменной ошибке, не
|
||||
сырой `err.Error()`. Маппинг доменная ошибка → статус живёт в одной точке
|
||||
в `httpapi`; новая штатная ветвь отказа заводится sentinel'ом и
|
||||
добавляется туда, иначе `default` отдаст 500 на нормальный конфликт.
|
||||
- Собрать независимые ошибки (валидация конфига — все проблемы разом) —
|
||||
`errors.Join`.
|
||||
- `panic` — только невосстановимое: нарушенный инвариант, сбой инициализации.
|
||||
`recover` — на верхней границе HTTP-обработчика.
|
||||
- Глушить ошибку без лога — только с однострочным комментарием «почему».
|
||||
|
||||
## Логи
|
||||
|
||||
Структурированный JSON (`log/slog`) в stdout, один формат для dev и prod.
|
||||
Сбор и ротацию делает окружение.
|
||||
|
||||
- `msg` — короткая константа в нижнем регистре, категория события
|
||||
(`delivery accepted`, `parse failed`). Данные — атрибутами, не в тексте.
|
||||
Подсистему выносим в поле `capability` (`ingest`/`parse`/`query`), не в
|
||||
префикс сообщения.
|
||||
- **Уровень — это адресат, а не громкость поломки:**
|
||||
|
||||
| Уровень | Кому | Примеры |
|
||||
|---|---|---|
|
||||
| `DEBUG` | разработчику при отладке | `/healthz`, тела запросов, шаги разбора |
|
||||
| `INFO` | владельцу, аудит постфактум | принята доставка, разбор завершён, старт |
|
||||
| `WARN` | владельцу, «может стать проблемой» | точка не разобрана, незнакомая форма метрики |
|
||||
| `ERROR` | владельцу, в разбор | не записался архив, сбой БД |
|
||||
|
||||
- Невалидный ввод от отправителя — `DEBUG`, а не `ERROR`: это норма, разбирать
|
||||
нечего. `WARN` ≠ «ничего страшного», `WARN` = «может стать проблемой».
|
||||
- Событийное → `INFO`, рутинно-частое (healthcheck, поллинг) → `DEBUG`.
|
||||
- **Либо лог, либо возврат, не оба.** Промежуточные слои только оборачивают и
|
||||
возвращают. Ошибка логируется **один раз**, на границе доменного слоя,
|
||||
которая определяет исход операции (`ingest`) — не в транспорте. Транспорт
|
||||
переводит ошибку в ответ и не логирует повторно.
|
||||
- Ошибка — атрибутом: `log.Error("parse failed", "error", err, "delivery_id", id)`.
|
||||
- Время в логах — UTC, RFC 3339 с долями секунды.
|
||||
- Корреляция — по `delivery_id` (ULID), отдельный `trace_id` не заводим.
|
||||
- **Секреты в логи не попадают**: токены приёма и чтения, `Authorization`.
|
||||
При сомнении логируем факт наличия, не значение.
|
||||
- Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
|
||||
и с обрезкой по длине.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
- Только **TOML**, никаких env-переменных: окружение наследуется дочерними
|
||||
процессами и видно через `/proc/<pid>/environ` — для токенов это слабее
|
||||
файла под `0600`.
|
||||
- Грузим один раз при старте в типизированную `Config`; дальше по коду читаем
|
||||
только её. Конфиг неизменяем — смена параметров означает рестарт.
|
||||
- Имя по умолчанию — `config.toml` в рабочей директории, переопределяется
|
||||
`--config=path`.
|
||||
- `config.example.toml` коммитим как единый самодокументируемый справочник:
|
||||
**каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон
|
||||
допустимых значений и в каких единицах. Секретные поля — пустые.
|
||||
- Реальный `config.toml` не коммитится; секреты рендерит деплой.
|
||||
- **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и
|
||||
выход с ненулевым кодом. Не стартуем «наполовину».
|
||||
|
||||
## База данных и идентификаторы
|
||||
|
||||
- Первичные ключи сущностей — **TEXT ULID**, генерируется приложением
|
||||
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
|
||||
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
|
||||
невалидный id — 404 без похода в БД.
|
||||
- Естественный ключ вместо ULID там, где он есть по природе данных: `sample`
|
||||
и `record` — по хешу содержимого, `workout` — по `id` из HealthKit.
|
||||
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
|
||||
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
|
||||
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
|
||||
падать громко.
|
||||
- Enum-поля — обычный `TEXT` без `CHECK`, допустимые значения держит код.
|
||||
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
|
||||
структуры обновляем схему в [architecture.md](architecture.md) тем же
|
||||
изменением.
|
||||
|
||||
## Тесты
|
||||
|
||||
- Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в
|
||||
`testdata` (с вычищенными токенами). Документация формата ненадёжна —
|
||||
источником истины служат живые данные.
|
||||
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
|
||||
витрину.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
# План
|
||||
|
||||
Живой документ: шаги отмечаем по мере готовности, отложенное копится внизу.
|
||||
|
||||
## Ближайшая цель
|
||||
|
||||
Минимальное ядро, которое принимает и сохраняет пакет. Проверяем его
|
||||
**локально**: сервис поднят на рабочей машине, телефон шлёт на её IP по
|
||||
локальной сети. Ни деплоя, ни домена, ни TLS для этого не нужно — они
|
||||
понадобятся, когда сервис поедет на rivendell (шаг 8).
|
||||
|
||||
Это шаги 1–2.
|
||||
|
||||
## Шаги
|
||||
|
||||
- [x] **1. Каркас.** `Taskfile.yml`, `.golangci.yml`, `CLAUDE.md`, TOML-конфиг
|
||||
с валидацией на старте, логгер, подкоманда `serve` с `/healthz`.
|
||||
- [x] **2. Приём без разбора.** `POST /api/v1/ingest`: лимит тела, gzip,
|
||||
запись тела в архив, строка в `delivery`. Разбора ещё нет.
|
||||
← **подключаем телефон по локальной сети**
|
||||
- [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик
|
||||
(`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим
|
||||
`id`, вывод слоя из выравнивания меток, канонизация с округлением чисел
|
||||
и хеш содержимого, слияние точек в объект, два формата дат,
|
||||
`healthlog reindex`.
|
||||
- [ ] **4. Read API.** Каталог метрик со слоями и диапазонами, точки с
|
||||
пагинацией и выбором слоя (сборка из часовых объектов), тренировки,
|
||||
записи.
|
||||
- [ ] **5. Самоописание.** Каталог разрезов + выведенные из данных схемы
|
||||
содержимого со статистикой + статичная схема контракта API.
|
||||
- [ ] **6. `healthlog import`.** Заливка полной истории кусками по годам.
|
||||
- [ ] **7. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина.
|
||||
- [ ] **8. Деплой.** `Dockerfile`, сборка образа локально, доставка на
|
||||
rivendell, конфиг Caddy, поддомен, токены.
|
||||
|
||||
Порядок неслучаен. После шага 2 автоматизация в Health Auto Export включена и
|
||||
копит настоящие пакеты в сыром архиве. Документация формата HAE скудная,
|
||||
поэтому разбор на шаге 3 пишем по реальным данным, а не по догадкам — и
|
||||
заодно видим фактический объём и характер потока.
|
||||
|
||||
## Отложено
|
||||
|
||||
- ~~Отсев идентичных тел доставок по `sha256`.~~ **Вычеркнуто:** находка 2
|
||||
показала, что порядок ключей в JSON нестабилен, поэтому два пакета с одними
|
||||
и теми же данными почти никогда не совпадают побайтно — хеш тела не
|
||||
сработает. Дедупликация возможна только по канонизированному содержимому,
|
||||
а это и делает хеш часового объекта. Отдельная механика не нужна.
|
||||
- **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление
|
||||
старых тел. Пока архив не подчищается; включить после того, как разбор
|
||||
устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна.
|
||||
- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по
|
||||
факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая
|
||||
глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).
|
||||
- **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный
|
||||
эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное
|
||||
уведомление добавим после.
|
||||
- **Аннотации к схемам** — человеческие описания метрик поверх выведенных
|
||||
схем, либо рукописный каталог. Выбор зависит от того, насколько стабильным
|
||||
окажется формат; меняться он может только с обновлением Health Auto Export,
|
||||
а это отслеживается.
|
||||
- **Схема тренировок** — глубину вывода определим по факту, когда увидим,
|
||||
как приходят маршруты.
|
||||
- **Пометка локализованных полей в схемах.** `context`, `value` и подобные
|
||||
приходят на языке телефона и изменятся при смене языка iOS — клиентам не
|
||||
стоит завязываться на конкретные строки
|
||||
([local-research.md](local-research.md), находка 8).
|
||||
- **Вторая автоматизация без группировки** для `sleep_analysis` и
|
||||
`heart_rate_variability` — минутная группировка съедает фазы сна и
|
||||
межударные интервалы ценой ~130 точек в сутки (находка 6).
|
||||
- **Переход на более грубый нижний слой.** Посекундный слой стоит ~730 МБ в
|
||||
год против ~20 МБ у минутного. Когда решим, что мелкая подробность не нужна,
|
||||
достаточно выключить несуммированную автоматизацию — старые данные останутся
|
||||
в своих слоях, переписывать ничего не придётся. Обратный путь тоже есть:
|
||||
ручной экспорт из Apple Health + `healthlog import` восстанавливает нижний
|
||||
слой.
|
||||
- **NDJSON-поток** для больших выборок из read API.
|
||||
- **Слой агрегаций** поверх сырья (суточные/недельные срезы).
|
||||
- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится
|
||||
клиент, которому мало отдачи тренировки одним пакетом.
|
||||
Reference in New Issue
Block a user