Grafana über eine Datenbank füttern
Zuerst die unbequeme Wahrheit
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 kleiner, als es klingt: ein paar Zeilen, die den POST annehmen und in eine Tabelle schreiben. Den Rest macht Grafana.
Wenn du keine Datenbank betreiben willst, ist das hier nicht dein Weg. Dann nimm 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 Punktformen der App in dieselben Spalten.
Der Empfänger
Dieses Beispiel nimmt den POST an, versteht alle drei Punktformen 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
Das ist der Punkt, an dem selbstgebaute Empfänger reihenweise scheitern.
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. Bei bis zu einem Lauf pro Stunde landet derselbe Tag also viele Male bei dir, meist mit identischem, manchmal mit korrigiertem Wert.
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 stumpf 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. Korrigiert deine Uhr die Nacht nachträglich, ä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, Zugangsdaten eintragen, fertig. 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, die Zeit kosten
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 kein Zierrat: Wer fehlende Stadien als 0 einträgt, malt sich Nächte ohne Tiefschlaf ins Dashboard, die es nie gab.
Was wir nicht wissen können
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. Und alles, was hinter dem 2xx passiert, siehst weder du im Protokoll der App noch wir.
Die vollständige Beschreibung dessen, was ankommt — alle zwölf Metriken, alle Felder, alle Einheiten — steht in der Format-Referenz.