Skip to content

PostgreSQL

sinks:
sql:
dialect: postgres
path: /var/lib/ghchronicle/points.sql
max_bytes: 67108864
keep: 5

INSERT statements in PostgreSQL’s dialect, written to a rotating file or, with path: "-", to standard output.

Terminal window
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.

  • 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: BIGINT for an integer, DOUBLE PRECISION for a float, BOOLEAN, TEXT, and TIMESTAMPTZ for a field that is itself a time.
  • PRIMARY KEY (time, <tag columns in name order>).
  • Every identifier is double-quoted, because user, type and state are 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";

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.

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.

  1. Point the sink at a file, or at standard output for a direct pipe.

  2. Load it.

    Terminal window
    psql "$DATABASE_URL" -f /var/lib/ghchronicle/points.sql

    Or, for the streaming arrangement, run the collector with -once from a scheduler and pipe it straight in.

  3. 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.

  • 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.