Files
healthlog/docs/architecture.md
T
av 5e2385ba6e добавлены документация проекта и каркас разработки
- README, CLAUDE.md, docs: назначение и границы, архитектура, конвенции, план
- docs/local-research.md — 36 находок по формату Health Auto Export, снятых на
  живых данных; документация приложения местами расходится с тем, что оно шлёт
- Taskfile, .golangci.yml, самодокументируемый config.example.toml
2026-08-01 12:37:03 +03:00

35 KiB
Raw Blame History

Архитектура

Назначение

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 и wiki Lybron/health-auto-export. Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам.

Автоматизация 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.

Даты приходят строкой с офсетом: 2026-07-31 12:00:00 +0300 — не RFC 3339.

Тренировка (v2) несёт стабильный id из HealthKit, start/end/duration, опционально route (точки GPS) и heartRateData.

Наша настройка: несуммированные данные (переключатель «Суммировать данные» выключен, группировка при этом недоступна). Причина — суммированные значения досчитываются задним числом: минутное ведро уезжает неполным и в следующей доставке приезжает полным (local-research.md, находка 10). На несуммированных данных расхождений не наблюдалось (находка 3), поэтому идентичность по содержимому работает без оговорок. Заодно сохраняются детали, которые группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19).

Чего это не даёт: настоящих сэмплов. Накопительные метрики (энергия, шаги, дистанция) в любом режиме приходят посекундной сеткой — нарезкой реальных сэмплов длиной 1–12 секунд, с сохранением итога и потерей границ интервала. Порядка 135 тысяч точек в сутки (находки 20, 23).

Поля start/end есть только у дискретных метрик (пульс, сатурация, сон, HRV); у накопительных — только date. Поэтому точка относится к часу по date, а вопрос о сэмплах, пересекающих границу часа, касается сотой доли данных (находка 21).

Модель синхронизации

Данные приходят тремя проходами разной глубины — три автоматизации HAE с одинаковыми настройками данных, различающиеся только расписанием и периодом:

проход расписание период зачем
быстрый каждые 5 минут Since Last Sync свежесть
средний 34 раза в день 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. Плотных метрик в доставке нет вовсе — слой наследуется от предыдущей доставки той же автоматизации; если её не было, берём заголовок (Minutesminute, Hourshour, иначе 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.

Слои, наоборот, часть контракта. Каталог показывает, какие разрезы есть и за какой период:

{"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 выбирает разрез. Если он не указан — отдаём самый мелкий слой, покрывающий весь запрошенный диапазон, и в ответе всегда называем, какой слой отдан. Молча переключать слой на границе периода нельзя: ряд поедет незаметно для клиента.

Форма точки в ответе — нормализованная оболочка, сырое содержимое:

{"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. Незнакомая метрика описывает себя сама, без релиза.

Схема отдаётся вместе со статистикой — для потребителя она важнее формального типа:

{"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 руками / плейбук).