healthlog
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export, складывает их в единое хранилище и отдаёт другим моим проектам через HTTP API.
Зачем
Данные о здоровье и тренировках нужны сразу трём моим приложениям: агенту-медику (анализ здоровья), трекеру (разбор тренировок) и игре (мотиватор по активности). Интегрировать каждое из них с Health Auto Export по отдельности — значит в каждом писать приём, дедупликацию и хранение заново.
healthlog делает это один раз. Телефон шлёт данные в него, все остальные проекты берут данные из него.
Границы
Это хранилище, а не аналитика. healthlog принимает, дедуплицирует, хранит и отдаёт. Он не переименовывает поля Apple и не интерпретирует значения — этим занимается тот, кто данные читает.
Одну уступку хранилище всё же делает: оно умеет свести метрику к запрошенной сетке («шаги по дням»). Иначе каждый из клиентов повторял бы одну и ту же логику выбора слоя, а ошибиться в ней легко — просуммировать не тот разрез и получить завышение втрое. Но род свёртки не проставлен вручную, а измерен сверкой слоёв между собой; где измерить не вышло, свёртка не предлагается вовсе.
Источников два: Health Auto Export (куплен, пожизненный премиум) — ежедневный поток, и родной экспорт Apple Health раз в 2–3 месяца — источник истины для нижнего слоя.
Как устроено
экспорт Apple ────────┐ снапшот всей истории, раз в 2–3 месяца
▼
iPhone ──HTTPS POST──► healthlog ──► журнал доставок (.json.gz)
│ │
│ └── события поверх снапшота
▼
SQLite ──┬──► HTTP read API ──► мои приложения
(свёртка по │
журналу) └──► MCP ───────────► агенты
Приём сначала кладёт тело запроса на диск как есть и только потом разбирает.
Значит, ошибка в разборе не теряет данные: состояние всегда пересобирается
свёрткой import(экспорт) + replay(доставки). Отсюда и главный инвариант —
точки хранятся дословно: журнал, из которого что-то выброшено, перестаёт быть
журналом.
Подробности — docs/architecture.md.
Состояние
В разработке. Готовы каркас и приём, большая часть разбора: сервис принимает
пакеты, складывает их в сырой архив и разбирает метрики в часовые объекты
— с выводом слоя из данных, канонизацией содержимого и слиянием точек по
полноте. Тренировки и записи со своим id (workouts, stateOfMind) тоже
разбираются; секции, которых разбор не покрывает, принимаются, хранятся и
честно помечаются как неразобранные — а имя, которого поток раньше не приносил,
даёт WARN в логе свёртки один раз и попадает в перечень healthlog uncovered.
Есть и пересборка: healthlog reindex проигрывает журнал доставок в свежую
витрину и сверяет её отпечаток с накопленной — на живом архиве из 116 тел
пересборка воспроизводима и повторный прогон ничего не меняет.
Маршрутов чтения открыто два. Каталог разрезов (GET /api/v1/metrics) под
токеном чтения отдаёт слои с диапазонами и измеренный род агрегации, а повтор
неизменившегося отвечает 304 по ETag — снимок витрины при этом не
открывается. Точки метрики за период (GET /api/v1/metrics/{name}) едут
одним запросом: слой выбирается по охвату точек внутри периода, а род агрегации
приезжает вместе с данными и с явным указанием, применим ли он к ряду. Журнал
WAL разбирается фоновым чекпойнтом по таймеру.
Чего ещё нет: свёртки по сетке, условного запроса по точкам, тренировок и записей наружу. Что умеет и чего не умеет — docs/tasks/ROADMAP.md.
Разведка формата закончена: 50 находок на живом потоке, половина расходится с документацией Health Auto Export — docs/research/apple-health.md.
Команды
healthlog serve приём + read API (MCP — в планах)
healthlog import родной экспорт Apple Health (в планах)
healthlog reindex пересборка витрины из журнала
healthlog uncovered перечень секций, которых разбор не покрыл
healthlog healthcheck проверка живости для docker HEALTHCHECK
Пересборка витрины
Разбор пишется по реальным данным и будет ошибаться. Исправленный разбор применяется к уже разобранному пересборкой:
healthlog reindex --config ./config.toml
Команда собирает витрину в отдельный файл рядом с рабочей базой и печатает два отпечатка — рабочей витрины и пересобранной. Рабочую базу она не трогает вовсе (открывает её только на чтение и без наката миграций), поэтому запускать её при живом сервисе безопасно — так и стоит делать, если нужно просто сверить.
Применить результат — другое дело. Подмена возможна только при остановленном сервисе: он держит файл базы открытым, и переименование поверх живого процесса портит базу молча. Сервис при этом надо остановить до пересборки, а не после: доставки, приехавшие за время прогона, в собранный файл не попадут, и подмена стёрла бы их учёт вместе с заголовками, которые не восстанавливаются ниоткуда. Команда это проверяет и в таком случае процедуру подмены не печатает вовсе.
task down
healthlog reindex --config ./config.toml
mv ./data/healthlog.db.rebuild ./data/healthlog.db
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
task up
Прогон идёт линейно по архиву: на 116 телах — около полуминуты, и время растёт вместе с архивом. Свободного места нужно не меньше текущего размера базы: собранный файл ложится рядом с ней, на тот же том.
Прогон, убитый жёстко (SIGKILL, потеря питания), оставляет рядом с базой файлы
*.partial* — это его недособранный результат. Штатное прерывание (Ctrl+C) их
убирает само; оставшиеся можно удалять руками, следующему прогону они не мешают.
Локальный запуск
Конфиг необязателен — без него берутся умолчания (:8080, ./healthlog.db,
./raw). Для своих значений скопируй config.example.toml в config.toml.
task run
Проверка:
curl localhost:8080/healthz
curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}'
curl localhost:8080/api/v1/metrics # каталог: слои, диапазоны, род агрегации
# повтор неизменившегося не стоит ничего: метка из ответа возвращается условием
curl -i -H 'If-None-Match: W/"…"' localhost:8080/api/v1/metrics # 304
Подключение телефона по локальной сети
Сервис слушает все интерфейсы (addr = ":8080"), так что телефон в той же
сети достучится по IP машины. В Health Auto Export заводится одна
автоматизация: REST API, формат JSON, минимальная гранулярность
(«Summarize Data» выключен), URL вида http://<ip-машины>:8080/api/v1/ingest.
Если auth.write_tokens пуст, проверка токена выключена — для доверенной
локальной сети этого достаточно, сервис пишет об этом write auth disabled
на старте. Для доступа снаружи понадобится и токен, и TLS — это цель
«Сервис доступен телефону из любой сети».
Документация
- docs/passport.md — цель проекта, типовые сценарии работы, референсы: чужие проекты, у которых смотрим решения, прежде чем придумывать своё
- docs/architecture.md — устройство: принципы, компоненты, внешние границы, эксплуатация, деплой
- docs/database.md — схема хранилища и настройки с числовым значением
- docs/adr/ — почему решено именно так
- docs/conventions/ — как пишем код
- docs/security.md — периметр и модель угроз
- docs/review.md — настройка конвейера ревью и журнал дефектов
- docs/tasks/ROADMAP.md — что приложение уже умеет и чего ещё не умеет
- docs/tasks/BACKLOG.md — что брать следующим, включая отложенные идеи
- docs/research/apple-health.md — что показал реальный поток Health Auto Export; источник истины по формату, документация приложения местами расходится с тем, что оно шлёт