# Архитектура ## Назначение 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/ГГГГ/ММ/ДД/.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 руками / плейбук).