Files
HealthLog/docs/integrations/apple-health.md
T
Marc-André Bombeck bf6231d79e chore(release): v1.28.11 — Strava workouts + Garmin guidance
Adds a Strava connection (BYO app keys, per-user, mirroring the other
wearables): activities arrive as workouts, de-duplicated against other sources
by the existing source-priority picker, workouts-only. Documents that Garmin has
no direct connector and flows in via Apple Health / Google Health Connect.
Migration 0233; new MeasurementSource STRAVA on /api/sync/changes.
2026-07-09 21:32:05 +02:00

209 lines
12 KiB
Markdown

# Apple Health import
HealthLog accepts a full Apple Health `export.zip` upload and folds
the contents into the same timeline as every other ingest source.
The importer is streaming end-to-end — multi-gigabyte exports never
land in V8 heap — and idempotent on the upload's SHA-256 so
re-uploading the same archive merges instead of duplicating.
## What you need
- An iPhone with the Apple Health app and at least a few months of
history (otherwise there is little to import).
- A HealthLog account on an instance you can reach from the device
or from a workstation. The upload goes over your authenticated
browser session — no separate API token to provision.
- Up to 1.5 GB of upload bandwidth. Apple's largest observed exports
sit around 800 MB for a 10-year iCloud-synced account; the parser
caps inbound bodies at 1.5 GB
(`src/app/api/import/apple-health-export/route.ts:41`).
## 1. Generate `export.zip` on the iPhone
1. Open the **Health** app.
2. Tap the profile picture in the top-right.
3. Scroll to the bottom and tap **Export All Health Data**.
4. iOS spends a few minutes packaging the archive. When it finishes,
share-sheet the resulting `export.zip` somewhere you can reach
from your HealthLog instance — AirDrop to a workstation, save to
iCloud Drive, send via email or any chat app you control.
The archive contains `apple_health_export/export.xml` (the canonical
record stream) plus a handful of auxiliary files (workout routes,
clinical records, electrocardiograms). The importer reads
`export.xml` and ignores the rest.
## 2. Upload to HealthLog
Open **Settings → Export & Import** in the app and drop the
`export.zip` onto the Apple Health upload control (or click to pick the
file). The browser streams the body directly to the server; nothing is
buffered into memory on either side. A progress indicator polls the
job until it reports the imported / skipped counts.
The endpoint is `POST /api/import/apple-health-export`. The handler:
1. Rate-limits to **three uploads per minute per user** to protect
against a flood of multi-gigabyte uploads
(`src/app/api/import/apple-health-export/route.ts:43-46`).
2. Enforces the **1.5 GB cap** twice — once cheaply against the
declared `Content-Length`, then a second time at the streaming
sink so a missing/wrong header cannot bypass it.
3. Streams the body to a temp file at `/tmp/healthlog-apple-health-import-*`
while computing a SHA-256 of the bytes inline.
4. Looks up any prior `ImportJob` row with the same `userId` +
`uploadSha256`. **A re-upload of the same archive short-circuits to
the previous job** instead of re-queueing — the response carries
`idempotent: true` and the original `jobId`.
5. Otherwise creates a fresh `ImportJob` row in `queued`, enqueues
the `apple-health-import` pg-boss job, and returns the new `jobId`.
The response body is `{ "jobId": "...", "status": "queued" }` with a
`202 Accepted`.
## 3. Poll the status endpoint
`GET /api/import/apple-health-export/{jobId}/status` returns the
canonical envelope:
```json
{
"data": {
"jobId": "clx123…",
"status": "parsing",
"startedAt": "2026-05-16T12:34:56.000Z",
"completedAt": null,
"uploadBytes": 412345678,
"exportedAt": "2026-04-30T18:12:00.000Z",
"progress": { "records_seen": 124000, "workouts_seen": 230 },
"result": null,
"failureReason": null
},
"error": null
}
```
The lifecycle is `queued → unpacking → parsing → upserting → done`
with `failed` as a terminal-error branch
(`src/app/api/import/apple-health-export/[jobId]/status/route.ts:8-9`).
The iOS native client polls every two seconds while active and every
thirty seconds once the job is terminal; the web UI uses the same
cadence. Authorisation: only the job owner can read it. The admin
who kicked off an import via the admin variant can also read the row
they triggered.
## What gets imported
The parser folds four XML element types into the existing HealthLog
schema (CHANGELOG v1.4.34):
| XML element | Maps to | Notes |
| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
The HK quantity types that map to HealthLog metric types live in
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
mapped set (workout routes, ECG voltage traces) is counted in
`progress.records_skipped` but not stored.
### Cumulative HK type → daily aggregate
Cumulative quantity types — steps, active energy, walking/running
distance, flights climbed — get **collapsed into one row per
user-local day** to match the iOS daily-aggregation convention.
- External ID format: `stats:<HKType>:<YYYY-MM-DD>` (e.g.
`stats:HKQuantityTypeIdentifierStepCount:2026-05-15`).
- The day boundary respects the user's timezone preference. The
worker defaults to `Europe/Berlin` when no preference is set
(`src/lib/jobs/apple-health-import-worker.ts:132-133`).
- A second import of the same archive re-folds the same days onto
the same external IDs — the values update in place rather than
duplicating.
### Re-import idempotency
The two-axis idempotency story:
1. **File-level** — re-uploading the same `export.zip` (same SHA-256
of bytes) short-circuits to the previous job before the parser
even starts.
2. **Record-level** — when an older `export.zip` is re-exported from
iOS (same history, slightly newer device timestamps), every
record's external ID stays stable. The upsert path matches on
`(userId, externalId)` and updates instead of inserting.
Practically: a user who exports monthly and re-imports each archive
sees fresh data merged in without duplicates. The job result line
in the status response reports `recordsInserted` and
`recordsUpdated` separately.
## Admin variant — import on behalf of another user
`POST /api/admin/import-apple-health-export` is the cookie-only admin
variant (Bearer tokens never elevate to admin). The multipart body
adds a `userId` text field naming the target user; everything else
is identical to the user-facing flow.
The `ImportJob` row carries `triggeredByAdminId = admin.id` so the
status endpoint admits both the target user and the triggering
admin. Idempotency is scoped by target user — an admin re-uploading
the same archive for the same user resolves to the previous job.
Typical use: migrating a friend's history into their HealthLog
account when they cannot run the import themselves, or backfilling
a household member's data after the admin received the `export.zip`
out of band.
## Source-priority interaction
HealthLog's source ladder is **APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻
IMPORT** for cumulative metrics (steps, active energy, distance,
flights, sleep, HRV, resting HR) and **WITHINGS ≻ APPLE_HEALTH ≻
MANUAL** for point measurements where the Withings device is the
primary sensor (weight, BP, body fat, body temperature, SpO₂, VO₂
max). The defaults live in `src/lib/validations/source-priority.ts:205-220`.
Concrete consequences:
- If you already sync steps from a Withings ScanWatch and also import
Apple Health, the Apple-Health-aggregated stream wins for the day —
iOS HealthKit folds the watch sensor + iPhone motion data into a
single canonical stream, so it is the most complete cumulative
source. The Withings rows stay in the database as an audit trail
but drop out of the per-day aggregation.
- If you weigh yourself on a Withings scale and also import Apple
Health (which received the same reading via the Health Mate iOS
app), the Withings row wins for display. Apple Health is
second-hand for that metric.
- Manual entries always rank below either device source. The IMPORT
source tag — the lowest rank — is reserved for legacy CSV/JSON
imports that pre-date the Apple Health passthrough.
Override per-user via the Sources section of `/settings/thresholds`.
## Failure modes
| Failure | What you see | Recovery |
| --------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
| Unsupported compression method | `failureReason: "Unsupported ZIP compression method <n> for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
The wide-event log line `import.apple-health.kickoff` (or
`import.apple-health.kickoff.denied` on failure) captures every
attempt with the upload size, SHA-256, and the resolved `ImportJob`
id for debugging.
## Garmin owners
Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
to Apple Health automatically, so the import path on this page is how Garmin data
reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
what Garmin withholds.