Дашборд здоровья, часть 2: Выход за рамки Health Auto Export
Часть 2 моего журнала разработки Дашборда здоровья: почему универсальный экспортёр Apple Health перестал быть достаточным сам по себе, сколько стоило добавление нативного клиента для iOS в виде регрессий, и как `sleep_core` оказался лжецом на протяжении нескольких лет. Health Auto Export всё ещё работает параллельно с нативным клиентом. Речь идёт о выходе за его рамки, а не о полном отказе.
В части 1 сервер научился принимать наблюдения, не теряя их, и пересобирать каждую производную таблицу из сырых данных. Этого хватило, чтобы сделать дашборд полезным. Но не хватило, чтобы сделать данные честными.
Эта часть — о моменте, когда стороннего экспортёра перестало хватать как самостоятельного ingestion path, и о двух регрессиях, которые появились после того, как я добавил рядом с ним собственный iOS-клиент. Health Auto Export всё ещё отправляет данные на сервер. Endpoint /health принимает оба источника, а кодовый путь, который парсит HAE payload-ы, по-прежнему поддерживается. «Выход за пределы» в заголовке — про ограничения HAE, а не про то, что я его выкинул.
Почему Health Auto Export был правильной отправной точкой
Health Auto Export дал мне рабочий канал приёма данных буквально за вечер. JSON payload-ы по HTTP, настраиваемое расписание и список имён метрик, которые уже совпадали с тем, что сервер хотел хранить. Никакого Xcode, никаких entitlements, никаких provisioning profiles. Формат payload-а, который он выдавал, — тот же, с которым сервер работает и сегодня:
{"data": {"metrics": [{"name": "...", "units": "...", "data": [...]}]}}
Для первой фазы проекта — «покажи мне число утром» — это был правильный компромисс. Я хотел понять, во что должен превратиться сервер, а не писать iOS-приложение для системы, у которой ещё не было понятной цели.
Где он перестал подходить
Экспортёр по своей природе универсален. Каждая новая сущность, которую сервер начинал отслеживать, превращала эту универсальность в проблему.
- Идентичность источника. Серверу нужно было понимать, пришла ли строка сна с Apple Watch — с разбивкой по стадиям — или с RingConn — грубой ночной сводкой. Health Auto Export передавал строку с именем источника, но отправлял оба набора данных, если одна и та же ночь была покрыта дважды, и не мог подавить один в пользу другого.
- Время синхронизации. Утренний отчёт запускается только тогда, когда данные сна за прошлую ночь «устоялись»: последний сегмент закончился ≥45 минут назад, и за последние 20 минут не появлялись новые фрагменты. С универсальным экспортёром, который живёт по собственному расписанию, сервер не мог понять, означает ли тишина «данных ещё нет» или «Apple Watch не подключены».
- Неполные дни. Сбой синхронизации посреди дня заставлял сервер рассуждать о дате, которая была наполовину реальной, а наполовину пустой. Без
HKQueryAnchor1 на стороне клиента восстановление означало ручной повторный экспорт диапазона дат из приложения. - Тренировки. Изначально они не поддерживались ни одним из путей. У сервера не было endpoint-а для тренировок, а стандартная схема HAE их не отправляла. Эта проблема решилась иначе, чем остальные, — ниже об этом отдельно.
- Стадии сна. Экспортёр пропускал всё, что возвращал HealthKit, включая грубый слой
.asleepUnspecified, который Apple Watch на iOS 26 отдаёт параллельно с детальными стадиями. У сервера не было способа понять, что одно дублирует другое.
Именно последний пункт позже стал самым дорогим. Подробности — ниже.
Добавление нативного клиента рядом с HAE
Следующим шагом стало нативное iOS-приложение health-sync: Swift 6, iOS 26+, без сторонних зависимостей. Это второй producer, который пишет в тот же endpoint POST /health, а не замена Health Auto Export. HAE продолжает делать то, в чём он хорош; нативный клиент забирает случаи, до которых HAE не дотягивается. Оба producer-а используют путь к источнику истины, описанный в части 1: сначала сырой payload попадает в health_records, и только потом обновляются производные таблицы.
Зачем вообще возиться с нативным клиентом, если HAE работает для большинства метрик? Потому что телефон — это устройство с прямым доступом к HealthKit2, и некоторые решения имеют смысл только на этой стороне провода. Схема ниже использует собственные типы запросов Apple3:
HKObserver (all metrics) → wakeup
↓
SUM metrics → HKStatisticsCollectionQuery (hourly buckets)
AVG metrics → HKSampleQuery (raw samples, minutely)
Workouts → HKWorkoutQuery + HKWorkoutRoute + HR samples
↓
Merge into single payload
↓
POST /health (one request)
Из спецификации вышли и закрепились три правила дизайна:
- Один запрос на цикл синхронизации. Никаких запросов по отдельным метрикам, никакого разделения на vitals и hourly values. Клиент собирает все изменившиеся метрики в один
POST /health. Сервер уже умеет принимать смешанные payload-ы. HKQueryAnchorна каждую метрику. Каждая синхронизация забирает только сэмплы, появившиеся после последней успешной доставки. Anchors хранятся в SwiftData4 с ключом по имени метрики.- Фильтрация источников на клиенте до отправки. Если для конкретной даты есть данные Apple Watch, клиент пропускает полуночные сводки RingConn за этот день. Сервер всё равно делает собственную cross-validation, но клиент убирает очевидный шум upstream, чтобы серверные логи оставались читаемыми.
Серверная часть осталась источником истины. В нативном приложении нет скоринга, агрегации, дедупликации сна или приоритета источников. Всё это живёт за endpoint-ами /api/health-briefing, /api/metrics/data, /api/readiness-history. Клиент занимается только ingestion-ом и отрисовкой графиков. Когда появляются новые метрики или тексты, обновлять приложение не нужно.
На бумаге такое разделение выглядит чисто. На практике пришлось пройти через две заметные регрессии, чтобы данные начали соглашаться сами с собой.
Тренировки вернулись другим путём
Пункт «тренировки не поддерживаются» из списка ограничений решился не так, как я планировал. В мае 2026 года внешний контрибьютор makvitaly открыл PR #17 в серверный репозиторий: добавил endpoint POST /health/workouts и storage helpers за ним. Endpoint принимает JSON-формат, который выдаёт Health Auto Export automation для тренировок: длительность, дистанция, энергия, таймлайн пульса, маршрут. Затем он пишет данные в новую таблицу workouts рядом с потоком отдельных метрик metric_points. Начиная с 2026-02-13, именно этот endpoint стал источником всех структурированных тренировок в базе. Все 102 записи о ходьбе, которые нашёл аудит из части 4, пришли через путь HAE, а не через нативный клиент.
Это как раз пример архитектуры с двумя producer-ами, работающей по назначению. Неправильным было не «HAE не умеет тренировки». Неправильным было «у сервера нет endpoint-а, чтобы их принять, поэтому ни один producer не может их отправить». Как только endpoint появился, HAE стал путём, который реально наполняет его для меня сегодня. Нативный клиент мог бы получить собственную синхронизацию тренировок — и в SPEC это запланировано, — но нет смысла приоритизировать это, пока HAE уже доставляет данные.
Именно поэтому проект в README указывает HAE как поддерживаемый ingestion path, а не как legacy-слой совместимости. Человек, которому нужны тренировки, но который не хочет собирать iOS-клиент из исходников, получает их через кодовый путь, который внешний контрибьютор добавил без моей помощи.
Раунд 1: исчезли сегменты сна
24 марта 2026 года, в день, когда health-sync начал отправлять данные сна в metric_points рядом с HAE, сегментные записи перестали появляться для ночей, которыми владел нативный клиент. База всё ещё получала ночной sleep_total, но разбивка deep / REM / core для этих ночей исчезла.
Причина: первая версия HealthKitManager.fetchSleep схлопывала все HKCategorySample внутри сессии в одну структуру Accum, а затем отправляла одну строку на фазу за ночь. С точки зрения сервера каждая ночь приходила уже предагрегированной. Time series отдельных сегментов, на которой держится stacked chart стадий сна, просто больше не существовала.
Исправление вошло в health-sync PR #3 (feat(sleep, workouts): ship per-segment sleep + workouts). Теперь каждый HKCategorySample отправляется отдельной строкой с metric_name ∈ {sleep_deep, sleep_rem, sleep_core, sleep_awake} и реальными start/end сегмента. Серверная extractPoints уже умела разворачивать payload sleep_analysis в пять метрик, поэтому сервер менять не пришлось.
Интереснее другое: эта регрессия показала то, что раньше было скрыто. С Health Auto Export я никогда не проверял, как выглядит строка отдельного сегмента сна. Экспортёр их отправлял, сервер сохранял, график рисовал — и весь pipeline работал благодаря случайной совместимости. В первый же момент, когда я стал владельцем producer-side, контракт немедленно сломался. Такова цена универсальности: она прячет протокол.
Раунд 2: 17-часовые ночи из 8 часов сна
PR #3 вернул стадии на дашборд. И одновременно сделал stacked chart стадий сна очевидно неправильным. Ночи, про которые я точно знал, что спал около 8 часов, отображались как 12–17 часов наложенных полос.
Причину пришлось искать не сразу, потому что по отдельности оба слоя выглядели легитимно. На iOS 26 Apple Watch отдаёт сэмплы сна двумя слоями для одного и того же wall-clock интервала:
- Грубый span
.asleepUnspecifiedили legacy.asleep, покрывающий всю ночь. - Детальные сегменты
.asleepDeep/.asleepREM/.asleepCore, наложенные поверх. Именно эту разбивку показывает стандартное Health.app.
PR #3 отправлял оба слоя. Серверный аккумулятор metric_name='sleep_core' делал единственное, что мог: суммировал их. Реальная 8-часовая ночь с deep=1.5h + rem=2h + core=4.5h превращалась в core ≈ 4.5h + unspecified-routed-to-core ≈ 8h ≈ 12.5h «core» в daily_scores.
С тех пор простая sanity check-проверка выглядит так:
SELECT sleep_total, (sleep_deep + sleep_rem + sleep_core + sleep_awake) AS stages_sum
FROM daily_scores;
stages_sum должен быть близок к sleep_total с небольшой поправкой на awake / in-bed margin. Расхождение в два раза означает, что баг с наложением вернулся.
Исправление вошло в health-sync PR #10 (fix(sleep): skip coarse asleepUnspecified when stages exist). Для каждой сессии клиент проверяет, есть ли хоть один детальный marker стадий. Если да, грубый слой выбрасывается и из агрегированного accumulator-а, и из отправки отдельных сегментов. Если нет — старые Apple Watch, RingConn или оценки iPhone Sleep Schedule — грубый слой сохраняется как единственный доступный сигнал. asleepHours() получил тот же guard, чтобы классификация main/nap оставалась согласованной с тем, что реально отправляется.
После деплоя существующие «грязные» даты на сервере были заново отправлены приложением тем же путём: сдвинуть соответствующий HKQueryAnchor, дождаться следующего observer wakeup, заново отправить данные, а серверный UpsertRecentCache пересоберёт затронутые даты из metric_points.
Что на самом деле говорил sleep_core
PR #10 остановил двойной подсчёт, но исходная неточность осталась: колонка sleep_core смешивала две физиологически разные вещи.
- Для Apple Watch с трекингом стадий: реальные минуты «core sleep», Apple-эвристику для лёгкого non-REM сна.
- Для RingConn, iPhone Sleep Schedule и старых Apple Watch: грубый span «просто спал» без информации о стадиях.
График стадий на дашборде показывал оба варианта как «Core». По колонке нельзя было понять, была ли ночь действительно измерена с разрешением до стадий или источник просто сказал: «спал, но какой именно сон — не знаю».
Решением стала пятая метрика фазы сна — sleep_unspecified. Wire contract:
{
"name": "sleep_unspecified",
"units": "hr",
"data": [{"date": "2026-05-14 23:00:00 +0200", "qty": 7.5, "source": "RingConn"}]
}
v2.3 отправляет sleep_unspecified только когда в сессии вообще нет marker-ов .asleepDeep, .asleepREM или .asleepCore. Источники, которые умеют стадии, продолжают отправлять sleep_core как реальное измерение. После rollout-а ночи только с RingConn или только с iPhone перестали попадать в sleep_core и начали попадать в sleep_unspecified.
Изменения на сервере были небольшими и выстроены так, чтобы на каждом шаге сохранять обратную совместимость:
- Добавить
sleep_unspecifiedв daily aggregator, чтобыsleep_total = deep + rem + core + unspecifiedстало новым invariant. - Добавить метрику в
/api/metricsс локализованными display name и description на en/ru/sr. - Добавить на дашборд 5-ю полосу в график стадий между
remиawake, с tooltip: «Нет разбивки по стадиям для этого источника». - Историческая миграция
cmd/migrate_sleep_unspecifiedпереносит pre-v2.3 строкиsleep_core, у которых нет соседнихsleep_deep/sleep_remв пределах ±1 календарного дня, вsleep_unspecified, а затем точечно пересобирает эти даты через тот жеUpsertRecentCache, что используется при свежем ingest. Окно ±1 день защищает Apple Watch-ночи со стадиями, переходящие через полночь, от ошибочной классификации.
У миграции есть известный false-negative case: если источник когда-либо отправлял стадии, окно ±1 day вокруг этих ночей остаётся в sleep_core независимо от того, были ли конкретные строки на самом деле грубыми. Для моей инсталляции это приемлемо и задокументировано для всех, кто клонирует репозиторий.
Что на самом деле изменилось после появления собственного клиента
Две регрессии сами по себе — не самый интересный результат. Они были предсказуемы: каждый раз, когда универсальный producer заменяется кастомным, неявный контракт всплывает в виде багов. Интереснее то, что стало возможно выразить после этого перехода.
- Фильтрация источников на producer-side. Клиент может решить: «Apple Watch есть для этой даты, не отправлять полуночную сводку RingConn». С Health Auto Export интерпретация ночи от каждого источника прилетала на сервер, и серверу приходилось спорить с самим собой, кому доверять.
- Стадии и грубый сон как разные концепты.
sleep_unspecifiedсуществует только потому, что клиент может проинспектировать HealthKit-сессию и решить, в какой bucket относятся данные. Различать их на сервере означало бы гадать по строкам с именами источников. - Тренировки — второй producer. Нативный клиент тоже получил собственную синхронизацию тренировок: HR timeline, GPS routes, распределение шагов и калорий с учётом временных overlap-ов. В основном это появилось между PR #4 и PR #9 на iOS-стороне. Клиент отправляет данные в тот же endpoint
/health/workouts, который PR #17 добавил на сервере, так что оба producer-а способны доставлять тренировки, а таблице всё равно, кто именно записал строку. На практике, поскольку HAE уже надёжно наполнял эту таблицу с 2026-02-13, путь тренировок в нативном клиенте остался доступным, но почти не используется для live ingest. Полезная страховка, но пока не несущая конструкция. - Восстановление после пропусков. Когда синхронизация падает, следующий observer wakeup заново забирает данные от per-metric
HKQueryAnchor, неудачный payload лежит вRetryQueue, а серверныйUpsertRecentCacheпересобирает затронутые даты так, будто это свежий ingest. Без ручного повторного экспорта.
Есть и вещь, которую я пока сознательно не делаю. Приложение не сверяет текущее состояние HealthKit на устройстве с тем, что, по мнению сервера, у него есть. В SPEC эта функция помечена как out of scope для v1, потому что меняет модель доверия: сегодня устройство — producer, сервер — keeper; cross-check сделал бы устройство ещё и auditor-ом. Я хочу добавлять роли по одной.
Настоящий урок
Health Auto Export не был неправильным выбором и никуда не исчез. Он всё ещё хорошо подходит для большей части того, что делает: scheduled JSON over HTTP, без Xcode и provisioning. Сервер всё ещё принимает его данные, а parser его payload-ов поддерживается в том же кодовом пути, который обрабатывает payload-ы нативного клиента.
Изменилось то, что HAE перестал быть достаточным сам по себе для меня. В тот момент, когда у сервера появились собственные мнения о данных — какой источник авторитетен, является ли один HealthKit-слой дублем другого, что на самом деле измеряет колонка, — эти мнения нужно было где-то выразить. Универсальный экспортёр не может иметь их за вас. Он может только передать то, что HealthKit ему отдал. Нативный клиент — место, где теперь живут эти мнения; HAE продолжает делать всё остальное.
Лично я сейчас использую нативный клиент. Именно он каждый день наполняет мой дашборд. Но проект остаётся self-hostable, а путь через HAE остаётся first-class ingestion option для всех, кто клонирует репозиторий и не хочет собирать iOS-приложение из исходников. Архитектура с двумя producer-ами — это не legacy-артефакт, а поддерживаемый способ поднять систему без TestFlight. Кому нужны описанные выше фильтрация источников и обработка стадий, может собрать health-sync самостоятельно. Кому не нужны — получает рабочий дашборд только на HAE, включая тренировки благодаря PR #17, и наследует те же компромиссы.
Предыдущая статья была о доверии к данным. Эта — о доверии к колонкам. Следующая — о доверии к источникам за этими колонками: Apple Watch, RingConn и iPhone не согласны друг с другом насчёт того, что именно они измерили. Системе приходится решать, кому верить в каждую конкретную ночь, прежде чем остальной pipeline сможет сделать с результатом что-то полезное.
Курсор инкрементальной синхронизации HealthKit. Непрозрачный токен, возвращаемый
HKAnchoredObjectQuery, который позволяет при следующем запросе извлекать только выборки, добавленные с момента предыдущего успешного вызова. Хранится для каждой метрики в SwiftData, чтобы клиент не перечитывал старые данные. Если синхронизация завершается сбоем, якорь не сдвигается вперед, и при следующем фоновом пробуждении попытка повторяется с той же точки. ↩︎Фреймворк Apple для хранения и запроса данных о здоровье и фитнесе на iOS. Все показания датчиков с Apple Watch, сторонних трекеров вроде RingConn, а также ручные записи проходят через HealthKit на устройстве до того, как они будут экспортированы на сервер. ↩︎
HealthKit предоставляет несколько типов запросов под разные сценарии доступа.
HKObserverQueryбудит приложение при появлении новых данных определенного типа (даже в фоновом режиме черезenableBackgroundDelivery).HKSampleQueryвозвращает сырые единичные измерения за указанный интервал дат.HKStatisticsCollectionQueryвозвращает предрассчитанные агрегированные значения в бакетах (почасовые суммы, среднесуточные показатели и т.д.), что дешевле извлечения сырых выборок и их последующей агрегации на стороне клиента.HKWorkoutQueryиHKWorkoutRouteвозвращают структурированные сессии тренировок с привязанными к ним маршрутами и замерами ЧСС. ↩︎Фреймворк для хранения данных Apple, представленный в 2023 году как Swift-native преемник Core Data. Описание схемы по принципу «источник истины в коде» (классы
@Model), автоматическая миграция при простых изменениях схемы и тесная интеграция со SwiftUI. ↩︎