Dating a point
A point carries the date the thing happened, not the date it was collected.
That is the one rule everything else follows from. A workflow run is stamped when it finished. A star is stamped when it was given. A traffic day is stamped at that day’s own date. A pull request is stamped when it closed. None of them is stamped at the instant the collector noticed.
The three kinds of point
Section titled “The three kinds of point”Not everything GitHub reports has a date of its own, so there are three treatments and each measurement declares which one it gets.
| Kind | Stamped at | Example |
|---|---|---|
| Dated | the moment the thing happened | gh_star, gh_workflow_run, gh_traffic, gh_commit |
| Daily | the start of the UTC day | gh_traffic_referrer, gh_label, gh_milestone, gh_webhook |
| Now | the instant of the sweep | gh_repo, gh_actions_cache, gh_dependabot_alert |
Daily is for a snapshot with no date of its own. GitHub returns the top ten referrers of the trailing fourteen days as one list with no day attached, so it is not a series. Stamping it at the instant of the sweep would write four copies a day and any query that summed them would report four times the traffic. Stamping it at the start of the UTC day means a day’s sweeps rewrite one row.
Now is for something that is genuinely a current state. The size of the Actions cache, the number of open alerts, the inventory of workflows: none of these happened at a moment, so pretending otherwise would be a lie with a timestamp on it.
Why the rule exists: re-collection has to converge
Section titled “Why the rule exists: re-collection has to converge”InfluxDB keys a point by measurement, tag set and timestamp. Three fields, one row. Write the same three again and the row is replaced, not added to.
Written out, two sweeps six hours apart offer the same day twice, and the second replaces the first because the three keys are identical:
gh_traffic,owner=acme,repo=telemetry,kind=views count=220i,uniques=131i 1757203200000000000gh_traffic,owner=acme,repo=telemetry,kind=views count=238i,uniques=140i 1757203200000000000gh_traffic,owner=acme,repo=telemetry,kind=views count=238i,uniques=140i 1757203200000000000One row, carrying the newest number GitHub reported for that day. Stamp those two lines at the moment of collection instead and they are two rows, and every sum over them is wrong by however many sweeps have run.
That is what makes the whole design work. GitHub’s traffic window is fourteen days, and the collector rewrites all fourteen on every sweep rather than trying to work out which day is new:
The same property is what makes a backfill safe to run twice, and what lets you delete the state file without corrupting anything: re-collection rewrites rows it has already written.
What Prometheus structurally cannot hold
Section titled “What Prometheus structurally cannot hold”Prometheus stamps a sample at scrape time. It does not take a timestamp from the producer, and it rejects anything meaningfully older than now.
This was measured rather than assumed. Against Prometheus 3.14, with
--web.enable-otlp-receiver and out_of_order_time_window: 30m, a sample
dated two days back comes back as HTTP 400.
Half of what this collects is older than that on purpose: a star from 2020, a pull request merged in July, yesterday’s traffic. So there is no configuration of Prometheus in which the dated history survives. Widening the out-of-order window moves the boundary; it does not remove it.
The reducer, and what it makes of each measurement
Section titled “The reducer, and what it makes of each measurement”Because the store cannot hold the history, the reduction happens before
Prometheus or an OTLP backend with raw: false ever sees the data.
Summarize gives each measurement one of four rules.
| Rule | What it does | Used for |
|---|---|---|
keep | The most recent point per label set wins | Snapshots: gh_repo, gh_account, gh_release |
sum | Every point in the batch is added up | Windows: views over the fourteen days |
count | The points become a count plus the mean of each numeric field | Dated items: pull requests become “how many merged” and “how long they took” |
skip | Nothing is served | History with no honest current value |
A measurement with no rule is skipped rather than guessed at. That is the safe default, and it is deliberate: without it, a new collector could quietly flood an exporter with one series per star.
count averages each of their numbers except the identifiers. A field that
joins one row to another, run_id, workflow_id, pull_request, number,
stack and the rest, is a name and not a quantity: averaged over a count it
becomes a number shaped exactly like the identifier it is made of and belonging
to nothing, and gh_deployment published run_id_mean that way. Those fields
are left out of the reduction, and so are the markers a point carries only so
that it has a field at all, whose mean is 1.0 for ever.
The reducer also publishes total, a running count of distinct items seen per
series. That is what lets a Prometheus dashboard answer “per day” at all, through
increase() over a monotonic counter, since it has no rows to count.
The ten that are never served
Section titled “The ten that are never served”Ten of the measurements carry skip, so a Prometheus exporter and an OTLP
backend with raw: false never see them. Seven are history, two are size, and
one is text:
| Measurement | Why |
|---|---|
gh_ | History. One row per artifact ever, and none of them moves again |
gh_ | Size. One series per repository, weekday and hour |
gh_ | History. The weekly commit series |
gh_ | History. The green calendar, one row per day |
gh_ | History. The same calendar split per repository, which would mint a series per day |
gh_ | Text, not a number. It belongs in a log store |
gh_ | History. The publication date of every tag; the count of them is a field on gh_package |
gh_ | Size. One series per file ever published |
gh_ | History. The per-day paths |
gh_ | History. The per-step timings |
The two skipped for size are the ones worth knowing about, because they are
real numbers rather than history: measured, together they were four fifths of
the exporter’s entire output. Both are drawn properly by the InfluxDB
dashboard, and gh_release keeps the per-release download counts the assets
were being read for.
Which store keeps what
Section titled “Which store keeps what”| Store | The dated history | Why |
|---|---|---|
| InfluxDB, PostgreSQL, Graphite, Elasticsearch | yes | The timestamp is part of the identity of a row |
| Telegraf | as far as its outputs allow | It forwards the timestamps unchanged; an output that stamps at receipt loses them |
| File and stdout | yes | The timestamp is in the line |
| Loki | recent events only | Loki refuses an entry too far behind the newest in its stream. See Loki |
| OpenTelemetry | the backend decides | OTLP data points carry an explicit timestamp; whether it is honoured is not up to this tool |
| Prometheus | no | Current values only, by the rules above |
Three consequences worth knowing
Section titled “Three consequences worth knowing”A tag is a series, a field is a value. Anything unbounded goes in a field.
The Actions runner name looks like a good tag until you notice a hosted runner
is named uniquely per run (GitHub Actions 1000163135), which would create a
series for every job ever executed. It is a field.
Weekly rows are anchored to the week, not to today. gh_commits_week is
stamped at the Sunday that starts each week. A sweep on Tuesday and one on
Friday have to land on the same row, or every re-read writes a second copy of
the year.
Prometheus reserves some tag names. A tag called job or instance
collides with the scrape labels, and the OTLP receiver overwrites it with the
service name. Workflow jobs are therefore tagged job_name.