Health Dashboard, 2. deo: izvan granica Health Auto Export-a
Drugi deo mog dnevnika izgradnje Health Dashboard-a: zašto generički Apple Health exporter više nije bio dovoljan sam za sebe, kakve je regresije donelo dodavanje nativnog iOS klijenta i kako se ispostavilo da je sleep_core godinama govorio neistinu. Health Auto Export i dalje radi paralelno sa nativnim klijentom. Ovo nije priča o odustajanju od njega, već o izlasku iz njegovih ograničenja.
U 1. delu server je naučio da prima merenja bez gubitka podataka i da ponovo izgradi svaku izvedenu tabelu iz sirovih podataka. To je bilo dovoljno da dashboard postane koristan. Ali nije bilo dovoljno da podaci postanu pošteni.
Ovaj deo govori o trenutku kada eksterni exporter više nije bio dovoljan kao samostalan ingestion path, i o dve regresije koje su se pojavile kada sam pored njega dodao sopstveni iOS klijent. Health Auto Export i dalje šalje podatke na server. Endpoint /health prima oba izvora, a code path koji parsira HAE payload-e i dalje se održava. „Izlazak iz granica“ u naslovu odnosi se na ograničenja HAE-a, a ne na njegovo slanje u penziju.
Zašto je Health Auto Export bio prava početna tačka
Health Auto Export mi je dao radnu površinu za prijem podataka bukvalno za jedno veče. JSON payload-i preko HTTP-a, podesiv raspored i lista imena metrika koja su se već poklapala sa onim što je server želeo da čuva. Bez Xcode-a, bez entitlements-a, bez provisioning profiles. Oblik payload-a koji je emitovao isti je onaj sa kojim server radi i danas:
{"data": {"metrics": [{"name": "...", "units": "...", "data": [...]}]}}
Za prvu fazu projekta — „pokaži mi broj ujutru“ — to je bio pravi kompromis. Hteo sam da shvatim u šta server treba da se pretvori, a ne da pišem iOS aplikaciju za sistem koji još nije imao jasnu svrhu.
Gde je prestao da odgovara
Exporter je po prirodi generički. Svaka nova stvar o kojoj je server učio da brine pretvarala je tu univerzalnost u problem.
- Identitet izvora. Server je morao da razume da li red sa podacima o snu dolazi sa Apple Watch-a — sa podelom po fazama — ili sa RingConn-a — kao gruba noćna suma. Health Auto Export je slao string sa imenom izvora, ali je slao oba skupa podataka ako je ista noć bila pokrivena dva puta, i nije mogao da potisne jedan u korist drugog.
- Vreme sinhronizacije. Jutarnji izveštaj se pokreće tek kada se podaci o snu od prethodne noći „slegnu“: poslednji segment se završio pre ≥45 minuta, a u poslednjih 20 minuta nije bilo novih fragmenata. Sa generičkim exporter-om koji živi po sopstvenom rasporedu, server nije mogao da zna da li tišina znači „podataka još nema“ ili „Apple Watch nije povezan“.
- Nepotpuni dani. Pad sinhronizacije usred dana terao je server da zaključuje o datumu koji je bio napola stvaran, a napola prazan. Bez
HKQueryAnchor1 na strani klijenta, oporavak je značio ručni ponovni export opsega datuma iz aplikacije. - Treninzi. U početku ih nije podržavala nijedna putanja. Server nije imao endpoint za treninge, a standardna HAE šema ih nije slala. Ovaj problem se rešio drugačije od ostalih — o tome posebno u nastavku.
- Faze sna. Exporter je propuštao sve što HealthKit vrati, uključujući grubi sloj
.asleepUnspecified, koji Apple Watch na iOS 26 šalje paralelno sa detaljnim fazama. Server nije imao način da shvati da jedno duplira drugo.
Upravo je poslednja stavka kasnije postala najskuplja. Detalji slede.
Dodavanje nativnog klijenta pored HAE-a
Sledeći korak bila je nativna iOS aplikacija health-sync: Swift 6, iOS 26+, bez zavisnosti trećih strana. To je drugi producer koji piše u isti endpoint POST /health, a ne zamena za Health Auto Export. HAE nastavlja da radi ono u čemu je dobar; nativni klijent preuzima slučajeve do kojih HAE ne dopire. Oba producer-a koriste putanju ka izvoru istine opisanu u 1. delu: prvo sirovi payload ide u health_records, a tek zatim se ažuriraju izvedene tabele.
Zašto se uopšte baviti nativnim klijentom ako HAE radi za većinu metrika? Zato što je telefon uređaj sa direktnim pristupom HealthKit-u2, i neke odluke imaju smisla samo na toj strani žice. Dijagram ispod koristi Apple-ove sopstvene tipove upita3:
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)
Iz specifikacije su izašla i zadržala se tri dizajnerska pravila:
- Jedan zahtev po ciklusu sinhronizacije. Bez zahteva po pojedinačnim metrikama, bez razdvajanja na vitals i hourly values. Klijent pakuje sve promenjene metrike u jedan
POST /health. Server već ume da primi mešovite payload-e. HKQueryAnchorpo metrici. Svaka sinhronizacija preuzima samo sample-ove nastale posle poslednje uspešne isporuke. Sidra se čuvaju u SwiftData4, sa ključem po imenu metrike.- Filtriranje izvora na klijentu pre slanja. Ako za određeni datum postoje podaci sa Apple Watch-a, klijent preskače ponoćne RingConn sažetke za taj dan. Server i dalje radi sopstvenu cross-validation, ali klijent uklanja očigledan šum upstream, da bi server logovi ostali čitljivi.
Serverska strana ostala je izvor istine. U nativnoj aplikaciji nema scoring-a, agregacije, deduplikacije sna ni prioriteta izvora. Sve to živi iza endpoint-a /api/health-briefing, /api/metrics/data, /api/readiness-history. Klijent se bavi samo ingestion-om i crtanjem grafikona. Kada se dodaju nove metrike ili tekstovi, update aplikacije nije potreban.
Na papiru ta podela izgleda čisto. U praksi su bile potrebne dve vidljive regresije da bi podaci počeli da se slažu sami sa sobom.
Treninzi su se vratili drugim putem
Stavka „treninzi nisu podržani“ iz liste ograničenja rešila se drugačije nego što sam planirao. U maju 2026. eksterni contributor makvitaly otvorio je PR #17 u serverskom repozitorijumu: dodao je endpoint POST /health/workouts i storage helpers iza njega. Endpoint prima JSON format koji emituje Health Auto Export automation za treninge: trajanje, distancu, energiju, timeline pulsa, rutu. Zatim upisuje podatke u novu tabelu workouts pored toka pojedinačnih metrika metric_points. Od 2026-02-13 upravo taj endpoint je izvor svih strukturiranih treninga u bazi. Svih 102 zapisa o hodanju koje je audit iz 4. dela pronašao stigli su kroz HAE putanju, a ne kroz nativni klijent.
To je dobar primer arhitekture sa dva producer-a koja radi kako je zamišljeno. Problem nije bio „HAE ne ume treninge“. Problem je bio „server nema endpoint da ih primi, pa nijedan producer ne može da ih pošalje“. Čim se endpoint pojavio, HAE je postao putanja koja ga danas zaista puni za mene. Nativni klijent bi mogao da dobije sopstvenu sinhronizaciju treninga — i u SPEC-u je to planirano — ali nema smisla prioritizovati to dok HAE već isporučuje podatke.
Zato README projekta navodi HAE kao podržan ingestion path, a ne kao legacy sloj kompatibilnosti. Korisnik kome trebaju treninzi, ali ne želi da build-uje iOS klijent iz source-a, dobija ih kroz code path koji je eksterni contributor dodao bez moje pomoći.
Runda 1: nestali su segmenti sna
- marta 2026, na dan kada je
health-syncpočeo da šalje podatke o snu umetric_pointspored HAE-a, segmentni zapisi prestali su da stižu za noći koje je pokrivao nativni klijent. Baza je i dalje dobijala noćnisleep_total, ali breakdown deep / REM / core za te noći je nestao.
Uzrok: prva verzija HealthKitManager.fetchSleep sabijala je sve HKCategorySample unutar sesije u jednu strukturu Accum, a zatim slala jednu liniju po fazi za noć. Iz ugla servera svaka noć je stizala već pre-agregirana. Time series pojedinačnih segmenata, na kom počiva stacked chart faza sna, jednostavno više nije postojao.
Popravka je stigla u health-sync PR #3 (feat(sleep, workouts): ship per-segment sleep + workouts). Sada se svaki HKCategorySample šalje kao poseban red sa metric_name ∈ {sleep_deep, sleep_rem, sleep_core, sleep_awake} i stvarnim start/end vremenima segmenta. Serverski extractPoints je već znao kako da razvije payload sleep_analysis u pet metrika, pa server nije morao da se menja.
Zanimljivije je šta je ova regresija pokazala. Sa Health Auto Export-om nikada nisam proveravao kako izgleda red pojedinačnog segmenta sna. Exporter ih je slao, server ih je čuvao, grafikon ih je crtao — i ceo pipeline je radio zahvaljujući slučajnoj kompatibilnosti. Čim sam postao vlasnik producer-side-a, ugovor se odmah slomio. To je cena univerzalnosti: ona sakriva protokol.
Runda 2: noći od 17 sati iz 8 sati sna
PR #3 vratio je faze na dashboard. I istovremeno učinio stacked chart faza sna očigledno pogrešnim. Noći za koje sam znao da sam spavao oko 8 sati prikazivale su se kao 12–17 sati naslaganih traka.
Uzrok nije bilo lako odmah pronaći, jer su oba sloja odvojeno izgledala legitimno. Na iOS 26 Apple Watch šalje sleep sample-ove u dva sloja za isti wall-clock interval:
- Grubi span
.asleepUnspecifiedili legacy.asleep, koji pokriva celu noć. - Detaljne segmente
.asleepDeep/.asleepREM/.asleepCore, naslagane preko njega. To je ista podela koju prikazuje standardni Health.app.
PR #3 je slao oba sloja. Serverski accumulator metric_name='sleep_core' uradio je jedino što je mogao: sabrao ih. Realna noć od 8 sati sa deep=1.5h + rem=2h + core=4.5h pretvarala se u core ≈ 4.5h + unspecified-routed-to-core ≈ 8h ≈ 12.5h „core“ u daily_scores.
Od tada jednostavna sanity check provera izgleda ovako:
SELECT sleep_total, (sleep_deep + sleep_rem + sleep_core + sleep_awake) AS stages_sum
FROM daily_scores;
stages_sum treba da bude blizu sleep_total, uz malu korekciju za awake / in-bed margin. Razlika od dva puta znači da se bug sa preklapanjem vratio.
Popravka je stigla u health-sync PR #10 (fix(sleep): skip coarse asleepUnspecified when stages exist). Za svaku sesiju klijent proverava da li postoji makar jedan detaljni marker faza. Ako postoji, grubi sloj se izbacuje i iz agregiranog accumulator-a i iz slanja pojedinačnih segmenata. Ako ne postoji — stariji Apple Watch, RingConn ili procene iPhone Sleep Schedule-a — grubi sloj ostaje kao jedini dostupni signal. asleepHours() je dobio isti guard, da bi klasifikacija main/nap ostala usklađena sa onim što se zaista šalje.
Posle deploy-a postojeći „prljavi“ datumi na serveru ponovo su poslati iz aplikacije istom putanjom: pomeriti odgovarajući HKQueryAnchor, sačekati sledeći observer wakeup, ponovo poslati podatke, a serverski UpsertRecentCache će izgraditi pogođene datume iz metric_points.
Šta je sleep_core zapravo govorio
PR #10 je zaustavio duplo brojanje, ali osnovna laž je ostala: kolona sleep_core mešala je dve fiziološki različite stvari.
- Za Apple Watch sa tracking-om faza: stvarne minute „core sleep-a“, Apple-ovu heuristiku za lagani non-REM san.
- Za RingConn, iPhone Sleep Schedule i starije Apple Watch modele: grubi span „samo spavao“ bez informacija o fazama.
Grafikon faza na dashboard-u prikazivao je oba slučaja kao „Core“. Iz kolone se nije moglo zaključiti da li je noć zaista izmerena sa rezolucijom do faza ili je izvor samo rekao: „spavao je, ali ne znam kakav je san bio“.
Rešenje je bila peta metrika faze sna — 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 šalje sleep_unspecified samo kada u sesiji uopšte nema marker-a .asleepDeep, .asleepREM ili .asleepCore. Izvori koji umeju faze nastavljaju da šalju sleep_core kao stvarno merenje. Posle rollout-a, noći samo sa RingConn-a ili samo sa iPhone-a prestale su da ulaze u sleep_core i počele su da ulaze u sleep_unspecified.
Izmene na serveru bile su male i poređane tako da se na svakom koraku sačuva kompatibilnost unazad:
- Dodati
sleep_unspecifiedu daily aggregator, da bisleep_total = deep + rem + core + unspecifiedpostao novi invariant. - Dodati metriku u
/api/metricssa lokalizovanim display name i description na en/ru/sr. - Dodati na dashboard 5. traku u grafikon faza, između
remiawake, sa tooltip-om: „Nema podele po fazama za ovaj izvor“. - Istorijska migracija
cmd/migrate_sleep_unspecifiedpremešta pre-v2.3 redovesleep_corekoji nemaju susednesleep_deep/sleep_remu okviru ±1 kalendarskog dana usleep_unspecified, a zatim ciljano obnavlja te datume kroz istiUpsertRecentCachekoji se koristi pri svežem ingest-u. Prozor ±1 dan štiti Apple Watch noći sa fazama koje prelaze ponoć od pogrešne klasifikacije.
Migracija ima poznat false-negative case: ako je izvor ikada slao faze, prozor ±1 day oko tih noći ostaje u sleep_core, bez obzira na to da li su konkretni redovi zaista bili grubi. Za moju instalaciju to je prihvatljivo i dokumentovano za sve koji kloniraju repozitorijum.
Šta se zapravo promenilo posle sopstvenog klijenta
Dve regresije same po sebi nisu najzanimljiviji rezultat. Bile su predvidive: svaki put kada generički producer zamenite custom producer-om, implicitni ugovor ispliva kao bug. Zanimljivije je ono što je postalo moguće izraziti posle tog prelaza.
- Filtriranje izvora na producer-side-u. Klijent može da odluči: „Apple Watch postoji za ovaj datum, ne šalji ponoćni RingConn sažetak“. Sa Health Auto Export-om, interpretacija noći iz svakog izvora stizala je na server, pa je server morao da se raspravlja sam sa sobom kome da veruje.
- Faze i grubi san kao različiti koncepti.
sleep_unspecifiedpostoji samo zato što klijent može da pregleda HealthKit sesiju i odluči u koji bucket podaci pripadaju. Razlikovati ih na serveru značilo bi pogađati na osnovu stringova sa imenima izvora. - Treninzi — drugi producer. Nativni klijent je takođe dobio sopstvenu sinhronizaciju treninga: HR timeline, GPS routes, raspodelu koraka i kalorija uz uvažavanje vremenskih overlap-a. Uglavnom se to pojavilo između PR #4 i PR #9 na iOS strani. Klijent šalje podatke na isti endpoint
/health/workouts, koji je PR #17 dodao na serveru, tako da oba producer-a mogu da isporučuju treninge, a tabelu ne zanima ko je upisao red. U praksi, pošto je HAE već pouzdano punio tu tabelu od 2026-02-13, putanja treninga u nativnom klijentu ostala je dostupna, ali se skoro ne koristi za live ingest. Korisna rezerva, ali još nije noseća konstrukcija. - Oporavak posle propusta. Kada sinhronizacija padne, sledeći observer wakeup ponovo preuzima podatke od per-metric
HKQueryAnchor, neuspešni payload stoji uRetryQueue, a serverskiUpsertRecentCacheobnavlja pogođene datume kao da je reč o svežem ingest-u. Bez ručnog ponovnog export-a.
Postoji i nešto što za sada svesno ne radim. Aplikacija ne poredi trenutno stanje HealthKit-a na uređaju sa onim što server misli da ima. U SPEC-u je ta funkcija označena kao out of scope za v1, jer menja model poverenja: danas je uređaj producer, server keeper; cross-check bi od uređaja napravio i auditor-a. Želim jednu ulogu po komponenti.
Prava lekcija
Health Auto Export nije bio pogrešan izbor i nije nestao. I dalje je dobar za većinu onoga što radi: scheduled JSON over HTTP, bez Xcode-a i provisioning-a. Server i dalje prima njegove podatke, a parser njegovih payload-a održava se u istom code path-u koji obrađuje payload-e nativnog klijenta.
Promenilo se to što HAE više nije dovoljan sam za sebe za mene. U trenutku kada je server dobio sopstvena mišljenja o podacima — koji je izvor autoritativan, da li je jedan HealthKit sloj duplikat drugog, šta kolona zaista meri — ta mišljenja su morala negde da se izraze. Generički exporter ne može da ih ima umesto vas. Može samo da prosledi ono što mu je HealthKit dao. Nativni klijent je mesto gde ta mišljenja sada žive; HAE nastavlja da radi sve ostalo.
Lično sada koristim nativni klijent. On svakog dana puni moj dashboard. Ali projekat ostaje self-hostable, a HAE putanja ostaje first-class ingestion option za sve koji kloniraju repozitorijum i ne žele da build-uju iOS aplikaciju iz source-a. Arhitektura sa dva producer-a nije legacy artefakt, već podržan način da se sistem podigne bez TestFlight-a. Kome trebaju gore opisano filtriranje izvora i obrada faza, može sam da build-uje health-sync. Kome ne trebaju — dobija radni dashboard samo na HAE-u, uključujući treninge zahvaljujući PR #17, i nasleđuje iste kompromise.
Prethodni članak bio je o poverenju u podatke. Ovaj je o poverenju u kolone. Sledeći je o poverenju u izvore iza tih kolona: Apple Watch, RingConn i iPhone se ne slažu međusobno oko toga šta su tačno izmerili. Sistem mora da odluči kome veruje za svaku konkretnu noć pre nego što ostatak pipeline-a može da uradi bilo šta korisno sa rezultatom.
Kursor za inkrementalnu sinhronizaciju HealthKit-a. Neproziran token koji vraća
HKAnchoredObjectQuery, a koji omogućava sledećem upitu da preuzme samo uzorke dodate od prethodnog uspešnog poziva. Čuva se za svaku metriku u SwiftData kako klijent ne bi ponovo čitao stare podatke. Ako sinhronizacija ne uspe, sidro se ne pomera unapred, a sledeće buđenje pozadinskog procesa ponavlja pokušaj sa iste tačke. ↩︎Apple-ov framework za čuvanje i pretraživanje podataka o zdravlju i fitnesu na iOS-u. Svi podaci sa senzora na Apple Watch-u, uređaja trećih strana za praćenje sna poput RingConn-a, kao i ručni unosi prolaze kroz HealthKit na uređaju pre nego što bilo kakav izvoz stigne na server. ↩︎
HealthKit nudi nekoliko tipova upita prilagođenih različitim scenarijima pristupa.
HKObserverQuerybudi aplikaciju kada stignu novi podaci određenog tipa (čak i u pozadini, uzenableBackgroundDelivery).HKSampleQueryvraća sirove pojedinačne uzorke u okviru određenog opsega datuma.HKStatisticsCollectionQueryvraća unapred agregirane vrednosti u vremenskim intervalima (satne sume, dnevne proseke itd.), što je jeftinije od preuzimanja sirovih uzoraka i njihove agregacije na strani klijenta.HKWorkoutQueryiHKWorkoutRoutevraćaju strukturirane sesije treninga sa povezanim rutama i uzorcima pulsa. ↩︎Apple-ov framework za perzistenciju podataka predstavljen 2023. godine kao Swift-native naslednik Core Data. Definisanje šeme po principu „izvor istine u kodu“ (
@Modelklase), automatska migracija za jednostavne promene šeme i bliska integracija sa SwiftUI-jem. ↩︎