Skip to content

Measurements

Ninety-one measurements. Each row says how a point is dated, because that is the thing that decides which questions it can answer.

DatingMeans
datedThe point carries the moment the thing happened, so the history is real and re-collecting rewrites the same rows
dailyA snapshot with no date of its own, stamped at the start of the UTC day so a day’s sweeps converge on one row rather than piling up
nowA current state, which only makes sense as “what is true at this moment”

Every measurement carries owner, repo and full_name as tags unless it is account-wide, in which case it carries user. Most of them also carry a url field: the page on GitHub for the thing the row is about, so a dashboard row that names an item can also open it. A url is absolute or absent, since the dashboards link to the value itself, and every measurement that has one is linked by unit from at least one table, except the eight that only ever draw as a curve or a bar (gh_pull_request_review, gh_workflow_job, gh_event, gh_issue_event, gh_artifact, gh_contribution_day, gh_contribution_day_repo and gh_commit_check), where a per-item url has no row to sit on.

A tag GitHub leaves empty is written as (none), one spelling on every measurement, and the same (none) goes into a few string fields that say nothing on most rows, a pull request’s decision or an issue’s assigned_to: InfluxDB 3 creates a column the first time a row carries it, and a query naming a column no row has written fails outright, so those are written on every row. The same convention writes (ghost) where a login belonged to an account that has since been deleted.

The parentheses are the point of the spelling. GitHub answers unknown itself in a Dependabot alert’s relationship, and none is a value in more than one of its enums, so a fallback spelled either way could not be told from an answer; (none) is never a value GitHub returns. One value does read none without them and means it: gate on gh_commit is the state of the commit’s gate, and none is the state of a commit no gate ever ran on, beside SUCCESS and FAILURE.

A value that moves after the row’s own date is a field, never a tag. A tag is part of a row’s identity, so a tag that changes after the fact opens a second series at the same instant and the stale row stays beside the new one for ever: measured after eleven hours of sweeps, one artifact in fifty had a row with expired=false and another with expired=true at the same timestamp, and a commit seen PENDING by one sweep and FAILURE by the next was counted twice. Every such tag is a field now, under a new name because InfluxDB 3 fixes a column as tag or field at first write: gh_artifact.expired is live, gh_commit.checks is gate, state on both alert items is alert_state and the code scanning reason is resolution, gh_issue’s state_reason, assignee, milestone and parent are resolution, assigned_to, milestone_title and parent_issue, gh_pull_request.draft and review_decision are is_draft and decision, gh_discussion.answered is has_answer, gh_pull_request_review.state is review_state, gh_deployment.state is outcome and gh_notification.unread is is_unread. state on a pull request, an issue and an external contribution stays a tag because its date moves with it: the open row is stamped at the start of the day and the closed row when it closed. The Prometheus exporter reads the demoted values back as labels; Graphite, which keeps no strings, cannot group by them and its panels say so.

Every row here is a table in the store, its tags are columns you filter and group by, and its fields are the numbers. Read against InfluxDB 3 in SQL mode, the three datings turn into three shapes of query.

A dated measurement is history, so it is read over a range:

SELECT time, "count" FROM gh_traffic
WHERE kind = 'views' AND repo = 'telemetry' AND time > now() - INTERVAL '90 days'

A daily snapshot is one row per day, so the newest row is the answer and the difference between two days is the movement:

SELECT time, downloads FROM gh_release_asset
WHERE asset = 'ghchronicle_linux_amd64.tar.gz' ORDER BY time DESC LIMIT 30

A dated item carries one row per thing that happened, which is what lets a question be asked of the items rather than of a count:

SELECT date_trunc('week', time) AS week, count(*) AS merged,
avg(seconds_to_merge) / 3600 AS hours
FROM gh_pull_request WHERE state = 'MERGED' GROUP BY week ORDER BY week

The same three shapes work in the other history stores; the dashboards carry one query set per store for every panel, which is the place to copy from.

Ninety-one, each link landing on the table it is in.

gh_account · gh_account_total · gh_achievement · gh_achievement_progress · gh_actions_cache · gh_actions_cache_entry · gh_actions_policy · gh_artifact · gh_artifact_total · gh_billing_usage · gh_branch · gh_branch_protection · gh_code_scanning_alert · gh_code_scanning_alert_item · gh_code_scanning_analysis · gh_code_scanning_setup · gh_commit · gh_commit_check · gh_commit_punchcard · gh_commits_week · gh_contribution_day · gh_contribution_day_repo · gh_contribution_repo · gh_contribution_year · gh_contributions_total · gh_dependabot_alert · gh_dependabot_alert_item · gh_dependabot_ecosystem · gh_dependency · gh_dependency_change · gh_dependency_license · gh_deploy_key · gh_deployment · gh_discussion · gh_discussion_comment · gh_environment · gh_event · gh_external_contribution · gh_fork · gh_gist · gh_issue · gh_issue_comment · gh_issue_event · gh_job_log · gh_key · gh_label · gh_milestone · gh_notification · gh_package · gh_package_version · gh_pinned_item · gh_policy_file · gh_profile_flag · gh_pull_request · gh_pull_request_review · gh_rate_limit · gh_release · gh_release_asset · gh_repo · gh_repo_activity · gh_repo_archived · gh_repo_community · gh_repo_created · gh_repo_language · gh_repo_policy · gh_repo_topic · gh_repo_total · gh_review_thread · gh_ruleset · gh_ruleset_rule · gh_ruleset_version · gh_secret · gh_security_feature · gh_security_setting · gh_social_account · gh_sponsors_listing · gh_sponsors_tier · gh_sponsorship · gh_star · gh_star_given · gh_star_list · gh_traffic · gh_traffic_path · gh_traffic_referrer · gh_webhook · gh_webhook_delivery · gh_workflow · gh_workflow_job · gh_workflow_run · gh_workflow_run_total · gh_workflow_step

MeasurementDatedTagsFields
gh_trafficdated, one point per daykind (views, clones)count, uniques, url
gh_traffic_referrerdailyreferrercount, uniques, url, referrer_url
gh_traffic_pathdailypathcount, uniques, title, url

GitHub serves fourteen days and the whole window is rewritten on every sweep, so a collector that was down for a day repairs itself on the next run. The referrers and paths are the top ten of that same window with no dates attached, which is why they are a snapshot rather than a series.

MeasurementDatedTagsFields
gh_stardated, when the star was givenuserstarred, url, user_url
gh_star_givendateduser, repo, languagestars, repo_stars, url
gh_forkdated, when the fork was createdbyforks, stars, days_since_push, advanced, url

gh_star_given is the outbound direction: what this account starred in other people’s repositories. advanced on a fork separates a real derivative from a bookmark, which most forks are.

MeasurementDatedTagsFields
gh_reponowlanguage, visibility, license, archived, fork, default_branchstars, forks, watchers, open_issues, size_kb, age_days, days_since_push, days_since_config_change, network, repo_id, is_template, has_pages, web_commit_signoff_required, allow_update_branch, pull_request_creation_policy, url
gh_repo_languagenowlanguagebytes
gh_repo_topicnowtopicpresent, url
gh_repo_communitynowhealth_percentage, url, has_readme, has_license, has_contributing, has_code_of_conduct, has_issue_template, has_pull_request_template
gh_repo_archiveddated, when the repository was archivedarchived, age_days_at_archive, url
gh_releasenowtag, draft, prereleasedownloads, assets, age_days, url
gh_release_assetdailytag, assetdownloads, size_bytes, digest, content_type, uploader, age_days, url

open_issues is GitHub’s field and GitHub counts pull requests in it. Use gh_issue to count issues.

The url on gh_release_asset is the asset’s download address, not a page: following it fetches the binary. The assets are inventory, anchored to the start of the UTC day like the cache entries: stamped at the sweep, every asset was a fresh row every hour, which was 15 per cent of the whole database after eleven hours. One row per asset per day still answers “downloads per day”, and the newest row is still the value.

gh_repo_archived is the one row about a repository that carries a date rather than a state: dated at archivedAt, a clear-out is visible as the batch it was, and a live repository produces no row at all, so counting the rows is counting the archive. It does not need include_archived. The listing a sweep already pays for says which repositories are archived, and the totals family asks the date of all of them in one GraphQL query of four scalars per repository, on every totals sweep: one point at that cadence, and the rows it rewrites are the same rows, which is what an exporter that keeps only what is rewritten needs; the listing cannot supply the date itself, since REST carries no archived_at and its updated_at was measured two seconds to eight minutes after the archive. An archived fork under the default fork rule is the one kind with no row.

MeasurementDatedTagsFields
gh_pull_requestdated when closed, daily while opennumber, state, authoris_draft, decision, title, labels, label_names, author_association, additions, deletions, churn, changed_files, commits, comments, total_comments, reviews, review_requests, review_threads, base_ref, head_ref, merged_by, merge_commit, mergeable, merge_state, stack, stack_size, stack_position, seconds_to_first_review, seconds_to_first_human_review, seconds_to_merge, seconds_open, url
gh_pull_request_reviewdated, when submittednumber, author, reviewer, bot, selfreview_state, reviews, seconds_to_review, url
gh_issuedated when closed, daily while opennumber, state, authorresolution, assigned_to, milestone_title, parent_issue, comments, reactions, labels, label_names, sub_issues_total, sub_issues_completed, pull_request, seconds_to_close, seconds_open, url
gh_commitdated, when committedsha, author, branch, signaturegate, additions, deletions, churn, changed_files, commits, signed, oid, headline, url, pull_request, checks_total, checks_failed
gh_commit_checkdated, when the check finishedsha, app, check, conclusionchecks, failed, url
gh_issue_eventdated, when it happenedevent, actor, kind, bot, label, milestone, requested_reviewer, review_requester, mentionedevents, number, title, url, commit_id, rename_from, rename_to
gh_review_threaddated, when the thread’s first comment was writtenthread, number, author, botpath, comments, resolved, outdated, subject_type, resolved_by
gh_discussiondated, when createdcategory, answerable, author, numberhas_answer, comments, replies, reactions, upvotes, closed, state_reason, seconds_to_answer, seconds_to_close, title, url
gh_labeldailylabelissues, pull_requests, used, url
gh_milestonedailymilestone, stateprogress, issues, pull_requests, days_to_due, seconds_to_close, url
gh_external_contributiondateduser, repo, number, kind, statecontributions, merged, title, comments, seconds_to_merge, seconds_open, url

gh_commit is what replaces stats/code_frequency, which returns 202 with an empty body forever on a personal account. signature is unsigned when there is no signature at all, which is a different fact from one that failed to verify.

gate is the state of the whole gate on that commit, which is not the same claim as a workflow run having failed: a run says one job failed, the rollup says the commit came out red. It is a field because the verdict lands after the commit’s own date. gh_commit_check holds only the checks that are not GitHub Actions, since everything Actions runs is already collected in far more detail.

seconds_to_first_review counts any review, and on an account with review bots that is the bot: measured over 140 pull requests, its median was five seconds, because 132 were first reviewed by sourcery-ai or coderabbitai within a minute of opening. seconds_to_first_human_review is the wait for somebody else, over the first twenty reviews the query fetches: not a bot, and not the author. The author’s reply in a review thread arrives as a review of state COMMENTED under their own name, and on this account it was the earliest non-bot review on every one of the 91 pull requests that had one, so a wait that counted it measured how fast the owner answers sourcery-ai. A pull request whose fetched reviews are all bots and the author’s own replies carries no such field rather than a wrong one. A bot is a GitHub App (__typename Bot) or a login ending in [bot]; a deleted account is not one. gh_pull_request_review.bot draws the same line per review and self marks the author’s own, so a reviewers table can leave both out or show them apart; bot is what gh_review_thread.bot already does for threads.

title, label_names and author_association are fields because a title is unbounded and nine labels on one pull request are one row, not nine series. labels is the count and label_names the names joined by commas, absent when there are none, on pull requests and issues alike. author_association is OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR or NONE: what separates an outside contribution from the owner’s own work.

mergeable and merge_state are written only while a pull request is open. A merged one keeps answering CONFLICTING long after it was merged, which is stale rather than false but reads as a repository full of conflicts. A sweep reads only what was updated in the last two cadences, so an open pull request nobody touches has its seconds_open, mergeable and merge_state rewritten once a day by the whole-page read rather than every hour; anything that moves updatedAt, a review, a comment, a push, a close, is rewritten by the sweep that follows it.

stack, stack_size and stack_position describe a stack of dependent pull requests and are absent on a pull request that is in none. stack is the stack’s own number, not a member’s, so the honest way to count deliveries is distinct stack values plus the rows carrying no stack fields at all. review_requests and review_threads are the two counts a reviewing flow is measured with, and total_comments counts every comment on the pull request rather than the ones in comments, which are the ones on the conversation.

sub_issues_total and sub_issues_completed are how far an epic has got, from the checklist GitHub keeps on the parent. parent_issue is the other end of the same relation, on the child, and is 0 on an issue with no parent. pull_request is the pull request that closed the issue, 0 when none did.

gh_issue_event is the transition rather than the state. gh_issue and gh_pull_request say what something ended up as; this says when it was labelled, closed, reopened, renamed or had a review requested. A reopening exists nowhere else. mentioned is the person a mentioned or subscribed event happened to, which GitHub files as the actor without saying who wrote the comment; here that person has a tag of their own and actor reads (none) on those two types, so the account named in “@coderabbitai” no longer shares a column with the app that reviews. An app is spelled the way REST spells it, with the [bot] suffix, on every measurement.

gh_discussion counts comments and replies apart: comments answer the discussion, replies answer those, and GitHub’s own number on the page is the two added together.

MeasurementDatedTagsFields
gh_workflow_rundated, when it finishedworkflow (the file path), event, conclusion, actorduration_seconds, queued_seconds, attempt, success, run_id, run_number, pull_request, pull_requests, headline, head_repo, head_sha, head_branch, name, title, workflow_id, initial_actor, url
gh_workflow_run_totalnowruns
gh_workflow_jobdated, when it finishedworkflow, job_name, attempt, conclusion, runner_group, labelsduration_seconds, queued_seconds, steps, success, runner, run_id, head_sha, head_branch, url
gh_workflow_stepdated, when it finishedworkflow, job_name, attempt, step, conclusionduration_seconds, step_number
gh_workflownowworkflow, path, stateactive, age_days, days_since_change, url
gh_artifactdated, when createdartifactlive, size_bytes, retention_days, digest, run_id, head_sha, head_branch, url
gh_artifact_totalnowlive_bytes, count, walked
gh_actions_cachenowsize_bytes, count
gh_actions_cache_entrydailycache, refsize_bytes, caches, key, days_since_use, age_days
gh_repo_activitydatedactivity, actorevents, id, ref_name

branch was a tag on gh_workflow_run and on gh_artifact, and is now the field head_branch on both, and on gh_workflow_job as well. A branch is an identity, but not a reusable one: every pull request and every Dependabot bump mints a name that never comes back, so the tag grows without bound, and the bounded question a reader actually asks, whether this was a push or a pull request, is already the event tag. InfluxDB 3 also fixes a column as a tag or a field the first time it sees it and refuses every later write that disagrees, so keeping the name would have meant dropping both tables to publish a value the API itself calls head_branch.

attempt is a tag on the job and on the step because the job listing is now asked for every attempt rather than the last one. Without it the two tries of a re-run are one series, told apart only by the second they finished in, and a flaky test cannot be distinguished from a broken one.

queued_seconds on a run is written on first attempts only. GitHub keeps the run’s created_at across re-runs, so on a second attempt the gap to run_started_at is the time a person took to press the button, not a runner queue: 0.4 s on average on first attempts against 1,747 s on second ones, measured. The queue of a retry exists only per job.

run_number is the “#1483” GitHub shows and people quote; run_id is what the API keys by. pull_request is the number of the first pull request GitHub linked to the run and pull_requests how many it linked, both absent when it linked none. headline is the first line of the commit that ran, and head_repo is written only when the run came from another repository, which is what a fork’s pull request looks like.

gh_workflow_run_total is the run listing’s own total_count, which is the whole history rather than the few hundred runs the walk sees. It is current state, so it is stamped now, and it is the only place “how many runs ever” can be answered without scanning the table.

An ordinary sweep asks for the run list in pages of thirty rather than a hundred. The page is thirteen kilobytes a run, of which the collector keeps six hundred bytes, and at a hundred runs it was a megabyte and a half per active repository every quarter of an hour, forty six percent of everything a day downloads. The stores lose nothing: the walk still pages on while a page is full of runs newer than the window, up to seven pages, which is the two hundred and ten runs two pages of a hundred reached, and the first sweep after start and a backfill still ask for a hundred. What changes is the Prometheus exporter, which holds only what the last sweep collected and shows the newest thirty runs between builds rather than the newest hundred.

The jobs of a run are listed once. The jobs of a completed attempt never change, and listing them again every sweep was a request per run in the window, nearly all of them 304s that cost no quota but a third of a second of waiting each, ninety six times a day. The collector remembers each attempt whose jobs it wrote, in memory like the ETag cache, so after a restart the first sweep lists the newest twenty per repository once and then asks only for new runs and new attempts; a backfill lists every run regardless. A re-run keeps the run’s id and is a new attempt, so it is listed again. The cap of twenty bounds what a sweep pays, not which runs get jobs: a window with more runs than that fills in twenty a sweep. A run is remembered only once the sweep that listed it succeeded, because the runner keeps nothing of a collector that failed partway.

retention_days is the retention an artifact actually got, which is rarely the configured default: eighty-eight of a hundred artifacts measured lived one day against a setting of ninety.

gh_repo_activity is one row per activity type, actor and second. The branch is the field ref_name, one name per pull request and per Dependabot bump, and without it in the key the branches one push moved in the same second would be one row in every store, the last one written standing for all of them: eight force pushes in one second, measured. The entries that share a key are folded into one point, events counting them, ref_name naming every branch joined by commas, id the newest entry’s, so a sum of events is the number of activities everywhere.

Queue time only exists at the job level. The run-level figure folds the wait into the duration, and the job-level one includes waiting for a dependency, so a job that waits nineteen minutes for another job to finish is not evidence of a runner shortage.

runner is a field, not a tag: a hosted runner is named uniquely per run, so as a tag it would create a series for every job ever executed. The workflow job tag is job_name rather than job, because job collides with the labels Prometheus adds at scrape time.

When walked is lower than count, the live size is a floor and the repository has more artifacts than the page cap reached.

gh_actions_cache says a repository holds twelve gigabytes; gh_actions_cache_entry says which key holds them and which has not been touched for a week, which is what decides what GitHub evicts at the ten gigabyte ceiling. The tag is the key without its content hash, because the whole key is a series per build.

MeasurementDatedTagsFields
gh_dependabot_alertnowseverity, ecosystemopen, url
gh_dependabot_alert_itemdated, when raisednumber, severity, ecosystem, package, ghsa, scope, relationship, manifestalert_state, alerts, cvss, cvss_v4, epss, epss_percentile, cve, cwe, summary, vulnerable_range, first_patched, dismissed_reason, dismissed_by, dismissed_comment, seconds_to_detect, seconds_to_resolve, seconds_open, url
gh_code_scanning_alertnowseverity, toolopen, url
gh_code_scanning_alert_itemdated, when raisednumber, severity, tool, rule, path, category, refalert_state, resolution, alerts, commit, line, cwe, seconds_to_resolve, seconds_open, url
gh_code_scanning_analysisdated, when the scan rantool, version, ref, categoryanalyses, results, rules, commit
gh_security_featurenowfeatureenabled, open_alerts, alerts, url
gh_security_settingnowsetting, statusenabled
gh_code_scanning_setupdailystate, query_suite, schedulesetups, languages, days_since_change
gh_secretdailykind (actions, dependabot), secretsecrets, age_days, days_since_rotation
gh_actions_policydailypermissionspolicies, can_approve_pr

The tags on the two item measurements cost nothing: both listings were already carrying them, and the series was always keyed by number, so they group rows that exist one per alert rather than multiplying them. All of them are always written, falling back to (none) when GitHub omits one. A tag written only sometimes gives the measurement two Graphite path depths, and the panels index their nodes from one fixed table. alert_state on both items and resolution on the code scanning one are fields, since an alert is dated when it was raised and both move when it closes; resolution reads open until then, so the column exists before any alert has closed.

The fields are the opposite: cve, cwe, first_patched, epss, epss_percentile and seconds_to_detect are written only when the advisory carries them, because a missing EPSS score is not a score of zero. cvss and cvss_v4 need the same guard for a different reason: GitHub always sends both keys and fills the one it lacks with 0.0, and an advisory published with a v4 vector only was 78 of the 225 alerts of one repository, enough for “worst CVSS” over a severity group of them to read zero. Neither score is written unless it is above zero; a panel that wants one number per alert reads COALESCE(cvss_v4, cvss).

summary is the advisory’s title, vulnerable_range the range it covers, which next to first_patched is the action to take, and dismissed_reason, dismissed_by and dismissed_comment say why a person closed an alert without fixing it; they exist only on an alert in state dismissed.

seconds_to_detect is the gap between the advisory being published and the alert being raised here. It is negative when the alert came first, which happens when an advisory is written up after the fact.

The two cwe fields cannot be joined. Dependabot writes CWE-400 and code scanning writes cwe-079, both GitHub’s own spelling, and neither is normalised here. line is the start line of the alert’s most recent instance, and its zero is GitHub’s own value for an alert about a whole file, not a missing reading.

A Dependabot alert closes three ways, not two: auto_dismissed_at is how GitHub closes a development-dependency alert on its own, leaving the other two null. An alert closed that way used to grow seconds_open forever.

gh_security_feature exists so that no data and no alerts are distinguishable. Without it, a repository with Dependabot switched off looks exactly like one with nothing to fix. enabled is read from the first full page of the listing, which answers 403 when the feature is off and, for code scanning, 404 when nothing has been analysed yet. alerts counts what the sweep read, which is one page, so at most a hundred, rather than the repository’s total.

MeasurementDatedTagsFields
gh_accountnowfollowers, following, following_users, public_repos, gists, packages, projects, starred, watching, sponsors, sponsoring, account_age_days, pronouns, url
gh_contributions_totalnowcalendar_total, commits, pull_requests, reviews, issues, repositories, restricted, repos_with_commits, repos_with_issues, repos_with_pulls, repos_with_reviews, url
gh_contribution_daydated, one point per calendar daycontributions, level, url
gh_contribution_yeardated, end of the year; the year in progress dailyyearcontributions, commits, issues, pull_requests, reviews, repositories, restricted, repos_with_commits, repos_with_issues, repos_with_pulls, repos_with_reviews, partial
gh_contribution_reponowrepo, kind (commits, issues, pulls, reviews)contributions, commits, days, commits_dated, url
gh_contribution_day_repodated, the day the commits belong torepo, private, owncommits, url
gh_commits_weekdated, the Sunday of its weekcommits, owner_commits
gh_commit_punchcardnowweekday, hourcommits
gh_packagenowpackage, type, visibility, repoversions, tagged_versions, age_days, days_since_update, url
gh_package_versiondated, when publishedpackage, type, visibility, repo, tagdigest, published, url
gh_gistnowgist, publicfiles, comments, size_bytes, description, url, age_days, days_since_update
gh_achievementdailyachievementname, tier_number, tier_name, present, image, url
gh_achievement_progressdailyachievementname, count, tier_number, next_threshold, percent, page_tier, agrees, image, url
gh_social_accountnow; the orcid row dailyproviderurl, present
gh_pinned_itemnowrepopinned, position, kind, stars, days_since_push, url
gh_profile_flagnowflagenabled, message, age_days, url
gh_sponsorshipdated, when the sponsorship was madedirection (sponsor, maintainer), sponsorablesponsorship, active, one_time, privacy, tier, amount_cents, url
gh_sponsors_listingnowhas_listing, listing_name, listing_public, listing_age_days, tiers, monthly_income_cents, next_payout_cents, next_payout_date, sponsor_spend_cents, lifetime_received_cents, sponsorships_received, goal_kind, goal_title, goal_target, goal_percent, url
gh_sponsors_tierdailytiertiers, price_cents, one_time, retired, age_days, url
gh_star_listdailylistlists, items, private, name, age_days, days_since_add, url
gh_account_totalnowpulls_opened, pulls_merged, pulls_open_now, pulls_merged_elsewhere, pulls_reviewed, issues_opened, issues_closed, issues_elsewhere, commented_elsewhere, commits, repositories, url
gh_repo_createddated, when createdrepo, forkcreated, private, url
gh_keydailykind (ssh, gpg), keykeys, age_days, days_since_use, never_used, days_to_expiry, verified, revoked, can_sign, emails, url
gh_discussion_commentdatedrepo, own, is_answer, is_reply, author, comment, numbercomments, answers, upvotes, title, reply_to, discussion_answered, discussion_answerable, discussion_closed, answered_by, answer_chosen_by, state_reason, category, seconds_to_answer, seconds_to_close, url
gh_issue_commentdatedrepo, own, numbercomments, url

following is the profile’s own number, and it counts organisations as well as people. GraphQL’s following connection counts only users, which on this account read four where the profile read nine, so more than half of it was invisible. Both are kept: following is what the profile page shows, following_users is the connection’s people-only count. When the profile request fails the connection’s count fills both, which is the tell that the profile was not reached.

versions on a package is the count the package element declares, with the walked count as the fallback. tagged_versions counts named releases only. It used to count every container tag, and about half of those are the OCI referrers fallback tag GitHub publishes for each attestation and signature manifest: sha256- followed by the digest the row already carries. Nobody pulls one, there is a fresh one on every build, and excluding them halved the count on two packages, from a hundred and twenty six to fifty seven on one. gh_package_version no longer writes a row for one either.

gh_pinned_item and gh_profile_flag are the profile page itself as data. A pin has no date of its own, so both are stamped now. position is a field and not a tag: a repository that moves from slot two to slot three is the same pin, and as a tag every rearrangement would fork the series. flag is a closed list of eight: hireable, developer_program, campus_expert, github_star, bounty_hunter, employee, sponsors_listing, which is whether the account has a Sponsors profile at all, and limited_availability, which is the availability status carried as an eighth flag rather than a measurement of its own, with the message it displays and the age_days since that status was set. age_days is written on that row alone and is the age of the status message, not of the flag; GitHub says when none of the other flags was granted, so no row carries a date for them.

gh_sponsorship is the only dated record of the money. gh_account.sponsors and gh_account.sponsoring are counts as of now that say neither when nor to whom, and gh_sponsors_listing.lifetime_received_cents is a total with no dates in it. Both connections are read with activeOnly off, which is what recovers a lapsed one. sponsorable is the other party, and it is the literal word private when the sponsorship hides it, in which case no URL is written rather than one being guessed at.

gh_sponsors_tier is standing inventory, the way an SSH key is. Dating a tier at its creation would put all eight of them in 2021, outside every dashboard range, where they would read as “no tiers”; anchored to the start of the UTC day they converge on one row per tier per day, and age_days keeps the creation date recoverable.

gh_star_list is the same shape for the same reason: the lists the account files its stars into, one row per list with how many it holds, anchored to the start of the UTC day. A list carries two dates, when it was made and when a star last went into it, and both survive as age_days and days_since_add rather than dating the row, which would put a list made in 2024 outside every dashboard range. The tag is the slug, which the list’s page is addressed by; the display name is a field. Whether the slug outlives a rename is not verified, since checking it means renaming a list. It rides in the account query that was already being paid for: measured on 2026-09-11, eleven lists with their item counts added nothing to a cost of one.

gh_contribution_day is the only place the green squares exist as data. With every.history set, it reaches back to the year the account was created, at one GraphQL point per year. Its level is the square’s shade, GitHub’s own quartile of the year as the 0 to 4 the profile draws, which is not a function of the count: on one account 83 contributions on one day and 52 on another were both the second quartile. The quartile is of the window asked for, the trailing twelve months for the sweep and the calendar year for history, so a day both write can change shade between the two, as it does on the profile when a year is picked. Which is why the dashboard’s grid shades a day by its own count instead: measured on 2026-09-14, the profile page shades by the fifths of the busiest day of the window, a rule that reproduced all 366 of its squares from GitHub’s own counts, where level disagreed with the page on 33 of those days.

gh_achievement is the one measurement that does not come from the API. GitHub lists achievements nowhere in REST or GraphQL, so the family reads the public profile page, https://github.com/<login>?tab=achievements, once a day as an anonymous visitor: no token travels to it and it is charged to no budget. One row per badge, stamped at the start of the UTC day: name is the badge, tier_number is the number on its label (1 with no label, 2 to 4 for x2 to x4) and tier_name the colour that goes with it (default, bronze, silver, gold); the number is not called tier because Elasticsearch maps a field name once across every measurement’s index and tier is already a string on sponsorships. The parser is strict about the markup it accepts and holds each part of a card to the others, so when GitHub changes the page the family logs one warning and writes nothing until the parser is updated; the rows it wrote before stay, and a panel reading the newest row per badge goes stale rather than wrong. image is the badge image the page shows at that tier, for a panel to draw.

The site the page is read from is derived from github.base_url. A base_url that is a proxy in front of the API has to name the site with github.web_url, because the API host answers the page’s url with a JSON 404, and the family refuses that rather than reading it as no badges.

An account with no badge at all has no achievements tab: its url answers 404 while the profile answers 200, which is no rows and no warning, the same reading every family gives a 404. A 200 with no card in it is refused as a changed page rather than read as none.

gh_achievement_progress is written by the same family beside the badges: one row per badge that has tiers (Pull Shark, Galaxy Brain, Starstruck, Pair Extraordinaire), whether or not the page shows it yet, saying how far the account is from the next tier. GitHub publishes neither the rule a badge is earned by nor the count it has reached, so the count is recomputed from the API and the thresholds are the community’s, the Tiers table of Schweinepriester/github-profile-achievements as read on 2026-09-12: Pull Shark counts merged pull requests anywhere and its tiers begin at 2, 16, 128 and 1024; Galaxy Brain counts the discussions whose accepted answer the account wrote, at 2, 8, 16 and 32; Starstruck takes the stars on the most starred repository of the account’s own, forks left out, at 16, 128, 512 and 4096; Pair Extraordinaire counts merged pull requests in public repositories with a co-authored commit, one per pull request, at 1, 10, 24 and 48, cross-checked the same day against a hand count (the two counts differed by two, the difference falling in a range where the hand count ran past the thousand results a search pages, and the page showed the same tier either way; the co-authored pull requests of a private repository moved nothing). The single-tier badges and the two GitHub is still testing have no row: there is no next tier to measure against. tier_number is the tier the count implies (0 below the first threshold), page_tier the tier the profile page shows (0 when the badge is not on it) and agrees whether the two are the same; when they are, next_threshold is where the next tier begins (0 at the top) and percent the count against it (100 at the top). A row that disagrees is a rule the page contradicts, or a page GitHub has not recomputed yet, said once per process in the log, and it carries neither field, so no bar is drawn from a rule the page contradicts. Three of the counts are one GraphQL query; the fourth is a walk over the account’s merged pull requests in public repositories with their commit messages, split by merge date where a range holds more than the thousand results a search will page, one point a page: a few dozen points and about a minute for the day over a whole account’s life. A count the API would not give is a day without progress rows, never a day without badges.

gh_social_account carries one more row than the social accounts listing: the homepage, under the provider website, from the blog of the profile. The ORCID iD the profile page shows is in no endpoint, so the achievements family, which already reads that page once a day, writes it from the page’s vcard under the provider orcid, stamped at the start of the UTC day like the badges. The links the API does list (Mastodon, LinkedIn, Bluesky) are written by the profile family from the API and skipped on the page, so no account is written twice. The read is as strict as the badges’: a page without the vcard is a changed page and one warning, never “no accounts”.

pronouns is the profile’s pronouns line, he/him, as a field on the headline row, absent when the profile shows none. A field and not a tag: it is free text the owner can edit, and as a tag every edit would fork the account’s one series.

gh_issue_comment and gh_discussion_comment are read from the newest end of their connections, which list oldest first: a sweep’s one page is the hundred newest comments, and a comment that became the accepted answer after it was first written is seen again on the next sweep.

gh_contribution_year has one row per past year, dated the thirty-first of December, and one for the year in progress, asked for on every run from the first of January to now. That row is a snapshot, stamped at the start of the UTC day and marked partial, so a panel comparing years can tell a bar that is still growing from one that is finished; read it as the newest row per year. The first run of the next year replaces it with the final row dated December.

gh_account.packages is counted from the REST listings the profile family walks, not from GraphQL’s packages connection, which does not see the container registry and answered 0 for an account whose four packages are all containers. If a listing fails the GraphQL count stands.

gh_account_total is the answer to “how many ever”. Every other measurement here is a row per fact, which is the right shape for “how many in July” and the wrong one for a lifetime count. GitHub counts them itself, in one search request each, so the number is one row and is right on the first sweep of a fresh install.

gh_dependency_change writes a row on every sweep of the deps family, not only when a dependency moved: a range with no change, a head that did not move and the first sweep, which has no base yet, each write one row with change and ecosystem at (none) and both counters at zero. InfluxDB 3 creates a table at its first point and answers a query naming a table it has not seen with an error, so a measurement written only on a change did not exist until the first bump, and the panel over it was an error until then. The zero row costs no request and the panels leave the (none) series out.

The three measurements about other people’s repositories, gh_discussion_comment, gh_issue_comment and gh_repo_created, exist because a sweep over one’s own repositories cannot see any of it. Each carries own so the two can be told apart.

MeasurementDatedTagsFields
gh_eventdatedtype, repo, action, ref_typeevents, public, commits, url
gh_notificationdated, last updatereason, repo, private, subject_typeis_unread, notifications, title, url

Both are windows, not histories. GitHub keeps the last three hundred events whatever their dates and discards read notifications quickly. What is captured is what was there when the sweep ran. gh_event carries no actor, since the feed is the account’s own and the actor was the login on every row; action and ref_type are written on every row, (none) on a push. is_unread is a field: reading a thread does not move its updated_at, so the daily read with all=true, the one that lists a thread read without a reply, rewrites the same row rather than opening a second one beside it.

A notification’s url is derived from the API address of its subject, and a subject shape the mapping does not recognise is left without one rather than guessed at, so a good part of the rows carry no link.

MeasurementDatedTagsFields
gh_webhookdailyhook (the id), host, activeevents, hooks
gh_webhook_deliverydated, when deliveredhook, host, event, status, code, okdeliveries, duration_seconds, redelivery
gh_rulesetdailyruleset, target, enforcementrulesets, active, days_since_change, url
gh_ruleset_ruledailyruleset, rulerules, bypass_actors, bypass_always, bypass_sampled, ref_include, ref_exclude
gh_ruleset_versiondated, when savedruleset, target, actor_typeversions, version_id, ruleset_id, actor_id, url
gh_branch_protectiondailypatternrules, admin_enforced, allows_deletions, allows_force_pushes, blocks_creations, dismisses_stale_reviews, requires_approving_reviews, required_reviews, requires_code_owner_reviews, requires_commit_signatures, requires_conversation_resolution, requires_linear_history, requires_status_checks, requires_strict_status_checks, required_checks, requires_deployments, restricts_pushes, restricts_review_dismissals, url
gh_branchdailybranch, is_defaultbranches, oid, days_since_commit
gh_deploymentdated, when the deployment was createddeployment, environment, taskoutcome, deployments, deployment_state, success, superseded, creator, commit, ref, log_url, environment_url, run_id, seconds_to_status, seconds_live, url
gh_policy_filedated, when the path last changedfile (dependabot, codeowners, security, funding)present, bytes, changes, path, blocks, ecosystems, url
gh_dependabot_ecosystemdated, when dependabot.yml last changedecosystem, intervalblocks
gh_environmentdailyenvironmentenvironments, days_since_change, age_days, protection_rules, has_branch_policy, protected_branches, custom_branch_policies, can_admins_bypass, url
gh_deploy_keydailykey, read_onlykeys, days_since_use
gh_repo_policynowsecurity_policy, forking_allowed, discussions, issues, wiki, sponsorships, blank_issues, auto_merge, delete_branch_on_merge, merge_commit, rebase_merge, squash_merge, funding_links, issue_templates, pull_request_templates, branch_protection_rules, codeowners, codeowners_errors, vulnerability_alerts, url
gh_repo_totalnowvisibility, archived, forkcommits, stars, forks, watchers, issues, issues_open, issues_closed, pulls, pulls_open, pulls_merged, pulls_closed, releases, discussions, labels, milestones, branches, tags, size_kb, repo_id, age_days, days_since_push, url
gh_dependencydailyecosystempackages
gh_dependency_licensedailylicensepackages
gh_dependency_changenowchange, ecosystempackages, vulnerable, base, head
gh_rate_limitnowresourcelimit, used, remaining, used_ratio, seconds_to_reset, own_cost, own_queries

Webhooks fail silently. Measured, one hook had been answering 403 for seventy-eight of its last hundred deliveries and nothing anywhere said so.

A 404 from branch protection does not mean unprotected: a repository can be governed entirely by rulesets, which that endpoint knows nothing about.

gh_ruleset_version is the changelog behind gh_ruleset: one row per saved version of a ruleset, dated the moment GitHub saved it, with the actor that saved it. days_since_change only summarizes that history: a ruleset switched off on a Tuesday and back on the Friday after reads as “changed three days ago”, and nothing else collected says a protection was ever absent. GitHub names the actor by id and type and not by login, so the row carries actor_type as a tag and actor_id as a field. The family is rulesets, daily: one list request per repository and one history request per ruleset, both with an ETag, so a day on which nobody edited a protection costs nothing from the budget. Measured on 2026-09-11 against the ruleset guarding the busiest repository measured: twenty versions across five months, 3 KB, one core request.

Only the host of a webhook URL is stored. The path usually carries a secret.

gh_repo_policy and gh_repo_total arrive in one batched GraphQL query that costs a single point for ten repositories, which is why settings that a REST sweep would price at a hundred and ninety eight calls are collected at all. codeowners_errors is the one that fails silently: a broken CODEOWNERS file stops requesting reviews and says nothing. vulnerability_alerts rides in that same query at no extra cost and is a second, independent reading of the switch gh_security_feature{feature="dependabot"}.enabled reports: one is the repository’s own setting, the other is whether the listing actually answered. Two sources that disagree is the case worth seeing.

The three dependency measurements are off by default. The SBOM is one call and a megabyte or two per repository, and only the aggregate is kept: a single dependency bump is three hundred and seventy changes, and what is stored is six rows.

The commit a diff ends at, and the next one starts from, is read as the bare SHA of HEAD under the application/vnd.github.sha media type: forty bytes, where the one-commit listing it used to read was five and a half kilobytes. The answer carries an ETag and is asked for conditionally, so on a repository nobody pushed to the day’s read is a free 304, as the listing’s was. The SBOM is read only when that head moved: GitHub regenerates it on every request, so its ETag never matches and each read is charged from its own bucket, and a repository without a commit has the packages it had.

gh_rate_limit is the only measurement the collector takes of itself. GitHub runs fifteen independent budgets, and without this a family skipped for want of budget looks exactly like a family with nothing to report.

GET /rate_limit reports the budgets and charges for none of them, which is what makes almost all of this free. Not every one it reports is true: measured with the token this runs under, the endpoint answered graphql as used=0, remaining=5000 in the same minute GraphQL itself answered used=162 and moved by one on every query, and the two do not even share a clock. So the graphql row is built from the rateLimit block GraphQL answers with, and the endpoint’s version of it is dropped rather than published beside it. That reading is a request every fifteen minutes and costs nothing in points, measured.

graphql is not the only bucket the endpoint invents. Measured on 2026-09-12, an SBOM request’s headers said dependency_sbom used 1, remaining 99, reset in 59 s, and GET /rate_limit two seconds later said used 0, remaining 100, its reset sliding forward a second per call. Every REST answer names the bucket it charged in its headers, so a row is built from the newest headers the client saw whenever they are still inside their own window and say more was spent than the endpoint admits. The windows of dependency_sbom and search are one minute, so a dependency_sbom row that reads zero between runs of the deps family is a refilled bucket, not the defect.

The graphql row can therefore be absent, where the others are written whenever the endpoint answers at all. It is not written when nothing has been read from GraphQL yet and no earlier reading is still inside its window: a missing row says “not measured” where a zero says “nothing spent”, and the zero was the defect.

own_cost and own_queries are on that row alone. limit, used and remaining describe the whole token’s window, shared with whatever else holds it; these two are the part this process is answerable for. They count from process start, so a restart returns them to zero and a panel has to read them as a counter rather than a value.

MeasurementDatedTagsFields
gh_job_logdated, when the line was printedworkflow, job_name, runline, head_branch

Off by default: set every.joblogs. It is text rather than a measurement, so it is excluded from the InfluxDB sink by default and skipped by the Prometheus exporter; Loki is where it belongs. The exported dashboards carry a text panel, “Where failure output went”, in the place the lines would take, since an importer may have no Loki; cmd/publish_dashboard -loki <datasource-uid> publishes the dashboard with the lines drawn from Loki in that panel’s place (see the dashboards).

Only failed jobs, and only the last forty lines of each. A successful job’s output is thousands of lines nobody will read, each log costs a request, and the tail is where a failure explains itself. GitHub keeps logs for exactly ninety days and answers 410 after that, so there is no backfilling them.

workflow here is the same file path, through the same helper, so a log line joins to the run that printed it; and branch became the field head_branch for the same two reasons as on the run. run is deliberately a series per run, which is what a log line genuinely belongs to, and it is affordable only because this family is off by default.

Colour codes are stripped and the byte order mark GitHub writes before the first timestamp is removed, so a search for a word does not fail because the word happened to be coloured.

The failure list is asked only for the runs created in the thirty one days before the window opened, rounded down to the day. Thirty one days because a re-run keeps the created_at of its first attempt and GitHub allows one for thirty days: measured on 2026-09-11, the newest failure of the busiest repository measured was the third attempt of a run created two hours before it finished, and a margin the length of a job would have missed every re-run of a failure older than a morning. Unfiltered the list was the newest hundred failures the repository ever had, six hundred kilobytes per repository per sweep for a window of an hour that is nearly always empty; filtered, a month of failures, sixty eight rows and a megabyte decompressed on that busiest repository, a few rows or none on most. Rounding keeps the URL, and with it the ETag, the same across the sweeps of a day, which is what makes the repeat a free 304 rather than a charged 200 on a fresh URL; within the day the page changes only when a failure is created or re-run. The cut at the window itself is still made here, by when the run finished.

MeasurementDatedTagsFields
gh_billing_usagedated, per dayproduct, sku, unit, repo, orgquantity, price_per_unit, gross, discount, net, url

unit is GitHub’s own unitType, capitalised as GitHub sends it: Minutes, GigabyteHours, AICredits, Requests. It is passed through rather than normalised, and the panels that read minutes filter on the capital.

net is not always zero. On the account this was developed against it carries the monthly credit, which is why gross, discount and net are all stored rather than one being derived from the others. The row is stamped at the start of its day: GitHub’s date arrives as the first billed minute of the day on half the rows, which would double the row had GitHub reported a different minute on the next read.

InfluxDB 3 creates a column the first time a row carries it, and a query that names a column no row has written fails at planning rather than answering null: the whole panel goes red. So a field written only when GitHub has a value for it does not exist on a database where that has never happened. The ones a fresh database is most likely to lack: gh_discussion.state_reason, seconds_to_answer and seconds_to_close; gh_milestone.days_to_due and seconds_to_close; gh_ruleset_rule.ref_exclude; and gh_workflow_run.initial_actor. The same rule covers every seconds_to_* that needs a closing, every url on an item GitHub sends no address for, the optional advisory fields on an alert, checks_total and checks_failed on a commit a gate ran on, label_names on an item with a label, resolved_by on a resolved thread, queued_seconds on a run’s first attempt, and pull_request, pull_requests, headline and head_repo on a run GitHub linked, described or took from a fork. Two more are worth naming, because the tables above list them beside fields that are always there: gh_dependabot_alert_item.dismissed_comment, written only when whoever dismissed an alert typed a reason, and gh_event.commits, written only on a push event. Neither column exists on the production database this documentation was checked against. gh_label writes only the labels somebody has used; gh_repo_total.labels is the declared count.