PostgreSQL
sinks: sql: dialect: postgres path: /var/lib/ghchronicle/points.sql max_bytes: 67108864 keep: 5INSERT statements in PostgreSQL’s dialect, written to a rotating file or, with
path: "-", to standard output.
ghchronicle -config config.yaml -once | psql "$DATABASE_URL"This is the sink for the Grafana user who self-hosts PostgreSQL or TimescaleDB and runs no InfluxDB. The tool cannot speak the PostgreSQL wire protocol without a driver, and a driver is a dependency this repository does not take, so it emits the SQL and leaves the connection to psql. Grafana’s PostgreSQL datasource then has a real schema to query.
The schema is the contract
Section titled “The schema is the contract”- One table per measurement, named after it:
gh_repo,gh_traffic,gh_workflow_run. time TIMESTAMPTZ NOT NULL, the date the thing happened.- One
TEXT NOT NULL DEFAULT ''column per tag, an empty string where the point had no value. - One column per field, typed from the value:
BIGINTfor an integer,DOUBLE PRECISIONfor a float,BOOLEAN,TEXT, andTIMESTAMPTZfor a field that is itself a time. PRIMARY KEY (time, <tag columns in name order>).- Every identifier is double-quoted, because
user,typeandstateare tag names here and reserved words there.
That primary key is InfluxDB’s series key spelled as a constraint, and it is what makes a rewrite of the fourteen-day traffic window converge instead of accumulate.
CREATE TABLE IF NOT EXISTS "gh_traffic" ("time" TIMESTAMPTZ NOT NULL, "full_name" TEXT NOT NULL DEFAULT '', "kind" TEXT NOT NULL DEFAULT '', "owner" TEXT NOT NULL DEFAULT '', "repo" TEXT NOT NULL DEFAULT '', "count" BIGINT, "uniques" BIGINT, "url" TEXT, PRIMARY KEY ("time", "full_name", "kind", "owner", "repo"));INSERT INTO "gh_traffic" ("time", "full_name", "kind", "owner", "repo", "count", "uniques", "url") VALUES ('2026-09-07T00:00:00Z'::timestamptz, 'acme/telemetry', 'views', 'acme', 'telemetry', 41, 12, 'https://github.com/acme/telemetry/graphs/traffic') ON CONFLICT ("time", "full_name", "kind", "owner", "repo") DO UPDATE SET "count" = EXCLUDED."count", "uniques" = EXCLUDED."uniques", "url" = EXCLUDED."url";How the declarations arrive
Section titled “How the declarations arrive”The CREATE TABLE IF NOT EXISTS is emitted the first time a measurement is
seen in a file, with the union of the columns that batch carries. A column that
turns up in a later batch arrives as ALTER TABLE ... ADD COLUMN IF NOT EXISTS.
A rotated file starts its declarations again, so any one file can be replayed
on its own.
A tag first seen after the table was declared cannot join the primary key without rewriting it, so it becomes a plain column. That only happens when a collector changes its tag set between sweeps.
TimescaleDB
Section titled “TimescaleDB”Turn each table into a hypertable once it exists. The primary key already
includes time, which is the one condition TimescaleDB puts on it.
SELECT create_hypertable('gh_traffic', 'time', if_not_exists => TRUE);SELECT create_hypertable('gh_workflow_run', 'time', if_not_exists => TRUE);Nothing in the dashboard changes.
Setting it up
Section titled “Setting it up”-
Point the sink at a file, or at standard output for a direct pipe.
-
Load it.
Terminal window psql "$DATABASE_URL" -f /var/lib/ghchronicle/points.sqlOr, for the streaming arrangement, run the collector with
-oncefrom a scheduler and pipe it straight in. -
Point Grafana’s PostgreSQL datasource at the database and import
ghchronicle-postgres.json.SELECT time, "count" FROM gh_traffic WHERE kind = 'views' AND repo = $repo
dashboards/ghchronicle-postgres.json has the same 152 panels as the InfluxDB
one, with every query translated to PostgreSQL against this schema.
Where to go next
Section titled “Where to go next”- Choosing a store compares PostgreSQL with the other nine, and holds the write ledger every one of them shares.
- The dashboards says which of the five is drawn against which store, and what a panel a store cannot answer becomes.