Apple-Health-Daten in Grafana anzeigen
Warum Grafana nicht das Ziel sein kann
Grafana kann nicht das Ziel sein, das du in der App einträgst. Grafana nimmt keine Daten entgegen — es liest sie aus einer Datenquelle. Es gibt keine Adresse, an die man ein Dashboard beliefert.
Der Weg hat deshalb ein Zwischenstück:
App ──POST──▶ dein Empfänger ──▶ Datenbank ◀──liest── Grafana
Das Zwischenstück ist ein Skript von rund dreißig Zeilen (Beispiel unten): Es nimmt den POST an und schreibt die Werte in eine Tabelle. Grafana liest aus dieser Tabelle.
Wenn du keine Datenbank betreiben willst, nimm stattdessen den Dateiexport und werte in Excel oder Numbers aus.
Die Tabelle
Eine einzige Tabelle genügt, im Langformat — ein Wert je Zeile. Der Schlüssel ist Metrik + Statistik + Tag, und zwar aus einem Grund, der weiter unten steht:
CREATE TABLE readings ( day date NOT NULL, ts timestamptz NOT NULL, metric text NOT NULL, stat text NOT NULL, value double precision NOT NULL, unit text, PRIMARY KEY (metric, stat, day) );
stat unterscheidet, was der Wert bedeutet: qty bei den meisten Metriken, min/avg/max beim Puls, asleep/deep/rem/core/awake beim Schlaf. Damit passen alle drei Arten von Datenpunkten der App in dieselben Spalten.
Der Empfänger
Dieses Beispiel nimmt den POST an, versteht alle drei Arten von Datenpunkten und schreibt aktualisierend. PHP mit PDO, hier gegen PostgreSQL — SQLite oder MySQL gehen genauso, nur die Zeile mit dem Verbindungsstring und das ON CONFLICT ändern sich.
<?php
$doc = json_decode(file_get_contents('php://input'), true);
if (!is_array($doc['data']['metrics'] ?? null)) { http_response_code(400); exit('bad json'); }
$db = new PDO('pgsql:host=localhost;dbname=health', 'user', 'pw',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$up = $db->prepare(
'INSERT INTO readings (day, ts, metric, stat, value, unit) VALUES (?,?,?,?,?,?)
ON CONFLICT (metric, stat, day) DO UPDATE SET value = EXCLUDED.value, ts = EXCLUDED.ts');
// Feldname im Punkt => stat-Wert in der Tabelle. totalSleep fehlt bewusst:
// es trägt immer denselben Wert wie asleep.
$STATS = ['qty'=>'qty', 'Min'=>'min', 'Avg'=>'avg', 'Max'=>'max',
'asleep'=>'asleep', 'deep'=>'deep', 'rem'=>'rem', 'core'=>'core', 'awake'=>'awake'];
foreach ($doc['data']['metrics'] as $m) {
foreach ($m['data'] as $p) {
$t = DateTime::createFromFormat('Y-m-d H:i:s O', $p['date']); // Offset OHNE Doppelpunkt
if (!$t) continue;
$day = substr($p['date'], 0, 10);
foreach ($STATS as $feld => $stat) {
if (isset($p[$feld])) {
$up->execute([$day, $t->format('c'), $m['name'], $stat, (float)$p[$feld], $m['units']]);
}
}
}
}
http_response_code(200);
echo 'ok';
Trag die Adresse dieser Datei in der App unter Senden → Ziel ein. Sie muss von außen über https:// erreichbar sein — die App sendet auch, wenn du unterwegs bist. Wie du sie absicherst, steht in Ein Ziel einrichten: Die App kann einen frei benannten Header mitschicken, den du am Anfang gegen einen Wert prüfst.
Warum aktualisierend und nicht anhängend
Diesen Punkt übersieht man beim Selbstbau leicht.
Dieselben Tage kommen mehrfach. Die App sendet bei jedem Lauf die letzten drei Tage mit — absichtlich, damit Lücken sich von selbst schließen, wenn dein Server einmal weg war. Derselbe Tag landet deshalb viele Male bei dir, meist mit identischem, manchmal mit korrigiertem Wert. Wie oft, steht nicht fest: Eine Automation und „Jetzt senden“ lösen jederzeit einen Lauf aus — dein Empfänger muss jede Wiederholung vertragen.
Für heart_rate und step_count ist dieses Fenster kürzer: ein Tag, weil dort die meisten Rohwerte hängen. War dein Server länger als einen Tag weg, holen sich diese beiden Reihen die alten Tage nicht von selbst zurück — dafür einmal in der App auf „Jetzt senden“ tippen, das liest drei Tage.
Zustellung ist at-least-once. Antwortet dein Server zu langsam (Grenze: 90 Sekunden) oder geht die Antwort unterwegs verloren, wiederholt die App dieselbe Sendung später.
Wer nur INSERT schreibt, hat nach einer Woche jeden Tag drei- bis vierfach und ein Dashboard, das Schritte addiert, die es nur einmal gab. Der Primärschlüssel oben und das ON CONFLICT erledigen das.
Warum der Schlüssel auf dem Tag liegt und nicht auf dem Zeitstempel: Tageswerte tragen 12:00 Uhr Ortszeit, Schlafpunkte dagegen das echte Ende der Nacht. Wird die Nacht nachträglich korrigiert (z. B. von deiner Uhr oder deinem Ring), ändert sich der Zeitstempel — der Tag bleibt. Auf dem Tag geschlüsselt wird die Nacht aktualisiert statt verdoppelt.
Die Datenquelle in Grafana
PostgreSQL und MySQL bringt Grafana von Haus aus mit — Datenquelle anlegen und Zugangsdaten eintragen. Für SQLite brauchst du ein zusätzliches Plugin; wenn du ohnehin frei wählst, nimm PostgreSQL.
Eine Zeitreihen-Abfrage sieht dann so aus:
SELECT ts AS "time", value FROM readings WHERE metric = 'resting_heart_rate' AND stat = 'qty' AND $__timeFilter(ts) ORDER BY ts;
Für den Schlaf nimmst du metric = 'sleep_analysis' und stat = 'asleep' — der Wert steht dort in Stunden. Beim Puls liefern min, avg und max drei Serien für dasselbe Panel.
Drei Stolpersteine
Der Zeitstempel ist kein ISO-8601. Der Offset steht ohne Doppelpunkt (2026-08-02 12:00:00 +0200). Strenge Parser scheitern; deshalb steht im Beispiel oben 'Y-m-d H:i:s O' statt einer Automatik.
Prozentwerte sind schon umgerechnet. SpO₂ kommt als 97.4 mit Einheit %, nicht als 0,974. Wer noch einmal mit 100 multipliziert, bekommt 9740 %.
Fehlende Felder sind nicht null. Nächte ohne echte Schlafstadien lassen deep, rem und core vollständig weg. Das isset() im Beispiel ist deshalb nötig: Wer fehlende Stadien als 0 einträgt, sieht im Dashboard Nächte ohne Tiefschlaf, die es nie gab.
Was das Protokoll der App nicht zeigt
Sobald dein Empfänger mit 200 antwortet, gilt die Sendung als zugestellt — auch wenn dein INSERT danach scheitert. Antworte deshalb erst, wenn du wirklich geschrieben hast. Was nach dem 2xx passiert, taucht im Protokoll der App nicht auf.
Die vollständige Beschreibung dessen, was ankommt — alle zwölf Metriken, alle Felder, alle Einheiten — steht in der Format-Referenz.