Skip to content

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.

Not everything GitHub reports has a date of its own, so there are three treatments and each measurement declares which one it gets.

KindStamped atExample
Datedthe moment the thing happenedgh_star, gh_workflow_run, gh_traffic, gh_commit
Dailythe start of the UTC daygh_traffic_referrer, gh_label, gh_milestone, gh_webhook
Nowthe instant of the sweepgh_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 1757203200000000000
gh_traffic,owner=acme,repo=telemetry,kind=views count=238i,uniques=140i 1757203200000000000
gh_traffic,owner=acme,repo=telemetry,kind=views count=238i,uniques=140i 1757203200000000000

One 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:

Two ways of writing the same fourteen days Two ways of writing GitHub's 14-day traffic window, side by side. On the left, dating as built: 4 sweeps a day, 6 hours apart, each re-read the whole window and stamp every day at that day's own date, so all 4 land on the same 14 rows and the newest count replaces the one before it. After a week the store holds 14 rows per repository. On the right, the same sweeps stamped at the moment of collection: each one adds 14 rows instead of replacing 14, so after a week the store holds 392 rows per repository, 28 copies of every day, and a sum over them reports 28 times the traffic. Dated, as built each sweep re-reads the whole 14-day window 4 sweeps a day, 6 hours apart 00:00 06:00 12:00 18:00 14 days ago today 14 rows after a week, per repository One row per day. The newest sweep replaces the day it re-read. Stamped at collection each sweep writes what it read, again 4 sweeps a day, 6 hours apart 00:00 +14 06:00 +14 12:00 +14 18:00 +14 14 days ago today 392 rows after a week, per repository 28 copies of every day. A sum of the month's views reports 28 times the traffic. a row that is rewritten a row that is added
Two ways of writing the same fourteen days Two ways of writing GitHub's 14-day traffic window, side by side. On the left, dating as built: 4 sweeps a day, 6 hours apart, each re-read the whole window and stamp every day at that day's own date, so all 4 land on the same 14 rows and the newest count replaces the one before it. After a week the store holds 14 rows per repository. On the right, the same sweeps stamped at the moment of collection: each one adds 14 rows instead of replacing 14, so after a week the store holds 392 rows per repository, 28 copies of every day, and a sum over them reports 28 times the traffic. Dated, as built each sweep re-reads the whole 14-day window 4 sweeps a day, 6 hours apart 00:00 06:00 12:00 18:00 14 days ago today 14 rows after a week, per repository One row per day. The newest sweep replaces the day it re-read. Stamped at collection each sweep writes what it read, again 4 sweeps a day, 6 hours apart 00:00 +14 06:00 +14 12:00 +14 18:00 +14 14 days ago today 392 rows after a week, per repository 28 copies of every day. A sum of the month's views reports 28 times the traffic. a row that is rewritten a row that is added

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.

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.

RuleWhat it doesUsed for
keepLastThe most recent point per label set winsSnapshots: gh_repo, gh_account, gh_release
sumEvery point in the batch is added upWindows: views over the fourteen days
countThe points become a count plus the mean of each numeric fieldDated items: pull requests become “how many merged” and “how long they took”
skipNothing is servedHistory 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.

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:

MeasurementWhy
gh_artifactHistory. One row per artifact ever, and none of them moves again
gh_commit_punchcardSize. One series per repository, weekday and hour
gh_commits_weekHistory. The weekly commit series
gh_contribution_dayHistory. The green calendar, one row per day
gh_contribution_day_repoHistory. The same calendar split per repository, which would mint a series per day
gh_job_logText, not a number. It belongs in a log store
gh_package_versionHistory. The publication date of every tag; the count of them is a field on gh_package
gh_release_assetSize. One series per file ever published
gh_traffic_pathHistory. The per-day paths
gh_workflow_stepHistory. 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.

StoreThe dated historyWhy
InfluxDB, PostgreSQL, Graphite, ElasticsearchyesThe timestamp is part of the identity of a row
Telegrafas far as its outputs allowIt forwards the timestamps unchanged; an output that stamps at receipt loses them
File and stdoutyesThe timestamp is in the line
Lokirecent events onlyLoki refuses an entry too far behind the newest in its stream. See Loki
OpenTelemetrythe backend decidesOTLP data points carry an explicit timestamp; whether it is honoured is not up to this tool
PrometheusnoCurrent values only, by the rules above

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.