Перепись по 22 метрикам с интервалами опровергла признак из первой редакции: start всегда равен date, интервалы несёт не только сон, обе формы точки не смешиваются внутри метрики одной доставки, а разные интервалы под одной меткой всегда несут разное содержимое. Значит ключ единый — метрика + слой + начало + конец, у измерения вырожденный, без ветвления по классу.
766 lines
59 KiB
Markdown
766 lines
59 KiB
Markdown
# Архитектура
|
||
|
||
## Назначение
|
||
|
||
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
|
||
(далее HAE) и родного экспорта Apple Health, хранит их и отдаёт другим
|
||
приложениям — в том числе агентам, через MCP. Он не переименовывает поля и не
|
||
интерпретирует значения; агрегаты считает только в ответе на запрос и только
|
||
там, где род метрики измерен, а не угадан.
|
||
|
||
## Принципы
|
||
|
||
- **Один статический бинарь** (`CGO_ENABLED=0`), доставка — docker-образом.
|
||
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
|
||
в каком их прислал HAE — без переименований, пересчётов и отбрасывания
|
||
незнакомых полей. Поэтому хранилище само по себе является полной копией
|
||
данных, а не производной выжимкой.
|
||
- **Хранилище — свёртка по журналу, а не единственная копия.** Экспорт Apple
|
||
это снапшот всей истории, доставки HAE после его даты — события поверх
|
||
снапшота. Состояние всегда пересобираемо: `import(экспорт) + replay(доставки)`.
|
||
Отсюда срок жизни сырого архива — до следующего проверенного экспорта, а не
|
||
произвольные две недели. Исключение названо вслух: `stateOfMind` в экспорт не
|
||
попадает, см. «Хранилище».
|
||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
|
||
(см. «Приём»).
|
||
- **Ничего не теряем молча.** Идентичность — устойчивые координаты
|
||
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
|
||
`source` в ключ не входит, он
|
||
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
|
||
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
|
||
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
|
||
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
|
||
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
|
||
см. «Модель синхронизации».
|
||
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
|
||
нормализовано только время. Единственное добавление — стабильный код рядом
|
||
с переведённой строкой (см. «Категориальные значения»): он приписывается, а
|
||
не подменяет.
|
||
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
|
||
разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
|
||
переагрегирования при записи не происходит никогда.
|
||
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
|
||
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
|
||
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
|
||
предлагается: отдаются значения как есть.
|
||
- **Минимум компонентов** — один процесс, 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).
|
||
|
||
## Модель синхронизации
|
||
|
||
У модели два независимых измерения: **глубина окна** (как далеко назад
|
||
переспрашиваем) и **подробность** (в какой слой попадут данные). Проходы
|
||
задаются их сочетанием.
|
||
|
||
По глубине — три прохода, догоняющие друг друга:
|
||
|
||
| проход | расписание | период | зачем |
|
||
|---|---|---|---|
|
||
| быстрый | каждые 5 минут | Since Last Sync | свежесть |
|
||
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
|
||
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное |
|
||
|
||
По подробности — что в какой слой:
|
||
|
||
| подробность | набор метрик | слой |
|
||
|---|---|---|
|
||
| без группировки | только несуммируемые: сон, пульс, HRV | `raw` |
|
||
| минутная | все метрики здоровья | `minute` |
|
||
| часовая | все метрики здоровья | `hour` |
|
||
| ручной экспорт | всё, раз в 2–3 месяца | `sample` |
|
||
|
||
Набор нижнего слоя определяется не важностью метрики, а тем, **можно ли её
|
||
складывать**. Для пульса и HRV посекундная подробность несёт форму сигнала,
|
||
которой в минутном разрезе нет. Для шагов и энергии нижний слой HAE — это
|
||
интерполяция, которая не сходится в сверке (находка 34); держать её значило бы
|
||
хранить втрое больший объём ради худших чисел.
|
||
|
||
Секции без группировки в интерфейсе HAE (`stateOfMind`, `symptoms`, `ecg`,
|
||
`heartRateNotifications`, `cycleTracking`, `medications`) идут как есть — у них
|
||
подробности нет, есть только глубина окна.
|
||
|
||
Средний и глубокий проходы используют **фиксированные окна, а не метку
|
||
синхронизации** — это принципиально. Инкрементальный режим проверен и
|
||
**теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла
|
||
13 961 точку, включая фазы сна за три часа и весь глубокий сон той ночи
|
||
(находка 29). Фиксированное окно идемпотентно по построению и не зависит ни от
|
||
какой метки.
|
||
|
||
Расписание — пожелание, а не гарантия: iOS не даёт приложению запускаться в
|
||
заданное время, а к данным Health доступа нет вовсе, пока телефон заблокирован
|
||
(находка 28). Поэтому проходы привязываются к моментам, когда телефон заведомо
|
||
разблокирован (триггер из Shortcuts по времени суток), а поток считается
|
||
пачечным: тишина ночью, всплеск утром.
|
||
|
||
Гарантия починки:
|
||
|
||
```
|
||
дыра моложе суток → закроется в течение часа
|
||
дыра моложе недели → закроется в течение суток
|
||
дыра старше недели → не закроется; лечится `healthlog import`
|
||
```
|
||
|
||
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
|
||
переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
|
||
почти все из которых сойдутся, и записи не будет.
|
||
|
||
**Прежнее правило «настройки данных у всех проходов одинаковы» снято.** Оно
|
||
существовало потому, что метрика, приехавшая с разной группировкой,
|
||
перетирала сама себя по одному ключу (находка 14). С тех пор слой вошёл в
|
||
ключ, и минутная точка с часовой больше не сталкиваются — они в разных рядах.
|
||
Именно это и позволяет наполнять слои разными автоматизациями намеренно.
|
||
|
||
Условие, при котором это безопасно: слой выводится **из выравнивания меток, а
|
||
не из настройки автоматизации**. Перенастроил автоматизацию — данные просто
|
||
пойдут в другой слой, без порчи уже накопленного.
|
||
|
||
### Досчёт задним числом
|
||
|
||
Метрики правятся после факта, и глубина правки резко разная по классам:
|
||
|
||
- **Количественные** (пульс, шаги, энергия) человек руками не правит; они
|
||
опаздывают на часы. Наблюдались правки хвоста возрастом до 22 минут.
|
||
Недельного глубокого прохода достаточно.
|
||
- **Ручные записи** (`symptoms`, `medications`, `stateOfMind`,
|
||
`cycleTracking`) заводятся задним числом на недели и месяцы — симптом или
|
||
приём лекарства можно отметить за прошлую дату.
|
||
|
||
Поэтому окно досчёта **не единое**. Растягивать глубокий проход на месяц по
|
||
всем метрикам в минутном разрезе нельзя: тела запросов и так доходили до
|
||
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
|
||
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
|
||
единицы записей, и месячное окно там почти ничего не стоит.
|
||
|
||
Последние пришедшие данные всегда актуализируют картину — правило слияния
|
||
одинаково для всех проходов, порядок прихода значения не имеет.
|
||
|
||
Автоматизации различимы по заголовку `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` — тело запроса как пришло, не редактируется.
|
||
|
||
Два источника вместе образуют **полный журнал событий**, а хранилище —
|
||
свёртку по нему:
|
||
|
||
```
|
||
состояние = import(последний проверенный экспорт) ← снапшот всей истории
|
||
+ replay(доставки после его даты) ← хвост событий
|
||
```
|
||
|
||
Экспорт Apple — не просто «источник истины для нижнего слоя», а снапшот: он
|
||
содержит всю историю целиком (3.6 млн записей с 2019 года). Доставки HAE после
|
||
его даты — события поверх снапшота. Значит любое повреждение хранилища,
|
||
включая ошибку в нашем разборе любой давности, лечится пересборкой, а не
|
||
восстановлением из бекапа.
|
||
|
||
Отсюда три следствия, каждое из которых меняет реализацию.
|
||
|
||
**Срок жизни архива определяется циклом экспорта, а не календарём.** Прежние
|
||
14 дней были произвольным числом. Правильное правило: доставки хранятся **до
|
||
следующего проверенного экспорта**, иначе в журнале появится дыра между концом
|
||
ретеншена и датой снапшота. Цена измерена: поток даёт ~23 МБ архива в сутки,
|
||
то есть ~2 ГБ за квартал между экспортами. Это дёшево за возможность
|
||
пересобрать что угодно.
|
||
|
||
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
|
||
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
|
||
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
|
||
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
|
||
строго по `received_at`, а не по порядку файлов в каталоге.
|
||
|
||
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
|
||
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
|
||
существует, она просто вырожденный случай с пустым снапшотом.
|
||
|
||
#### Что не восстанавливается, и это сказано вслух
|
||
|
||
Модель почти полна, но не полностью — умолчать об этом опаснее, чем признать.
|
||
|
||
**`stateOfMind` в экспорте отсутствует вовсе.** Проверено на свежем экспорте:
|
||
ни одного типа со словом `StateOfMind` (есть только `MindfulSession` — это
|
||
минуты осознанности, другое). Состояние разума живёт **только** в доставках
|
||
HAE. Значит для него доставки не хвост журнала, а единственный источник: либо
|
||
они не удаляются никогда, либо его история держится на самих сохранённых
|
||
записях и восстановлению не подлежит.
|
||
|
||
**Верхние слои за периоды с удалёнными доставками.** После проигрывания
|
||
снапшота у старого периода будет только слой `sample`; `minute` и `hour` за
|
||
него не воскреснут. Посчитать их вниз из `sample` технически можно — и нельзя
|
||
по инварианту: это была бы **наша** агрегация под видом присланной.
|
||
|
||
Поэтому правило: **восстановление не обязано быть побайтным, оно обязано быть
|
||
честным.** Каталог разрезов показывает, какие слои есть за какой период; после
|
||
пересборки старый период честно объявляет один слой вместо трёх, а не
|
||
притворяется, что ничего не изменилось.
|
||
|
||
### Устаревание нижнего слоя
|
||
|
||
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
|
||
месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же
|
||
период лежит в слое `sample` подробнее и честнее.
|
||
|
||
Два ограничения, без которых правило опасно:
|
||
|
||
**Пометка вешается по загруженному экспорту, а не по сделанному.** Условие —
|
||
экспорт разобран, и проверено, что он **покрывает период**: непрерывность по
|
||
дням и сходимость сумм с часовым слоем. Иначе срок жизни данных повисает на
|
||
ручной операции, которую можно забыть или сделать наполовину, — а этот
|
||
механизм уже протекал: «Since Last Sync» молча потерял 13 961 точку, включая
|
||
ночь сна целиком (находка 29).
|
||
|
||
**Чистится только нижний слой.** Разница между слоями — три порядка: `hour`
|
||
это ~100 координат в сутки, `minute` ~3 700, `raw` ~100 000 (находка 41).
|
||
Удаление верхних слоёв не экономит ничего, но ломает ответы на исторические
|
||
запросы. Всё давление по объёму создаёт нижний слой, и ровно там экспорт —
|
||
настоящее надмножество.
|
||
|
||
Пометка «устарело» **не равна удалению**: сперва данные помечаются и остаются
|
||
доступными, удаление — отдельный шаг с собственным сроком. Пока восстановление
|
||
из экспорта не проверено на живых данных хотя бы раз, удаление не включается
|
||
вовсе.
|
||
|
||
Оговорка: для секций, которых в экспорте нет (ЭКГ выгружается отдельными CSV,
|
||
судьба `stateOfMind` и лекарств не проверена), экспорт источником истины не
|
||
является и правило к ним неприменимо — их держим всегда.
|
||
|
||
**Это осознанная смена источника истины.** Пока тело в архиве, истина — оно;
|
||
после удаления истиной остаются часовые объекты. Инвариант, который держит
|
||
конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то
|
||
отбрасывать или нормализовать внутри точки, срок хранения архива станет
|
||
сроком жизни данных.
|
||
|
||
### Часовые объекты метрик
|
||
|
||
Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за
|
||
один час 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
|
||
```
|
||
|
||
Почему не переагрегируем **при записи**: правильный способ свёртки зависит от
|
||
метрики (сумма для энергии, среднее для пульса), и ошибка здесь необратима —
|
||
исходные точки уже не вернуть. При записи слои остаются раздельными всегда.
|
||
|
||
Свести их **в ответе** можно, и Read API это делает, — но род свёртки не
|
||
проставляется вручную, а **измеряется**: одна метрика лежит в минутном и
|
||
часовом разрезе одновременно, и если часовое значение сходится с суммой
|
||
минутных, метрика накопительная; если со средним — мгновенная. Форма точки
|
||
рода не выдаёт (`Avg`/`Min`/`Max` есть только у `heart_rate`), единицы дают
|
||
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
|
||
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
|
||
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
|
||
агрегация по метрике не предлагается вовсе.
|
||
|
||
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
|
||
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
|
||
даёт завышение. Накопительные метрики агрегируются только из `minute`,
|
||
`hour` или `sample`.
|
||
|
||
**Слой — это режим выгрузки, которым пришли данные**, а не измеренное
|
||
разрешение каждой метрики. Различие принципиально: частота метрик разная —
|
||
пульс идёт секундами, 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` рядом с ними идёт с секундной
|
||
точностью. Классификация каждой по отдельности растащила бы одну выгрузку по
|
||
трём слоям.
|
||
|
||
**Исключение — `sleep_analysis`.** Под этим именем HAE шлёт две несовместимые
|
||
схемы: поэпизодную (`start`/`end`/`value`/`qty`) и суточную сводку
|
||
(`totalSleep`/`core`/`rem`/`deep`/`awake`, метка на местной полуночи). Общих
|
||
полей, кроме `date` и `source`, у них нет, источники тоже разные — эпизоды от
|
||
стороннего приложения, сводка от часов (находка 38). Правило выравнивания на
|
||
сводке даст `hour`, хотя это суточный итог, а не часовой разрез. Поэтому в
|
||
каталоге они разводятся на два имени — `sleep_analysis` и
|
||
`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как
|
||
`day`. Хранение остаётся дословным: разводятся имена, а не содержимое.
|
||
|
||
Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на
|
||
приёме: это ещё одна причина держать сырой архив.
|
||
|
||
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
|
||
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
|
||
не смешиваются в одном ряду; если же две автоматизации шлют одну метрику с
|
||
одинаковой гранулярностью, это честный дубликат, и его схлопывает хеш.
|
||
|
||
Слои считаются **вниз, но не вверх**: из `raw` получается `hour`, обратно —
|
||
нет. Поэтому самый мелкий слой стоит держать, пока он не станет дорог; цена
|
||
измерена — около 730 МБ в год против 20 МБ у минутного. Страховка на случай,
|
||
если мелкий слой всё-таки выключат: **ручной экспорт из Apple Health**
|
||
восстанавливает нижний слой целиком через `healthlog import`.
|
||
|
||
**Идентичность точки — координаты, а не содержимое.**
|
||
|
||
```
|
||
ключ: метрика + слой + начало + конец конец = начало, если end нет
|
||
значения: qty / Min / Avg / Max / source / … ← перезаписываются
|
||
```
|
||
|
||
**Ключ — интервал, а не метка.** Метка на записи сна не уникальна: под одним
|
||
`date` лежит до трёх записей, и это не дефект, а способ Apple выразить
|
||
вложенность «в кровати» и фазы внутри неё. Измерено на всём корпусе (находка
|
||
47): 174 координаты против 170 по метке, ноль столкновений против 33 **внутри
|
||
одной доставки**, где тай-брейк по времени приёма неприменим в принципе.
|
||
`value` в ключе ничего не добавляет.
|
||
|
||
Форма ключа **одна для всех точек**. Интервалы несут 22 метрики, а не только
|
||
сон; `start`, когда он есть, всегда равен `date`; обе формы точки не
|
||
смешиваются внутри метрики одной доставки. Поэтому отдельного класса «эпизодных
|
||
метрик» нет — нечего выводить и нечего поддерживать в каталоге и Read API. Час
|
||
объекта берётся по началу, иначе принадлежность объекту зависела бы от
|
||
длительности.
|
||
|
||
`HKObject.uuid` дал бы идентичность даром, но в выгрузку Apple он не попадает —
|
||
там у записи только `type`, `sourceName`, `sourceVersion`, `creationDate`,
|
||
`startDate`, `endDate`, `value`. Значит модель обязана выражаться через
|
||
`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`.
|
||
|
||
Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали
|
||
задвоение на живых данных:
|
||
|
||
- **числа сериализуются нестабильно** — 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` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
|
||
эксплуатации, а не из предположений.
|
||
|
||
### Категориальные значения
|
||
|
||
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
|
||
фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип
|
||
тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При
|
||
этом `stateOfMind` в том же пакете шлёт честные коды HealthKit
|
||
(`momentary_emotion`, `slightly_pleasant`, `drained`) — значит дело не в
|
||
приложении, а в том, что старые секции тянут строки из UI (находка 37).
|
||
|
||
Оставить как есть нельзя по трём причинам, и третья решающая:
|
||
|
||
1. Клиент вынужден угадывать словарь вместо того, чтобы сравнивать с кодом.
|
||
2. Смена языка телефона молча расколет историю: та же фаза сна станет другим
|
||
значением, и по координатному ключу это неотличимо от изменения данных.
|
||
3. **Родной экспорт Apple говорит кодами** (`HKCategoryValueSleepAnalysisAsleepREM`).
|
||
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
|
||
но сверить покрытие по этим полям было бы нечем.
|
||
|
||
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**:
|
||
|
||
```
|
||
value "БДГ" ← как прислал HAE
|
||
value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю
|
||
```
|
||
|
||
Словарь ключуется парой `(локаль, строка)`; локаль берётся из
|
||
`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой
|
||
строки код пустой — пустота честнее догадки, и она же видна в `/stats` как
|
||
список того, что пора добавить в словарь.
|
||
|
||
Дословность инварианта не нарушена: код **приписывается**, а не подменяет
|
||
строку. Обратное преобразование всегда возможно.
|
||
|
||
### Тренировки и прочие секции
|
||
|
||
Тренировка адресуется своим `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}?from&to&bucket&layer точки метрики, при желании свёрнутые
|
||
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", "units": "count/min", "aggregation": "instant",
|
||
"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}
|
||
]}
|
||
```
|
||
|
||
`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него
|
||
зависит, что вообще можно спросить.
|
||
|
||
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
|
||
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
||
границе периода нельзя: ряд поедет незаметно для клиента.
|
||
|
||
### Свёртка и размер ответа
|
||
|
||
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
|
||
|
||
```
|
||
?from&to все значения за период вес, лекарства, симптомы
|
||
?from&to&bucket=day значения с разбивкой шаги, энергия
|
||
```
|
||
|
||
Главный потребитель — агент, у которого ограничен контекст. «Пульс за неделю»
|
||
без разбивки — это десятки тысяч точек в минутном слое и сотни тысяч в
|
||
нижнем. Правило:
|
||
|
||
- **Разбивка не задана, ответ не влезает** — сервер сам берёт сетку погрубее,
|
||
чтобы влезло, и называет её в ответе. Агент всегда получает ответ и может
|
||
переспросить уже.
|
||
- **Разбивка задана явно, ответ не влезает** — это ошибка, а не тихая
|
||
подмена. В теле ошибки — число точек по каждой доступной сетке, чтобы
|
||
второй запрос был заведомо успешным.
|
||
|
||
Различие существенно: «указали уровень» работает как информация в первом
|
||
случае и как защита во втором. Иначе агент, попросивший минутную сетку,
|
||
получил бы суточные суммы и заметил бы это, только прочитав поле, которое
|
||
вполне может не прочитать.
|
||
|
||
Свёртка применяет род из каталога: `cumulative` — сумма, `instant` —
|
||
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
|
||
`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются
|
||
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
|
||
|
||
### Форма ответа
|
||
|
||
Нормализованная оболочка, сырое содержимое:
|
||
|
||
```json
|
||
{"layer": "minute", "bucket": "hour", "aggregation": "sum",
|
||
"points": [
|
||
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
|
||
"values": {"qty": 812}}
|
||
]}
|
||
```
|
||
|
||
`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не
|
||
было (`"bucket": null`): клиент не должен выводить их наличием или
|
||
отсутствием поля.
|
||
|
||
Время приведено к единому виду, значения отданы как пришли: ни
|
||
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
|
||
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
|
||
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
|
||
|
||
### MCP
|
||
|
||
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
|
||
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
|
||
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
|
||
|
||
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
|
||
ходит к нему по сети. Отсюда следствия:
|
||
|
||
- MCP — это **эндпоинт того же процесса**, а не отдельная подкоманда: тот же
|
||
бинарь, тот же порт, тот же Caddy впереди с TLS.
|
||
- Аутентификация — **тот же токен чтения** в `Authorization: Bearer`, что и у
|
||
Read API. Отдельного контура доступа не заводим: MCP не даёт ничего, чего
|
||
не даёт HTTP, и права у них обязаны совпадать.
|
||
- Правило размера ответа (см. выше) здесь не украшение, а необходимость:
|
||
сетевой агент не имеет возможности «посмотреть поближе» иначе, чем
|
||
повторным вызовом.
|
||
|
||
## Самоописание
|
||
|
||
Сервис описывает свои данные сам: клиент (в том числе 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 умеет слать произвольные заголовки, этого достаточно.
|
||
|
||
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
|
||
данные, не может писать. MCP пользуется токеном чтения — отдельного контура
|
||
у него нет, см. «MCP».
|
||
|
||
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
|
||
приложения). Оба через Caddy с TLS, оба с разными токенами.
|
||
|
||
## Деплой
|
||
|
||
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
|
||
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
|
||
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
|
||
экспорт копится и уезжает пачкой при возвращении домой.
|
||
|
||
Сборка — на локальной машине: статический бинарь и docker-образ; на сервер
|
||
едет готовый образ. Go-тулчейн на сервере не нужен.
|
||
|
||
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
|
||
(с токенами) — отдельно, `0600`.
|
||
|
||
## Открытые вопросы
|
||
|
||
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
|
||
- **Предел размера ответа** — в точках или в оценке байт. Точки считать
|
||
проще, но у `heart_rate_variability` с `heartbeatSeries` точка на два
|
||
порядка тяжелее, чем у `step_count` (находка 39).
|
||
- **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV ради данных,
|
||
которых нет ни в одном из планируемых запросов.
|
||
- **Есть ли `stateOfMind`, симптомы и лекарства в родном экспорте.** От этого
|
||
зависит, применимо ли к ним устаревание нижнего слоя.
|
||
- **Как проверять покрытие экспортом** — до какой строгости. Непрерывности по
|
||
дням и сходимости сумм, вероятно, хватит, но порог не выбран.
|
||
- **Хранилище под аналитику.** Сейчас SQLite: часовые объекты дают ~260 тыс.
|
||
строк на слой в год независимо от плотности точек, а плоская таблица по
|
||
грубым слоям (`hour` ~36 тыс. строк в год, `minute` ~1.4 млн) делает
|
||
`GROUP BY` дешёвым без разжатия блобов. DuckDB рассматривался и отложен:
|
||
чистого Go-драйвера нет, любой требует cgo, что стоит нам
|
||
`CGO_ENABLED=0` и одного статического бинаря. Дверь при этом открыта —
|
||
DuckDB читает и parquet, и файл SQLite напрямую, так что выгрузка в parquet
|
||
остаётся отдельной командой на случай тяжёлой аналитики снаружи.
|