From 5e2385ba6e90744feab778dab78132e7ce409cf8 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 1 Aug 2026 12:37:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README, CLAUDE.md, docs: назначение и границы, архитектура, конвенции, план - docs/local-research.md — 36 находок по формату Health Auto Export, снятых на живых данных; документация приложения местами расходится с тем, что оно шлёт - Taskfile, .golangci.yml, самодокументируемый config.example.toml --- .gitignore | 16 + .golangci.yml | 88 +++ CLAUDE.md | 82 +++ README.md | 93 ++++ Taskfile.yml | 54 ++ config.example.toml | 30 + docs/architecture.md | 474 ++++++++++++++++ docs/conventions.md | 109 ++++ docs/local-research.md | 1180 ++++++++++++++++++++++++++++++++++++++++ docs/plan.md | 79 +++ go.mod | 28 + go.sum | 85 +++ 12 files changed, 2318 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 Taskfile.yml create mode 100644 config.example.toml create mode 100644 docs/architecture.md create mode 100644 docs/conventions.md create mode 100644 docs/local-research.md create mode 100644 docs/plan.md create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..518bbb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Сборка +/healthlog + +# Реальный конфиг (токены), локальная БД и сырой архив +/config.toml +*.db +*.db-wal +*.db-shm +/raw/ + +# Временные файлы +/tmp/ + +# IDE +/.idea/ +/.vscode/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1400ab9 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,88 @@ +# Конфиг golangci-lint (схема v2; устанавливается через `task setup`). +# +# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign, +# staticcheck, unused. Сверх него включены линтеры, механизирующие конвенции +# из docs/conventions.md: то, что проверяет правило, не остаётся прозой. +version: "2" + +linters: + enable: + - misspell + # docs/conventions.md, «Логи»: msg — константная категория, данные — в + # полях, единый стиль ключ-значение. + - sloglint + # docs/conventions.md: без fmt.Print* (логируем через slog), конфиг только + # из TOML (env не используем), время — только store.Now(). + - forbidigo + # docs/conventions.md, «Ошибки»: сравнение через errors.Is/As. + - errorlint + # docs/conventions.md, «Ошибки»: ошибки — только stdlib. + - depguard + + settings: + sloglint: + no-mixed-args: true # не мешать пары «ключ-значение» с slog.Attr + kv-only: true # принятый в проекте стиль вызова + static-msg: true # msg — константа, без fmt.Sprintf и интерполяции + + forbidigo: + forbid: + - pattern: ^fmt\.Print.*$ + msg: логируем через slog, в stdout напрямую не пишем (docs/conventions.md) + - pattern: ^os\.Getenv$ + msg: конфигурация только из TOML, env для конфига не используем (docs/conventions.md) + - pattern: ^time\.Now$ + msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions.md + + errorlint: + # Обёртка вида fmt.Errorf("%w: %v", ErrSentinel, err) осознанна: sentinel + # раскрываем для errors.Is, причину — намеренно нет. + errorf: false + asserts: true + comparison: true + + depguard: + rules: + main: + deny: + - pkg: github.com/pkg/errors + desc: ошибки — только stdlib errors + fmt.Errorf (docs/conventions.md) + - pkg: github.com/cockroachdb/errors + desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions.md) + + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ + rules: + # CLI — другая поверхность: печатает результат в stdout, это не логи. + - path: ^cmd/ + linters: [forbidigo] + # Единая точка генерации id и времени — им time.Now по определению можно. + - path: ^internal/(ident|store)/ + text: time.Now + linters: [forbidigo] + # Замер длительности запроса — не метка времени в БД: засекается на + # входе транспорта и никуда не сохраняется. + - path: ^internal/httpapi/ + text: time.Now + linters: [forbidigo] + # В тестах время задаётся явно, чтобы не зависеть от часов машины. + - path: _test\.go$ + text: time.Now + linters: [forbidigo] + +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..364d006 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +Памятка для работы над healthlog. Перед задачей прочитай также +[README.md](README.md), [docs/architecture.md](docs/architecture.md), +[docs/conventions.md](docs/conventions.md) и [docs/plan.md](docs/plan.md). + +## Что это + +Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export, +хранит их и отдаёт другим моим проектам через HTTP API. Это **хранилище, а не +аналитика**: принять, дедуплицировать, сохранить, отдать. Не считать агрегаты, +не переименовывать поля Apple, не интерпретировать значения. + +## Стек + +Go, один статический бинарь (`CGO_ENABLED=0`). SQLite (`modernc.org/sqlite`, +чистый Go), `chi`, `sqlx`, `goose` (миграции), `pelletier/go-toml/v2`, +`log/slog`, ULID через `internal/ident`. + +Module path — `git.vakhrushev.me/av/healthlog`. + +## Инварианты + +- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде, + в каком их прислал HAE. На этом инварианте держится всё остальное: сырой + архив живёт лишь 14 дней, дальше истина — сами объекты. Начнём что-то + отбрасывать внутри точки — срок хранения архива станет сроком жизни данных. +- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: + битый JSON — 400, непонятое содержимое — 200. +- **Ничего не теряем молча.** Идентичность — хеш **канонизированного** + (рекурсивно отсортированного) содержимого: повтор не меняет ничего, + различие сохраняется. Изменение запечатанного часа — `WARN`, но данные + всё равно пишутся. +- **Дыры закрываются сами.** Три прохода синхронизации разной глубины + (5 минут / час / сутки), настройки данных у всех одинаковы. +- **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано + только время (`ts_utc` + офсет исходной зоны). +- **Своей агрегации нет — есть слои.** Метрика хранится в той подробности, в + какой пришла (`raw`/`minute`/`hour`); слой выводится из выравнивания меток, + а не из заголовка HAE — тот врёт. Клиенту показываем каталог разрезов, выбор + за ним. +- **Секреты не в логах** — токены приёма и чтения. Данные о здоровье + чувствительны: тела запросов только на `DEBUG` и с обрезкой. + +## Команды + +Запуск через [Task](https://taskfile.dev) (`task --list` — полный список): + +- `task run` — локальный запуск (`--config ./config.toml`) +- `task build` — статический бинарь linux/amd64 +- `task test` / `task lint` — тесты и golangci-lint +- `task tidy` — `go mod tidy` +- `task setup` — установка golangci-lint + +## Конвенции + +Механизируемое проверяет `task lint` (`.golangci.yml`): форма логов +(`sloglint`), `fmt.Print*` / `os.Getenv` / `time.Now` мимо единых точек +(`forbidigo`), сравнение ошибок (`errorlint`), сторонние пакеты ошибок +(`depguard`). Пересказывать эти правила не нужно — линтер скажет точнее. + +Прозой остаётся то, что правилом не выражается: +[docs/conventions.md](docs/conventions.md) — уровень лога по адресату, +единственный логирующий чекпоинт на доменной границе, трансляция ошибки на +внешней границе, самодокументируемый `config.example.toml`, время в БД в UTC +RFC 3339, ULID через `ident`. + +Отдельно: **тесты на разбор формата HAE держим на реальных пакетах** в +`testdata`. Документация формата тонкая и местами расходится с тем, что +приложение реально шлёт, — источником истины служат живые данные. + +Что показал реальный поток — [docs/local-research.md](docs/local-research.md). +Читать **до** работы над разбором: там же лежат находки, которых нет в +документации HAE (поле `source` существует; порядок ключей в JSON нестабилен, +поэтому хеш содержимого считается по канонической форме с рекурсивной +сортировкой; мелкая группировка даёт интерполяцию, а не измерения). Файл +пополняется по мере накопления доставок. + +## Язык + +- Документация, комментарии, сообщения коммитов — **русский**. +- Код и идентификаторы — английский. diff --git a/README.md b/README.md new file mode 100644 index 0000000..065103f --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# healthlog + +Коллектор данных Apple Health. Принимает выгрузки из +[Health Auto Export](https://www.healthyapps.dev/apps/health-auto-export/), +складывает их в единое хранилище и отдаёт другим моим проектам через HTTP API. + +## Зачем + +Данные о здоровье и тренировках нужны сразу нескольким приложениям: анализ +здоровья, разбор тренировок, мотиватор по активности. Интегрировать каждое +из них с Health Auto Export по отдельности — значит в каждом писать приём, +дедупликацию и хранение заново. + +healthlog делает это один раз. Телефон шлёт данные в него, все остальные +проекты берут данные из него. + +## Границы + +Это **хранилище**, а не аналитика. healthlog принимает, дедуплицирует, +хранит и отдаёт. Он не считает агрегаты, не переименовывает поля Apple и не +интерпретирует значения — этим занимается тот, кто данные читает. + +Единственный источник — Health Auto Export (куплен, пожизненный премиум). +Другие источники не поддерживаем. + +## Как устроено + +``` +iPhone ──HTTPS POST──► healthlog ──► сырой архив (файлы, .json.gz) + │ │ + │ └── источник истины, не трогаем + ▼ + SQLite-витрина ──► HTTP read API ──► мои приложения + (пересобирается из архива) +``` + +Приём сначала кладёт тело запроса на диск как есть и только потом разбирает. +Значит, ошибка в нашем разборе не может привести к потере данных: витрина +пересобирается из архива командой `healthlog reindex`. + +Подробности — [docs/architecture.md](docs/architecture.md). + +## Состояние + +В разработке. Готовы шаги 1–2 из 8: сервис принимает пакеты и складывает их в +сырой архив. Разбора, витрины и read API ещё нет — план в +[docs/plan.md](docs/plan.md). + +## Команды + +``` +healthlog serve приём + read API +healthlog import заливка файлов в архив и витрину (шаг 6) +healthlog reindex пересборка витрины из сырого архива (шаг 3) +healthlog healthcheck проверка живости для docker HEALTHCHECK +``` + +## Локальный запуск + +Конфиг необязателен — без него берутся умолчания (`: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":[]}}' +``` + +### Подключение телефона по локальной сети + +Сервис слушает все интерфейсы (`addr = ":8080"`), так что телефон в той же +сети достучится по IP машины. В Health Auto Export заводится одна +автоматизация: **REST API**, формат **JSON**, минимальная гранулярность +(«Summarize Data» выключен), URL вида `http://:8080/api/v1/ingest`. + +Если `auth.write_tokens` пуст, проверка токена выключена — для доверенной +локальной сети этого достаточно, сервис пишет об этом `write auth disabled` +на старте. Для доступа снаружи понадобится и токен, и TLS (шаг 8). + + +## Документация + +- [docs/architecture.md](docs/architecture.md) — устройство, схема данных, API, принятые решения +- [docs/conventions.md](docs/conventions.md) — как пишем код +- [docs/plan.md](docs/plan.md) — шаги и отложенное +- [docs/local-research.md](docs/local-research.md) — что показал реальный поток + Health Auto Export; источник истины по формату, документация приложения + местами расходится с тем, что оно шлёт diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..718d0f9 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,54 @@ +# yaml-language-server: $schema=https://taskfile.dev/schema.json +# +# Запуск команд проекта через Task (https://taskfile.dev). +# Список задач: `task --list`. + +version: '3' + +vars: + BINARY: healthlog + PKG: ./cmd/healthlog + # Версии инструментов для воспроизводимой установки (см. задачу setup). + GOLANGCI_VERSION: v2.12.2 + +tasks: + default: + desc: Список доступных задач + cmds: + - task --list + silent: true + + run: + desc: 'Локальный запуск (config.toml не обязателен — есть умолчания)' + cmds: + - go run {{.PKG}} --config ./config.toml | hl + + build: + desc: Статический бинарь linux/amd64 для сервера + cmds: + - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o {{.BINARY}} {{.PKG}} + + test: + desc: Прогон тестов + cmds: + - go test ./... + + lint: + desc: Запуск golangci-lint + cmds: + - golangci-lint run + + tidy: + desc: go mod tidy + cmds: + - go mod tidy + + clean: + desc: Удалить собранный бинарь + cmds: + - rm -f {{.BINARY}} + + setup: + desc: Установка инструментов разработки + cmds: + - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.GOLANGCI_VERSION}} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..edf74c3 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,30 @@ +# Образец конфигурации healthlog. +# +# Реальный config.toml не коммитится (в нём токены), права 0600. +# Все поля необязательны: без файла сервис поднимается на умолчаниях, что +# удобно для локального запуска. Умолчание каждого поля указано ниже. + +[server] +addr = ":8080" # адрес прослушивания; ":8080" — все интерфейсы (нужно, чтобы телефон достучался по локальной сети) +read_timeout = "5m" # на всё чтение запроса вместе с телом; Go-duration. Щедро: экспорт истории — десятки мегабайт по мобильной сети +write_timeout = "30s" # на отправку ответа; Go-duration + +[auth] +# Токены проверяются как `Authorization: Bearer <токен>`. +# Health Auto Export умеет слать произвольные заголовки — токен задаётся в +# настройках автоматизации. +# ПУСТОЙ СПИСОК = ПРОВЕРКА ВЫКЛЮЧЕНА. Так можно в доверенной локальной сети; +# сервис предупреждает об этом на старте записью `write auth disabled`. +write_tokens = [] # токены на приём данных +read_tokens = [] # токены на чтение (read API появится позже) + +[storage] +db_path = "./healthlog.db" # файл SQLite-витрины; каталог должен существовать +archive_dir = "./raw" # корень сырого архива; создаётся при старте + +[ingest] +max_body_mb = 64 # максимальный размер тела запроса, МиБ; целое > 0. Больше — 413 + +[log] +level = "info" # debug | info | warn | error +format = "json" # json | text (text удобен при локальной отладке) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f7f3655 --- /dev/null +++ b/docs/architecture.md @@ -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/ГГГГ/ММ/ДД/.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 руками / плейбук). diff --git a/docs/conventions.md b/docs/conventions.md new file mode 100644 index 0000000..84d759a --- /dev/null +++ b/docs/conventions.md @@ -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//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` (с вычищенными токенами). Документация формата ненадёжна — + источником истины служат живые данные. +- Проверяем идемпотентность: повторный разбор того же пакета не меняет + витрину. diff --git a/docs/local-research.md b/docs/local-research.md new file mode 100644 index 0000000..d075b5f --- /dev/null +++ b/docs/local-research.md @@ -0,0 +1,1180 @@ +# Разведка на живых данных + +Журнал наблюдений за реальным потоком Health Auto Export. Документация +формата ([wiki](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format)) +тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому +источником истины служит этот файл. + +Пополняется по мере накопления доставок. Каждый вывод — с числами и командой, +которой он получен, чтобы его можно было перепроверить. + +## Как снималось + +Сервис запущен локально (`task run`), телефон шлёт по локальной сети на IP +машины. Автоматизация — REST API, JSON, интервал 5 минут. + +Накоплено к 2026-08-01: **42 доставки, 165 МБ тел, 6,6 МБ архива**. +Три автоматизации, режимы менялись по ходу разведки: + +| автоматизация | что шлёт | режимы, которые прошли | +|---|---|---| +| `BC99C8A3` | показатели здоровья | суммирование посекундно → поминутно → **выключено**, период Today → Default → **Since Last Sync** | +| `37A43AE1` | тренировки (сперва ошибочно показатели) | период Default | +| `F4458FA4` | состояние разума | период Default | + +За это время снято: суммированные данные обеих гранулярностей, +несуммированные, тренировка в помещении и уличная с геотреком, состояния +разума, ночь целиком. + +Разбор — командами вида: + +``` +gzip -dc raw/2026/07/31/.json.gz | jq -r '...' +``` + +## 1. Поле `source` существует + +Документация утверждает, что источника в точке метрики нет. **Это неверно** — +`source` есть в каждой точке, в обоих режимах группировки. + +Значения бывают **составными**, через `|`: + +``` +156386 Apple Watch Ultra 3|iPad (Anton) +133923 Apple Watch Ultra 3 + 39903 Apple Watch Ultra 3|iPhone (Anton) + 294 iPhone (Anton) + 18 (пустая строка) + 10 AutoSleep +``` + +Составное значение означает, что несколько устройств писали одно и то же, а +Health Auto Export слил их вклад в одну точку и перечислил всех участников. +Пустая строка тоже встречается (18 точек `apple_stand_hour`) — поле +необязательно. + +### Почему в источнике оказался неподвижный iPad + +Наблюдение, которое сперва выглядело аномалией. Разбор: + +- iPad **никогда не встречается в одиночку**, только в паре с часами; +- он появляется ровно в одной метрике — `basal_energy_burned`; +- суммы за сутки правдоподобны (1892 и 2121 ккал базального обмена против + 423–519 ккал активной энергии), то есть **вклады не складываются дважды**. + +Базальный обмен — единственная метрика, не требующая движения: это расчёт от +профиля на прошедшее время, и его пишет любое устройство с Health, включая +стоящий на столе планшет. По той же причине `iPhone (Anton)` приклеивается к +шагам и дистанции — телефон в кармане их честно считает. + +**Вывод:** составной `source` — это метка «кто вложился», а не признак порчи +данных. Значения корректны. + +## 2. Порядок ключей в JSON нестабилен + +Самая важная находка для реализации. Из 81 952 точек, встретившихся в двух +доставках, **67 534 отличаются только порядком ключей**: + +```json +{"qty":0.005001252398249198,"date":"2026-07-31 00:24:43 +0300","source":"Apple Watch Ultra 3"} +{"date":"2026-07-31 00:24:43 +0300","source":"Apple Watch Ultra 3","qty":0.005001252398249198} +``` + +Хеш по сырым байтам поймал бы только **18%** повторов — витрина распухла бы в +пять раз дублями одних и тех же точек. + +**Следствие для схемы:** хеш содержимого считается по **канонической форме с +рекурсивной сортировкой ключей**. Рекурсивной — потому что одиннадцать +оставшихся «расхождений» оказались тем же самым беспорядком внутри вложенного +массива `heartbeatSeries`. + +## 3. Значения не меняются задним числом + +После канонизации расхождений по значению — **ноль на 81 941 общей точке**. + +Гипотеза, на которой строилась модель идентичности («сырые сэмплы HealthKit +неизменяемы, задним числом пересчитываются только агрегаты»), подтвердилась +для посекундного режима. Модель «повтор — no-op» верна. + +**Не проверено для минутного режима** — см. «Открытые вопросы». + +## 4. Формы точки + +Единой формы значения нет, но их немного. При посекундной группировке — шесть +на 330 тысяч точек: + +``` +328891 [date, qty, source] + 934 [date, start, end, qty, source] + 537 [date, start, end, Min, Avg, Max, context, source] heart_rate + 107 [date, start, end, startDate, endDate, qty, value, source] sleep_analysis + 43 [date, start, end, Min, Avg, Max, source] + 22 [date, start, end, qty, heartbeatSeries, source] HRV +``` + +При минутной — три: + +``` +6447 [date, qty, source] + 573 [Avg, Max, Min, date, source] + 2 [asleep, awake, core, date, deep, inBed, inBedEnd, inBedStart, + rem, sleepEnd, sleepStart, source, totalSleep] sleep_analysis +``` + +Обобщённая модель (`payload JSON` + нормализованное время) покрывает оба +режима без выделения типов. + +Даты — строкой с офсетом: `2026-07-31 12:00:00 +0300`, не RFC 3339. +Внутри `heartbeatSeries` время другое — числовой Unix timestamp с долями. + +## 5. «Минимальная гранулярность» даёт синтетические данные + +При группировке Default точки идут **раз в секунду**, непрерывно: + +``` +2026-07-31 00:00:00 +0300 +2026-07-31 00:00:01 +0300 +2026-07-31 00:00:02 +0300 +``` + +86 400 точек в сутки на один только `basal_energy_burned`. Но посекундный +базальный обмен **никто не измеряет** — это гладкая расчётная кривая, которую +Health Auto Export нарезает на секундные ломтики по запрошенной группировке. + +Проверка: суммы за завершившиеся сутки 30 июля совпадают до килокалории. + +| | посекундно | поминутно | +|---|---|---| +| `basal_energy_burned` | 2181 ккал | 2181 ккал | +| `active_energy` | 519 ккал | 519 ккал | + +**Вывод:** мелкая группировка ≠ сырые данные. + +> **Уточнено находкой 20.** Причина посекундной сетки — не группировка: +> выключение суммирования её не убрало. И это не выдумка на пустом месте, а +> нарезка настоящих сэмплов, которых под ней в 2,4 раза меньше. Читать +> находку 20 вместе с этой. + +## 6. Что теряет минутная группировка + +Не всё посекундное было мусором. При переходе на минуты исчезает: + +- **`heartbeatSeries`** — межударные интервалы, по полсотни ударов в каждой из + 22 записей `heart_rate_variability`. Единственные по-настоящему измеренные + сырые данные во всём наборе; остаётся одно число `qty`. +- **Детализация сна** — было 107 эпизодов с фазами и границами, стало 2 + суточных агрегата (`totalSleep / core / deep / rem / awake`). +- **`context` у пульса и `start`/`end`** у интервальных метрик — схлопываются + в одну `date`. + +По объёму потерянное — около **130 точек в сутки** на фоне 3300. То есть оно +стоит копейки, но выброшено заодно с посекундной интерполяцией, которая стоила +150 тысяч точек. + +Возможный гибрид: вторая автоматизация без группировки только для +`sleep_analysis` и `heart_rate_variability`, первая — минутная для остального. +Наборы метрик не пересекаются, поэтому коллизий по `(метрика, время)` между +автоматизациями не возникнет и «поток-источник» в ключе не понадобится. + +## 7. Объём + +| режим | тело | точек | в архиве | точек в сутки | +|---|---|---|---|---| +| посекундно | 57,2 МБ | 330 534 | 2,2 МБ | ~155 000 | +| поминутно | 1,2 МБ | 7 022 | 72 КБ | ~3 300 | + +Gzip жмёт такой JSON примерно **в 25 раз** — архив дешевле, чем закладывалось. + +Пакеты сильно перекрываются: период `Today` покрывал двое суток, `Default` — +трое. При интервале в 5 минут каждая доставка целиком переприсылает окно, и +архив растёт впустую: посекундно это около 630 МБ в сутки, поминутно — около +20 МБ. Лечится сменой периода на **«Since Last Sync»**. + +## 8. Строковые значения локализованы + +```json +"context": "Не задано" (heart_rate) +"value": "Во сне" (sleep_analysis) +``` + +Приходят **на языке телефона**. Смена языка iOS изменит их, и данные до и +после смены перестанут сходиться. Хранить как есть обязаны — это сырьё; но в +самоописании такие поля стоит помечать, а клиентам не завязываться на +конкретные строки. + +В минутном режиме оба поля исчезают, так что ловушка возникает только при +мелкой группировке. + +## 9. Секции + +Приходит только то, что включено в автоматизации. В снятых доставках — одна +секция `metrics` (26 метрик); `workouts` и остальные отсутствуют как ключи, +а не приходят пустыми. Разбор обязан это переживать. + +## 10. Минутные агрегаты досчитываются задним числом — да + +Сравнение трёх подряд идущих минутных доставок (интервал 5 минут), ключ +`(метрика, дата, источник)`, рекурсивная канонизация: + +``` +17:44 → 17:49: общих 7022, изменилось 0, новых 3 +17:49 → 17:54: общих 7025, изменилось 2, новых 36 +``` + +Что именно изменилось: + +``` +heart_rate 20:36 Avg 69 → 70.25, Max 69 → 71.51 +basal_energy_burned 20:32 qty 5.5704 → 7.1345 +``` + +Обе изменившиеся минуты — **хвост**, возраст 13 и 22 минуты на момент первой +отправки. Минутное ведро уезжает неполным, пока сэмплы не досинхронизировались +с часов, и в следующей доставке приезжает досчитанным. Дальше в прошлое +значения не меняются. + +Это **отменяет вывод 3 для агрегированного режима**: «повтор — no-op» верно +для посекундных сэмплов, но не для минутных агрегатов. + +## 11. Ключ `(метрика, дата, источник)` почти уникален, но не всегда + +Проверка внутри одной доставки, сколько ключей имеют два разных значения: + +``` +посекундная (330k точек): 2 +минутная (7k точек): 0 +``` + +Оба исключения — `sleep_analysis` от `AutoSleep`: эпизоды сна делят одну +`date` (начало сна), а различаются полями `start`/`end` и фазой. То есть +`date` там не идентифицирует запись. + +### Следствие: координаты против значений + +Ни чистый хеш содержимого, ни чистая перезапись по ключу не покрывают оба +режима: + +- **хеш содержимого** в минутном режиме накопит по нескольку версий одной + минуты (находка 10), и читателю нечем выбрать актуальную; +- **перезапись по `(метрика, дата, источник)`** в посекундном режиме потеряет + эпизоды сна (эта находка). + +Разделение полей точки на две группы закрывает оба случая: + +- **координаты** — `date`, `start`, `end`, `startDate`, `endDate`, `source`: + отвечают на вопрос «какая это запись»; +- **значения** — всё остальное (`qty`, `Min`/`Avg`/`Max`, `value`, фазы сна, + `heartbeatSeries`): отвечают на вопрос «что измерено». + +Ключ строки — метрика плюс координаты, запись — перезапись значений +(last-write-wins). Тогда эпизоды сна расходятся по `start`/`end`, досчитанная +минута перезаписывает неполную, а точный повтор не меняет ничего (сверяется по +хешу значений). + +Цена — минимальная интерпретация: список полей-координат фиксирован и не +зависит от метрики, незнакомое поле считается значением. Риск в том, что новое +координатное поле от Apple схлопнет две разные записи в одну; страхуемся тем, +что **перезапись с изменением хеша логируется** — молчаливой потери не будет, +а сырой архив хранит все версии. + +## 12. Автоматизация опознаётся по `automation-id`, но не по имени + +Из заголовков, которые шлёт Health Auto Export: + +``` +automation-id BC99C8A3-8BE7-4519-B545-F3ED6212008E стабилен во всех доставках +automation-name (пусто) автоматизация не названа +session-id уникален на каждую доставку +``` + +`automation-id` — стабильный UUID автоматизации, по нему доставки разных +автоматизаций различимы на одном эндпоинте. `automation-name` приходит пустым, +пока автоматизации не задано имя в приложении; для читаемости `/stats` имя +стоит проставить. + +`session-id` меняется каждую доставку — это идентификатор попытки отправки, а +не потока. Годится для сшивания частей, разбитых Batch Requests. + +## 13. Заголовок `automation-aggregation` не описывает реальную гранулярность + +Две доставки с **одинаковым** значением заголовка дали **разную** гранулярность: + +| автоматизация | заголовок | шаг меток `basal_energy_burned` | +|---|---|---| +| `BC99C8A3` (17:31) | `Default` | 00:00:00, 00:00:01, 00:00:02 — секунда | +| `37A43AE1` (18:00) | `Default` | 00:00:00, 00:01:00, 00:02:00 — минута | + +Правдоподобное объяснение: `Default` означает «в автоматизации явно не +задано», а действующая настройка живёт уровнем выше и была изменена между +доставками. Проверить это изнутри данных нельзя. + +**Следствие:** заголовок годится как метаданные доставки, но **не как +описание данных**. Гранулярность определяется по самим меткам времени, а не по +`aggregation`. Для самоописания (шаг 5) шаг метрики нужно выводить из данных — +как и всё остальное. + +## 14. Две автоматизации с одним набором метрик дают дубликат + +`37A43AE1` была заведена под тренировки, но приехала с теми же 26 показателями +здоровья, что и `BC99C8A3` (секция `workouts` в пакете отсутствует). Сравнение +по ключу `(метрика, дата, источник)`: + +``` +общих ключей 6490, значение совпало 6489, изменилось 1 +``` + +Единственное расхождение — хвостовая минута 20:44, то есть обычный досчёт из +находки 10, а не разница между автоматизациями. + +Пока обе автоматизации шлют одну гранулярность, витрина схлопнет дубликат сама +(тот же ключ, то же значение). Опасен другой случай: **одинаковые метрики с +разной гранулярностью** — тогда по одному ключу приезжают разные значения, и +перезапись начнёт их чередовать в зависимости от того, чья доставка пришла +последней. Правило: наборы метрик между автоматизациями не пересекать. + +## 15. Тренировка: разнородная структура, много избыточности + +Первая живая тренировка (ходьба в помещении, 91 секунда, 6,5 КБ на пакет). +Двадцать два поля верхнего уровня четырёх разных сортов: + +``` +id строка UUID из HealthKit — стабильный ключ +name, location строка "В помещении Ходьба", "В помещении" ← локализованы +start, end строка дата с офсетом, как у метрик +duration число 91.746 — СЕКУНДЫ (21:07:25 → 21:08:56) +isIndoor булево +metadata объект пустой +distance, totalEnergy, объект {qty, units} + activeEnergyBurned, + avgHeartRate, maxHeartRate, + intensity, speed, + temperature, humidity +heartRate объект {avg, max, min}, каждый — {qty, units} +activeEnergy массив 2 точки [date, qty, units, source] +basalEnergy массив 2 точки [date, qty, units, source] +walkingAndRunningDistance массив 2 точки [date, qty, units, source] +heartRateData массив 2 точки [date, Min, Avg, Max, units, source] +heartRateRecovery массив 13 точек [date, Min, Avg, Max, units, source] +``` + +Заметное: **сводки дублируют ряды**. `activeEnergyBurned` — это сумма +`activeEnergy`, `distance` — сумма `walkingAndRunningDistance`, а +`avgHeartRate`/`maxHeartRate` повторяют `heartRate.avg`/`heartRate.max`. + +`route` отсутствует — тренировка в помещении. Как выглядит маршрут, живьём +пока не видели. + +Точки внутренних рядов по форме совпадают с точками метрик, но **несут +`units` в каждой точке**, тогда как у метрик `units` живут на уровне метрики. + +**Вывод:** решение хранить тренировку одной строкой с полным JSON +подтверждается. Раскладывать такую структуру в таблицы означало бы принять +десяток решений о том, что здесь главное, — а это уже интерпретация. + +## 16. `stateOfMind` живёт по другим соглашениям + +```json +{ + "id": "C0E1AF76-1EA3-406B-9F90-BA537FBEB3AD", + "kind": "momentary_emotion", + "start": "2026-07-31T18:03:51Z", + "end": "2026-07-31T18:03:51Z", + "valence": 0.02508960573476693, + "valenceClassification": "neutral", + "labels": ["drained", "calm"], + "associations": ["hobbies"] +} +``` + +Отличий от метрик три, и все существенные: + +1. **Формат даты другой** — RFC 3339 в UTC с `Z`, а не `2026-07-31 21:03:51 +0300`. + То есть разбор дат зависит от секции пакета, одного парсера мало. +2. **Перечисления по-английски** и не локализованы: `kind` + (`momentary_emotion` / `daily_mood`), `valenceClassification` + (`neutral` / `slightly_pleasant`), `labels`, `associations`. В метриках и + тренировках те же по смыслу поля приходят на языке телефона (находка 8) — + единого правила у Health Auto Export нет. +3. **Поля `source` нет вовсе.** Набор полей-координат (находка 11) обязан + переживать его отсутствие. + +Есть свой `id` (UUID), как у тренировок, — годится как естественный ключ. + +## 17. Набор метрик не фиксирован + +В одной и той же автоматизации метрик стало **28 вместо 26**: как только +появились данные, добавились `mindful_minutes` и `walking_heart_rate_average`. + +Каталог метрик растёт по факту поступления данных. Это подтверждает выбор +обобщённой модели хранения и выводимых схем (шаг 5): фиксированный список +метрик в коде устарел бы в тот же день. + +## 18. Пустой пакет не отправляется + +Автоматизация без данных за период молчит совсем — доставки нет. Наблюдение: +автоматизация тренировок не слала ничего 15 минут, пока в Health не появилась +запись, тогда как автоматизация показателей за то же время доставила трижды. + +**Следствие для наблюдаемости:** молчание разреженного типа (тренировки, +осознанность, ЭКГ, цикл) — норма, а не сбой, и отличить его от сломавшейся +автоматизации нечем. Порог тревоги «данных нет N часов» имеет смысл только для +показателей здоровья: они идут всегда и годятся как пульс всей связки. По +остальным типам в `/stats` осмысленно показывать лишь «когда приходило в +последний раз», без тревоги. + +## 19. Выключение суммирования: что вернулось и что не изменилось + +Переключатель «Суммировать данные» выключён (группировка при этом в интерфейсе +исчезает). Из пяти проверок, заявленных заранее, подтвердились три. + +**Вернулось то, что съедала минутная группировка (находка 6):** + +``` +heart_rate_variability heartbeatSeries на месте: 60, 51 и 48 ударов +sleep_analysis снова эпизодами, с value/startDate/endDate +heart_rate поле context вернулось +``` + +**Не изменилось:** посекундная сетка у кумулятивных метрик. `basal_energy_burned` +по-прежнему выдаёт ровно **3600 точек в час**, непрерывно, всю ночь. + +**Заголовок `automation-aggregation` остался `Default`** и при суммировании, и +без него. То есть он не различает ни группировку, ни сам факт суммирования — +как источник сведений о данных бесполезен полностью. Находка 13 усилена: +режим определяется только по самим данным. + +## 20. Посекундная сетка — нарезка настоящих сэмплов, а не выдумка + +Ключевое наблюдение: в пакете 18 014 точек `basal_energy_burned`, но всего +**2057 различных значений**, и подряд идущие совпадают до шестнадцатого знака: + +``` +21:54:04 qty=0.14020320410520137 +21:54:05 qty=0.14020320410520137 +21:54:06 qty=0.14020320410520137 +21:54:08 qty=0.14020320410520135 ← дрожь последнего разряда от деления +``` + +Распределение длин серий одинаковых значений: + +``` +длина 1: 3709 серий длина 4: 523 +длина 2: 1786 длина 5: 241 +длина 3: 1026 длина 6+: 361 +``` + +Итого около **7600 серий** — то есть под посекундной сеткой лежат настоящие +сэмплы длительностью 1–12 секунд, каждый растянут на свою длину. Инфляция +примерно **2,4×**, и вместе с ней теряются границы сэмплов: у кумулятивных +метрик `start`/`end` не приходят вовсе. + +Суммы при этом корректны — нарезка сохраняет итог: + +``` +2026-08-01 00:00 334 кДж = 80 ккал точек 3600 +2026-08-01 01:00 340 кДж = 81 ккал точек 3600 +… +``` + +63–82 ккал в час, около 1900 ккал в сутки базального обмена — сходится с +измеренным в суммированном режиме (находка 5). + +**Вывод:** «настоящих» сэмплов от Health Auto Export получить нельзя ни в +одном режиме. Выбор такой: посекундная сетка с деталями (эпизоды сна, HRV) и +инфляцией 2,4×, либо минутная группировка без деталей. Первое дороже примерно +в 20 раз, но при хранении часовыми сжатыми объектами это всё равно копейки. + +## 21. Дискретные и кумулятивные метрики ведут себя по-разному + +Поля `start`/`end` приходят только у части метрик: + +``` +heart_rate, physical_effort, environmental_audio_exposure, +blood_oxygen_saturation, sleep_analysis, heart_rate_variability, +apple_stand_hour ← интервал есть + +basal_energy_burned, active_energy, step_count, +walking_running_distance, apple_stand_time … ← только date +``` + +Деление проходит по границе «дискретное измерение» против «накопительная +величина». Накопительные режутся на секунды и теряют интервал (находка 20), +дискретные сохраняют свой. + +Практически: у 24 261 точки из 24 368 в пакете вообще нет `start`/`end`. +Поэтому вопрос «в какой час класть сэмпл, пересекающий границу» касается лишь +сотни точек в пакете — и решается простым правилом «по `date`». + +## 22. Маршрут тренировки + +Уличная ходьба, 594 секунды, **593 точки маршрута** — одна в секунду. Точка +несёт десять полей, а не пять, как обещала документация: + +```json +{"latitude":44.778909627459036,"longitude":37.70132686458095, + "altitude":70.61593273964799,"speed":1.181851863861084, + "course":-1,"timestamp":"2026-08-01 10:04:31 +0300", + "horizontalAccuracy":10.22251601695661,"verticalAccuracy":9.629558563232422, + "speedAccuracy":1.0428272485733032,"courseAccuracy":-1} +``` + +**Маршрут — 95% веса тренировки**: 190 КБ из 199,6 КБ. Десятиминутная прогулка +даёт 200 КБ, часовая пробежка дала бы порядка 1,2 МБ. + +**Набор полей тренировки зависит от её типа:** + +``` +только у уличной: route, avgSpeed, maxSpeed, elevationDown, flightsClimbed +только у домашней: temperature, humidity, intensity +``` + +Фиксированной схемы тренировки не существует — ещё один довод за хранение +блобом и выводимые схемы. + +## 23. Объём в несуммированном режиме + +``` +basal_energy_burned 3600 точек в час = 86 400 в сутки +всего ~135 000 точек в сутки +``` + +Тот же порядок, что и у посекундного суммированного режима (находка 7). +Часовая грань объектов остаётся уместной: 3600 точек в объекте — это около +320 КБ JSON, порядка 13 КБ в сжатом виде. + +Отдельно: автоматизация тренировок работает с периодом `Default` и потому +**переприсылает те же тренировки каждые 5 минут** — 317 КБ на доставку, из +которых 95% маршрут. Хеш по `id` тренировки сделает это бесплатным для +хранилища, но не для архива. Ей тоже нужен `Since Last Sync`. + +## 24. В именах устройств — неразрывные пробелы + +Источник приходит не тем, чем выглядит: + +``` +"Apple Watch Ultra 3" +``` + +Между «Apple», «Watch» и «Ultra» стоят **U+00A0**, а не обычные пробелы (между +«Ultra» и «3» — обычный). Обнаружено случайно: фильтр +`source == "Apple Watch Ultra 3"`, набранный руками, молча не находил ничего, +хотя группировка по тому же полю работала. + +Источник — сама Apple, не HAE: в родном экспорте `sourceName` содержит те же +неразрывные пробелы (находка 34). То есть обойти это выбором источника нельзя. + +Это ловушка на будущее: любой клиент, отбирающий данные по имени устройства, +напишет обычный пробел и получит пустой ответ без всякой ошибки. То же +касается составных значений: `"Apple Watch Ultra 3|iPhone (Anton)"`. + +**Следствие:** в самоописании (шаг 5) значения-примеры нужно отдавать так, +чтобы невидимые символы были заметны, а в read API фильтр по источнику — либо +не делать, либо нормализовать пробелы на входе и хранить оба варианта. +Значение при этом храним дословно, как и всё остальное. + +## 25. Разбор ночи: наша сторона чистая, вопросы к устройствам + +Проверка ночи 31 июля → 1 августа. Из 118 экземпляров `sleep_analysis` во всех +доставках после канонизации осталось **43 различных записи** — дедупликация +по содержимому отработала, повторов не осталось. + +**Ряд Apple Watch безупречен:** + +``` +35 эпизодов, разрывов 0, пересечений 0 +окно 02:02:16 → 07:57:39 +сумма эпизодов 5.92 ч = длительность окна 5.92 ч +``` + +Конец каждого эпизода совпадает с началом следующего, сумма фаз сходится с +окном до сотых. Это сильное свидетельство, что в приёме и дедупликации ничего +не потерялось: дыра или задвоение сломали бы равенство. + +**Фазы:** Основная 3,68 ч, БДГ 0,56 ч, Бодрствование 1,68 ч. + +**Вопросы — не к приёму:** + +1. **Часы не покрывают первые четыре часа сна.** AutoSleep фиксирует + укладывание в 22:04, владелец сообщает, что уснул около 22:30 и часы были + на руке всю ночь, — а записи часов начинаются только с 02:02. + Это **аномалия**, а не норма: см. находку 26. +2. **Фазы «Глубокий» этой ночью нет вовсе** — прямое следствие пункта 1: + глубокий сон приходится на первые циклы, то есть на пропущенный отрезок. +3. **AutoSleep переписывает ночь более длинной записью.** «В кровати» + 22:04–02:21 и «В кровати» 22:04–07:51 — первая вложена во вторую, и обе + лежат в хранилище. + +Третий пункт важен для контракта: **наивная сумма даёт 14,07 ч в кровати за +ночь длиной 9,8 ч**. Хранить обе записи правильно (мы не знаем, какая +«настоящая», и терять нельзя), но клиент обязан схлопывать вложенные +интервалы сам. Это стоит сказать в самоописании. + +Заодно это первый наблюдавшийся случай, когда **несуммированные данные +переписываются задним числом** — не изменением значения по ключу, а выпуском +более длинной записи с тем же началом. Идентичность по содержимому его не +схлопнет, и это правильно. + +## 26. «Since Last Sync» отслеживает время записи, а не дату сэмпла + +Проверка возникла из аномалии находки 25. Сравнение трёх ночей по данным часов: + +``` +ночь первая запись последняя эпизодов фазы +2026-07-29 22:21 07:43 57 БДГ, Бодрствование, Глубокий, Основная +2026-07-30 22:34 05:22 40 БДГ, Бодрствование, Глубокий, Основная +2026-07-31 02:02 07:57 35 БДГ, Бодрствование, Основная +``` + +В обе предыдущие ночи часы начинали писать ровно в момент засыпания. Значит +пробел третьей ночи — не обычное поведение устройства. + +Отсюда вопрос: не потеряли ли данные **мы**? Если бы «Since Last Sync» +отбирал сэмплы по их собственной дате, то запись, которую часы дописали в +Health утром задним числом, уже не попала бы в выгрузку — метка синхронизации +ушла вперёд. Это и есть тот сценарий дыры, ради которого задумывались широкие +проходы. + +Данные говорят, что нет. Записей, чья дата **старше дня доставки**, набралось +78, и часть приехала именно в доставках с периодом «Since Last Sync»: + +``` +сэмпл 2026-07-31 22:04 (В кровати, AutoSleep) → доставлен 2026-08-01T05:09:07Z +``` + +К моменту этой доставки метка синхронизации давно прошла 22:04 предыдущего +дня — и запись всё равно приехала. Значит **отбор идёт по времени добавления +записи в Health, а не по дате самого сэмпла**, и дописанные задним числом +данные выгружаются штатно. + +**Следствия:** + +- Риск дыр меньше, чем закладывалось: многоуровневая синхронизация остаётся + нужной на случай простоя сервиса, но не для ловли поздних дописок. +- Задержка данных часов велика: эпизоды 02:02–03:33 приехали в 09:18 по + местному времени, то есть через семь часов. Порог тревоги по свежести это + обязан учитывать. + +> **ОПРОВЕРГНУТО находкой 29.** Вывод «пропавшие 22:30–02:02 отсутствуют в +> Health» оказался неверным: широкая выгрузка их привезла. Наблюдение про +> доставку старых записей верное, но выводить из него сохранность нельзя — +> механизм отбора сложнее, чем «по времени добавления». + +## 27. Смена режима автоматизации стоила 30 минут данных + +Покрытие `basal_energy_burned` по минутам за всё время (метрика идёт +непрерывно, поэтому годится как индикатор работы канала): + +``` +276 798 точек, 3432 минуты с данными, от 2026-07-30 00:04 до 2026-08-01 10:43 +разрывов: 4 + 2026-07-31 21:23 → 21:54 нет 30 мин ← смена режима автоматизации + 2026-08-01 07:51 → 08:19 нет 27 мин + 2026-08-01 02:54 → 03:12 нет 17 мин + 2026-08-01 09:03 → 09:18 нет 14 мин +``` + +Первый разрыв приходится ровно на паузу между доставками: последняя +суммированная пришла в 18:27Z (21:27 местного), первая несуммированная — в +23:58Z. Пока автоматизацию перенастраивали, метка синхронизации ушла вперёд, и +полчаса данных не выгрузились **никогда**. + +Это первая наблюдённая настоящая потеря, и она подтверждает необходимость +широких проходов: средний проход (час, за сутки) закрыл бы её сам в течение +часа. Их отсутствие и стало причиной — на момент сбоя был настроен только +быстрый проход. + +**Но пробел в фазах сна (22:30–02:02) этим не объясняется.** В том же окне +`basal_energy_burned` идёт непрерывно, ровно по 3600 точек в час: + +``` +2026-07-31 22:00 3600 точек +2026-07-31 23:00 3600 точек +2026-08-01 00:00 3600 точек +2026-08-01 01:00 3600 точек +``` + +Канал работал, данные за это время доехали. Значит фазы сна за ранний период +просто не появились в Health — вывод находки 26 остаётся в силе. + +## 28. Расписание автоматизаций — пожелание, а не гарантия + +Из документации Health Auto Export +([Automations](https://help.healthyapps.dev/en/health-auto-export/automations/), +[Shortcuts](https://help.healthyapps.dev/en/health-auto-export/automations/schedule-automations-using-shortcuts/)): + +- **Приложение можно не держать открытым**, но в фоне автоматизации зависят от + Background App Refresh, и iOS решает сама: «iOS also does not allow apps to + run in the background at a specified time… automations are not guaranteed to + run precisely at the specified time». +- **Пока приложение на переднем плане**, автоматизации перезапускаются примерно + раз в 60 секунд — отсюда ровный пятиминутный ритм в наших доставках. +- **Заблокированный телефон = экспорта нет вообще:** «Apps are not allowed to + access health data while iPhone is locked». Ночью автоматизации не работают + в принципе, и данные за ночь приезжают утром — что мы и наблюдали + (эпизоды сна 02:02–03:33 доставлены в 09:18, находка 26). +- На зарядке ограничения слабее. +- Триггер через Shortcuts («Run Automation») предсказуемее фонового + расписания, но **телефон всё равно должен быть разблокирован**. + +**Следствие для наблюдаемости:** ровного ритма доставок не бывает. Поток +пачечный: тишина ночью, всплеск утром. Порог тревоги по молчанию должен быть +не меньше суток, а осмысленный показатель свежести — возраст самой свежей +точки, а не время последней доставки. + +**Следствие для стратегии:** нельзя строить сохранность на том, что доставка +случится вовремя. Либо период выборки перекрывает любой разумный простой +(широкие окна идемпотентны по построению), либо метка «Since Last Sync» +обязана переживать неудачные попытки — а это **не проверено**. + +Что известно про непрерывность «Since Last Sync» из наших данных: окна +последовательных доставок стыкуются без разрывов — + +``` +доставлено окно данных +2026-07-31T23:58:11Z 21:44:44 → 02:56:25 +2026-08-01T00:09:01Z 22:04:00 → 03:04:20 стык ок +2026-08-01T05:09:07Z 22:04:00 → 08:05:19 стык ок +2026-08-01T06:18:17Z 22:04:00 → 09:12:56 стык ок +``` + +Но все эти доставки **успешные**. Поведение при неудачной отправке (сервис +недоступен) не наблюдалось ни разу — см. «Открытые вопросы». + +## 29. «Since Last Sync» теряет данные — подтверждено экспериментом + +Период основной автоматизации переключили на широкий (`Default`, окно +2026-07-30 21:48 → 2026-08-01 11:14, 42 МБ, 251 976 точек). Сравнение с тем, +что за то же время отдал инкрементальный режим. + +**Окно, которое «Since Last Sync» уже покрывал** (2026-07-31 21:44 → +2026-08-01 11:09), сравнение по ключу «метрика + дата + источник»: + +``` +через Since Last Sync: 72 428 точек +в широкой выгрузке: 85 692 +ключей только у широкой: 13 961 ← потеряно инкрементальным режимом +ключей только у SLS: 696 +``` + +**Фазы сна за спорную ночь:** + +``` +через Since Last Sync: 35 эпизодов, с 02:02, фазы: БДГ, Бодрствование, Основная +в широкой выгрузке: 59 эпизодов, с 22:43, фазы: БДГ, Бодрствование, + Глубокий, Основная +``` + +Пропавшие фазы **были в Health** и приехали, как только окно выборки перестало +зависеть от метки синхронизации. Гипотеза владельца подтвердилась, вывод +находки 26 отменён. + +После широкой выгрузки покрытие `basal_energy_burned` стало непрерывным по +минутам за всё время наблюдения — **разрывов не осталось вовсе**, включая +получасовую дыру находки 27. + +**Вывод, меняющий стратегию:** инкрементальный период нельзя использовать как +единственный источник. Он годится для свежести, но сохранность обязаны +обеспечивать широкие проходы с фиксированным окном. + +## 30. Числа сериализуются нестабильно между выгрузками + +При сравнении тех же точек всплыло неожиданное: из 71 730 общих ключей +**45 507 различались значением** — но вот как: + +``` +basal_energy_burned 22:24:51 0.09523182962471353 против 0.09523182962471352 +active_energy 22:06:32 0.0074754192155406605 против 0.00747541921554066 +``` + +Расхождение в последнем разряде — шум представления double, а не разные +данные. Хеш по канонической форме этого не переживает: **63% повторно +доставленных точек считались бы новыми**, и каждый широкий проход дублировал +бы витрину. + +Лечится округлением перед хешированием: + +``` +нормализация совпало разошлось +без округления 26 223 45 507 +%.16g 33 799 37 931 +%.15g 61 347 10 383 +%.14g 69 738 1 992 +%.12g 70 184 1 546 +``` + +Ложных схлопываний округление не даёт: на 251 976 точках одной выгрузки +`%.12g` не склеил ни одной пары различных точек. + +**Следствие для схемы:** хеш считается по канонической форме **с округлением +чисел** (порядка 12–14 значащих цифр); значение при этом хранится дословно, +как пришло. Округление — только для идентичности, не для данных. + +Оставшиеся ~1 500 расхождений (2%) — настоящие: **нарезка на секунды не +детерминирована между выгрузками**, границы и доли слегка разъезжаются. Это +значит, что широкие проходы всё равно будут добавлять небольшой процент +дубликатов при идентичности по содержимому. Ключ по координатам +(`метрика + date + source`) с перезаписью значений эту проблему снимает и +заодно сохраняет эпизоды сна (они различаются `start`/`end`) — стоит +вернуться к развилке находки 11 при реализации шага 3. + +## 31. Вся мета-информация о выгрузке — в заголовках, и она полуправдива + +**В теле метаданных нет.** Верхний уровень всегда ровно один ключ `data`, +внутри — только секции. Ни версии формата, ни окна выборки, ни настроек, ни +времени экспорта. + +**В заголовках — пять полей**, из которых опираться в коде можно на два: + +| заголовок | пригодность | +|---|---| +| `automation-id` | **надёжен** — стабильный UUID автоматизации | +| `session-id` | **надёжен** — уникален на доставку | +| `automation-name` | пустой, пока имя не задано в приложении | +| `automation-aggregation` | `Minutes` \| `Default` — см. ниже | +| `automation-period` | `Today` \| `Default` \| `Since Last Sync` — см. ниже | + +Сопоставление заголовков с фактическим поведением по всем крупным доставкам: + +``` +aggregation period шаг меток окно данных +Minutes Default минута надёжно +Default Today секунда 2026-07-30 21:48 → 07-31 20:20 (двое суток!) +Default Default секунда 2026-07-29 22:11 → 07-31 20:20 +Default Default минута ← и так тоже бывает +Default Since Last Sync секунда 2026-07-31 22:04 → 08-01 10:45 +``` + +- **`aggregation`**: значение `Minutes` действительно означает минутную + группировку. Значение `Default` не означает ничего — под ним прошли + посекундная сетка при включённом суммировании, минутная у второй + автоматизации и несуммированный режим. Одно значение, три поведения. +- **`period`**: называет настройку, но не описывает охват. `Today` дал окно + шире суток, потому что в него попали записи сна, начавшиеся накануне + вечером: окно определяется датами сэмплов, а не календарём. + +**Вывод:** режим и охват определяются **по самим данным** — шаг меток и +min/max даты считаются за один проход при разборе. Заголовки годятся как +подсказка человеку и как ключ источника, не более. + +Поэтому с этого момента **сохраняем весь набор заголовков** в +`delivery.headers` (JSON, без секретов): что HAE шлёт помимо +задокументированных пяти, мы не знали, а именно из незадокументированного +вышли поле `source` (находка 1) и неразрывные пробелы (находка 24). + +## 32. Полный набор заголовков: три полезных поля сверх документации + +После включения записи всех заголовков (15 доставок): + +``` +Accept */* +Accept-Encoding gzip, deflate +Accept-Language ru +Automation-Aggregation Default +Automation-Id BC99C8A3-8BE7-4519-B545-F3ED6212008E +Automation-Name (пусто) +Automation-Period Today +Connection keep-alive +Content-Length 12882621 +Content-Type application/json +Host 192.168.2.60:8080 +Session-Id 77BEBA08-E372-42EC-B80F-101863AB4BB1 +Upload-Complete ?1 +Upload-Draft-Interop-Version 6 +User-Agent Auto%20Export/20260729.1 CFNetwork/3860.700.1 Darwin/25.6.0 +``` + +Задокументированных полей пять, реально приходит пятнадцать. Три из +незадокументированных имеют смысл для проекта. + +### `User-Agent` несёт версию приложения + +`Auto%20Export/20260729.1` — датированный номер сборки, плюс версия системы +(`Darwin/25.6.0`). Это закрывает вопрос, который висел с самого начала: формат +данных может измениться только с обновлением Health Auto Export, и теперь +**обновление видно в каждой доставке**. Значит выведенные схемы (шаг 5) можно +сверять по версии: изменилась версия — стоит перепроверить формы точек. + +Версию стоит сохранять отдельной колонкой рядом с доставкой, а не только внутри +`headers`, — по ней захочется группировать. + +### `Accept-Language` объясняет локализацию + +`ru` — язык телефона приезжает в каждом запросе. Это делает решаемой ловушку +находки 8: строки `value` («Во сне»), `context` («Не задано») и `name` +тренировки («В помещении Ходьба») приходят на языке телефона, и теперь мы +знаем, на каком именно. Язык можно хранить рядом с данными и помечать им +локализованные поля в самоописании — вместо того чтобы клиенту гадать. + +### `Upload-Complete` — CFNetwork умеет докачиваемую загрузку + +`Upload-Complete: ?1` и `Upload-Draft-Interop-Version: 6` — это черновик IETF +Resumable Uploads, который реализует CFNetwork на стороне iOS. Пока во всех +доставках `?1`, то есть тело приезжает целиком. + +**Риск на будущее:** тела уже достигают 42 МБ, а по мобильной сети клиент +может захотеть слать их по частям. Частичная загрузка приедет с +`Upload-Complete: ?0`, и наш сервис сочтёт обрезанное тело битым JSON и +ответит 400. Пока мы не отвечаем `104 Upload Resumption Supported`, клиент +переходить на докачку не должен — но это стоит помнить и **не включать +поддержку случайно**. Если `?0` когда-нибудь придёт, правильнее ответить +явной ошибкой, а не молча трактовать тело как испорченное. + +## 33. Слой надо выводить как режим доставки, а не как частоту метрики + +Первая версия правила определяла слой **по каждой метрике отдельно**, по +выравниванию её меток. На живых данных она сломалась, и сломалась именно на +редких метриках. + +**Что пошло не так.** Частота метрик различается на порядки: `heart_rate` идёт +сотнями точек в час, `vo2_max` и `six_minute_walking_test_distance` — по одной +точке за всё время наблюдения. Для редкой метрики выравнивание не значит +ничего: единственная несуммированная точка попадает на ровную минуту примерно +в 1,7% случаев, а на ровный час — реже, но регулярно, потому что многие такие +метрики Apple и записывает на границе часа. + +Результат классификации «по метрике» на наших данных: + +``` +apple_sleeping_wrist_temperature hour (12 точек), raw (2) ← одно измерение за ночь +apple_stand_hour hour (284), raw (5) ← почасовая по природе +walking_heart_rate_average hour, minute, raw ← одна точка в сутки +vo2_max minute, raw +six_minute_walking_test_distance minute, raw +``` + +Одна и та же редкая метрика растащена по трём слоям — при том, что режим +выгрузки был один. Клиент увидел бы `vo2_max` в двух слоях по одной точке и не +понял бы, что это одно и то же измерение. + +**Дополнительно выяснилось, что и «по доставке целиком» неверно** в лоб: одна +доставка законно содержит метрики разной подробности — `apple_stand_hour` +почасовой по природе, `sleep_analysis` в минутном режиме становится суточным +агрегатом на `00:00:00`, а `heart_rate` рядом идёт секундами. Правило «самый +мелкий слой побеждает» переворачивалось от одной метрики. + +### Рабочее правило + +Слой — это **режим выгрузки**, общий для доставки; редкая метрика его +наследует, а не голосует. + +1. Голосуют только **плотные** метрики доставки — не меньше 10 точек. У десяти + несуммированных точек шанс всем лечь на ровную минуту исчезающе мал. +2. Режим — самый мелкий слой среди проголосовавших. +3. Голосовать некому — режим **наследуется** от предыдущей доставки той же + автоматизации; если её не было, берётся заголовок. +4. Все метрики доставки, включая редкие, кладутся в слой этого режима. + +### Проверка на всей истории + +39 доставок с метриками, три автоматизации, четыре смены настроек: + +``` +расхождений с надёжным заголовком (Minutes): 0 +смены режима обнаружены: 3 (ровно там, где настройки меняли) +наследование сработало: 2 (доставки без плотных метрик) +редких метрик в доставке: 0–12, ни одна не повлияла +``` + +Правило воспроизводит фактические настройки автоматизаций без единой ошибки. + +## 34. Родной экспорт Apple Health — другой источник, и он точнее HAE + +Сравнение выгрузки Health Auto Export с ручным экспортом из приложения Health +за те же трое суток (`apple_health_export/экспорт.xml`, HealthKit Export +Version 14). + +### Объём: меньше в девяносто раз + +``` +Health Auto Export, несуммированный: ~135 000 точек в сутки +Родной экспорт Apple: ~1 500 записей в сутки +``` + +За окно наблюдения (30 июля – 1 августа) в родном экспорте **4 629 записей** +всех типов. У Health Auto Export за то же окно — сотни тысяч точек. + +### Причина: HAE режет сэмплы по секундам + +У каждой записи Apple есть `startDate` и `endDate` — настоящий интервал +измерения: + +``` +BasalEnergyBurned: 564 записи, интервал: медиана 10с, макс 21990с + суммарно покрыто 74.9 ч + → нарезка по секундам дала бы 269 693 точки +``` + +А у нас от HAE за это окно ровно столько и лежит — 3600 точек в час. Совпадение +до цифры: **инфляция 478×** для базального обмена, около 90× по всему потоку. + +Это окончательно закрывает находки 5 и 20: «несуммированный» режим HAE — не +сырые данные, а посекундная развёртка настоящих сэмплов. Причём развёртка +**теряет информацию**: границы интервала (`startDate`/`endDate`) в неё не +попадают. + +Отсюда неожиданный вывод: **самое точное представление данных одновременно и +самое компактное**. Посекундный режим HAE — худший из вариантов: в 90 раз +больше объёма, чем у правды, и меньше сведений. + +### Что ещё есть в родном экспорте и нет у HAE + +- `startDate` / `endDate` / `creationDate` — интервал измерения и момент + записи в Health отдельно; +- `device` — полное описание устройства (модель, версия прошивки, серийный + идентификатор объекта), а не только имя; +- `sourceVersion` — версия приложения-источника; +- фазы сна **английскими идентификаторами**: `HKCategoryValueSleepAnalysisAsleepCore`, + `AsleepDeep`, `AsleepREM`, `Awake`. Локализацию («Основная», «Во сне») + делает именно HAE — в источнике значения независимы от языка телефона. + Это снимает ловушку находки 8 для импортированных данных; +- 236 GPX-треков тренировок и 11 CSV с ЭКГ отдельными файлами. + +### Перекрёстная проверка сна + +Спорная ночь в родном экспорте: + +``` +часы: с 22:43 до 07:57, 59 эпизодов +фазы: AsleepCore 25, Awake 21, AsleepREM 10, AsleepDeep 3 +первый: 22:43–23:02 AsleepCore, глубокий сон в 23:32 +``` + +Ровно то же, что привезла широкая выгрузка HAE (находка 29): 59 эпизодов, +начало в 22:43. Два независимых источника сошлись — данные достоверны, а +инкрементальный режим действительно их терял. + +### Цена + +Формат совершенно другой: XML на **1,69 ГБ** (плюс дублирующий +`export_cda.xml` на 1,1 ГБ), архив целиком 104 МБ. Имя файла **локализовано** — +`экспорт.xml`, а не `export.xml`: захардкодить нельзя. + +Значит `healthlog import` из шага 6 — это не «те же JSON, только из файла», а +отдельный парсер XML со своей моделью записи. Зато он даёт слой, которого HAE +не отдаёт ни в каком режиме. + +### Следствие для стратегии + +Напрашивается другая раскладка источников: + +| источник | что даёт | объём | как часто | +|---|---|---|---| +| HAE, минутная группировка | свежесть, непрерывный поток | 3,3 тыс. точек/сутки | каждые 5 минут | +| Родной экспорт Apple | настоящие сэмплы с интервалами | 1,5 тыс. записей/сутки | вручную, раз в N недель | + +Посекундный режим HAE в этой раскладке не нужен вовсе: он дороже обоих и +точнее ни одного. + +## 35. Три разреза сходятся — и это проверка правила вывода слоя + +Появилась третья автоматизация (`8364E2C6`, заголовок `Hours`), метрики стали +приходить в трёх разрезах одновременно. Сверка сумм между ними и с родным +экспортом Apple как эталоном: + +``` +час sample (Apple) minute (HAE) hour (HAE) +2026-08-01 00 79.74 79.73 79.73 +2026-08-01 01 81.24 81.24 81.24 +2026-08-01 03 79.53 79.54 79.54 +active_energy 09 22.47 22.47 22.47 +``` + +Три независимых представления совпадают до сотых. Минутный и часовой разрезы +HAE достоверны, эталон подтверждает. + +### По дороге нашлись две ошибки — обе в измерении, не в данных + +**Первая: наивный подсчёт эталона.** Я приписывал каждую запись Apple часу её +начала — а базальный обмен приходит записями с интервалом до шести часов +(находка 34). Суммы скакали от 54 до 580 ккал в час. Лечится раскладкой +значения по часам пропорционально перекрытию интервала. + +**Вторая, важнее: правило вывода слоя мис-филировало транзитные доставки.** +Правило находки 33 назначало слой доставке целиком по самой мелкой из плотных +метрик. В доставке `494A0C76` от 08:55:57 `heart_rate` был ещё несуммированным, +а остальные 29 метрик — уже минутными. Вся доставка ушла в слой `raw`, и +минутные точки базального обмена сложились с посекундными: сумма ровно +удвоилась. + +**Исправленное правило** (проверено — суммы сошлись): + +- метрика с ≥ 10 точками классифицируется **сама по себе**; +- метрика с < 10 точками наследует **преобладающий слой доставки** (самый + мелкий среди плотных). + +Так и смешанная доставка раскладывается верно, и редкая метрика не дробится по +слоям — оба требования выполняются одновременно. + +## 36. Поле `source` нестабильно — и это ломает идентичность по содержимому + +Самая дорогая находка проверки. Одно и то же измерение — та же метрика, та же +минута, **то же значение** — приезжает с разными строками источника: + +``` +minute 2026-07-31 03:15, basal_energy_burned, qty=5.671724507333192 + +доставки 31 июля: source = "Apple Watch Ultra 3|iPad (Anton)" +доставки 1 августа: source = "Apple Watch Ultra 3" +``` + +Значение совпадает до последнего разряда, метка та же, а `source` изменился: +iPad перестал числиться среди вкладчиков. Судя по всему, Health переосмыслил +атрибуцию источников задним числом. + +**Последствие:** идентичность по хешу содержимого сохраняет обе записи, и сумма +за час удваивается. Это не редкий случай — на 31 июля минутный слой содержал +**120 точек в час вместо 60**, то есть задвоено всё. + +### Следствие для модели + +Ключ обязан состоять из **устойчивых координат**, а `source` к ним не +относится: + +``` +ключ: метрика + слой + метка времени +значения: qty / Min / Avg / Max / source / … ← перезаписываются +``` + +Это окончательно решает развилку находок 11 и 30 в пользу координат: +хеш содержимого хорош тем, что ничего не теряет, но он не переживает ни +нестабильной сериализации чисел (находка 30), ни нестабильной атрибуции +источника (эта находка). Хеш при этом остаётся полезен — как быстрая проверка +«изменилось ли что-нибудь», чтобы не писать зря. + +**Остаточный случай:** в посекундном слое HAE 65 меток из 3600 несут по два +разных значения при одном источнике — там перезапись потеряет одно из двух. В +минутном и часовом слоях такого нет вовсе: ровно одна точка на метку. Ещё один +довод отказаться от посекундного слоя HAE в пользу `sample` из родного +экспорта. + +## Инструмент + +Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная +библиотека, каталог под `.gitignore`): + +``` +python3 tmp/research/hl.py deliveries что приехало +python3 tmp/research/hl.py metrics --period 'Since Last Sync' +python3 tmp/research/hl.py shapes формы точки +python3 tmp/research/hl.py sources источники, с показом невидимых символов +python3 tmp/research/hl.py points step_count точки, инфляция серий +python3 tmp/research/hl.py sleep разбор ночи +python3 tmp/research/hl.py diff что изменилось между доставками +python3 tmp/research/hl.py workouts тренировки, ряды, маршрут +``` + +Он канонизирует JSON перед сравнением и показывает невидимые символы — те две +грабли, на которых разбор оболочкой ломался молча. + +## Открытые вопросы + +- **Переживает ли «Since Last Sync» неудачную отправку.** Ключевой вопрос для + стратегии (находка 28). Проверяется экспериментом: остановить сервис на + полчаса при открытом приложении, поднять и посмотреть, приедет ли + пропущенное окно. Если метка двигается независимо от исхода — на + инкрементальный период полагаться нельзя вообще. +- **Дальность досчёта.** Наблюдались правки хвоста возрастом до 22 минут. + Меняется ли что-то на глубине часов и суток — покажет более длинный ряд + доставок. +- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`, + `heartRateNotifications`, `cycleTracking`, `medications`. diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..2a9953a --- /dev/null +++ b/docs/plan.md @@ -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. +- **Слой агрегаций** поверх сырья (суточные/недельные срезы). +- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится + клиент, которому мало отдачи тренировки одним пакетом. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9fe6f21 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module git.vakhrushev.me/av/healthlog + +go 1.26.5 + +require ( + github.com/go-chi/chi/v5 v5.3.1 + github.com/jmoiron/sqlx v1.4.0 + github.com/oklog/ulid/v2 v2.1.2 + github.com/pelletier/go-toml/v2 v2.4.3 + github.com/pressly/goose/v3 v3.27.3 + modernc.org/sqlite v1.55.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-retry v0.4.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..edbeeee --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= +github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= +modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=