Feed Grafana through a database

For self-hosters · September 2026

The inconvenient truth first

Grafana cannot be the destination you enter in the app. Grafana does not accept data — it reads it from a data source. There is no address you deliver a dashboard to.

So the path has a piece in the middle:

app  ──POST──▶  your receiver  ──▶  database  ◀──reads──  Grafana

That middle piece is smaller than it sounds: a handful of lines that accept the POST and write it into a table. Grafana does the rest.

If you don't want to run a database, this isn't your path. Take the file export and work in Excel or Numbers instead.

The table

A single table in long format is enough — one value per row. The key is metric + statistic + day, for a reason given further down:

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 records what the value means: qty for most metrics, min/avg/max for heart rate, asleep/deep/rem/core/awake for sleep. That way all three point shapes fit the same columns.

The receiver

This example accepts the POST, understands all three point shapes and writes by updating. PHP with PDO, here against PostgreSQL — SQLite or MySQL work just as well; only the connection string and the ON CONFLICT clause change.

<?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');

// field in the point  =>  stat value in the table. totalSleep is left out on purpose:
// it always carries the same value as 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 has NO colon
        if (!$t) continue;
        $day = substr($p['date'], 0, 10);
        foreach ($STATS as $field => $stat) {
            if (isset($p[$field])) {
                $up->execute([$day, $t->format('c'), $m['name'], $stat, (float)$p[$field], $m['units']]);
            }
        }
    }
}
http_response_code(200);
echo 'ok';

Enter this file's address in the app under "Senden" (send) → destination. It has to be reachable from the outside over https:// — the app also sends while you're away from home. How to protect it is covered in Set up a destination: the app can send a header you name yourself, which you check against a value at the top.

Why updating, not appending

This is where home-built receivers fail in droves.

The same days arrive repeatedly. Every run includes the last three days — deliberately, so gaps close by themselves if your server was ever away. At up to one run per hour, the same day reaches you many times over, usually with an identical value, occasionally with a corrected one.

Delivery is at-least-once. If your server answers too slowly (the limit is 90 seconds) or the reply is lost in transit, the app repeats the same delivery later.

Write a plain INSERT and after a week you have every day three or four times over, and a dashboard that sums steps which only happened once. The primary key above and the ON CONFLICT clause take care of it.

Why the key is on the day and not the timestamp: daily values carry 12:00 local time, whereas sleep points carry the real end of the night. If your watch corrects a night afterwards, the timestamp moves — the day does not. Keyed on the day, the night is updated rather than duplicated.

The data source in Grafana

PostgreSQL and MySQL ship with Grafana — add a data source, enter the credentials, done. SQLite needs an extra plugin; if you're free to choose, take PostgreSQL.

A time-series query then looks like this:

SELECT ts AS "time", value
FROM readings
WHERE metric = 'resting_heart_rate' AND stat = 'qty'
  AND $__timeFilter(ts)
ORDER BY ts;

For sleep use metric = 'sleep_analysis' and stat = 'asleep' — that value is in hours. For heart rate, min, avg and max give you three series for one panel.

Three traps that cost time

The timestamp is not ISO 8601. The offset carries no colon (2026-08-02 12:00:00 +0200). Strict parsers choke, which is why the example above uses 'Y-m-d H:i:s O' rather than anything automatic.

Percentages are already converted. SpO₂ arrives as 97.4 with unit %, not 0.974. Multiply by 100 again and you get 9,740 %.

A missing field is not zero. Nights without real sleep stages omit deep, rem and core entirely. The isset() in the example is not decoration: store missing stages as 0 and you paint nights without deep sleep into your dashboard that never happened.

What we cannot know

The moment your receiver answers 200, the delivery counts as delivered — even if your INSERT fails afterwards. So answer only once you have actually written. And whatever happens behind the 2xx is invisible both to you in the app's log and to us.

The full description of what arrives — all twelve metrics, all fields, all units — is in the format reference.

← Back to the start page