Skip to content

fix(telemetry): fail loudly when collection stops, and detect a drained feed#24

Merged
thebenignhacker merged 8 commits into
mainfrom
fix/telemetry-collector-loud-failure
Jul 17, 2026
Merged

fix(telemetry): fail loudly when collection stops, and detect a drained feed#24
thebenignhacker merged 8 commits into
mainfrom
fix/telemetry-collector-loud-failure

Conversation

@thebenignhacker

Copy link
Copy Markdown
Contributor

Why

Three silent exit 0 paths in scripts/collect-telemetry-stats.js — unset REGISTRY_URL, fetch failure, validation failure. Each defensible alone (a transient feed must not fail the daily workflow, and zeros must never overwrite a good snapshot); together they made a broken collector look exactly like a healthy no-op. It was also the only collector doing this — npm, docker, huggingface and pypi all exit 1 on missing config.

What this does

Retrieval failures escalate. A blip warns; a sustained one fails:

Situation Result
REGISTRY_URL unset, never collected warning, exit 0
unset, but telemetry was collected before error — the variable was removed
set, never collected error — broken feed or wrong URL
failing ≤ TELEMETRY_STALE_AFTER_DAYS (default 2) warning
failing longer error — the dashboard is serving stale numbers
snapshot store unreadable error, reported as unknown, never as "never succeeded"

A drained feed is detected. This is the failure a retrieval check structurally cannot see, and the one a broken ingest actually produces: the feed answers 200 with a well-formed payload whose active counts have emptied. normalizeAdoptionFeed accepts it (correctly — it's valid), persist() writes it, last-success advances, and the retrieval classifier is never called.

Two details, both learned from review:

  • Keys on MAU alone. total_installs is a 90-day window, mau is 30-day, so a broken ingest empties MAU at T+30 while installs coasts to T+90. Requiring both zero waved through ~60 days of a dead pipeline.
  • Compares against the last snapshot with users, not yesterday — otherwise day one's zeros become the baseline and the alert fires exactly once, then reports healthy forever.

The zeros are still recorded as reported. We don't suppress what the feed said; raising the alarm is the job.

Security: log injection (found in review, fixed, verified)

httpGetJson embeds up to 200 bytes of the registry's raw response body into the message, and a JSON parse error echoes body bytes even on a 200. That message reached an ::annotation:: on stdout and console.error on stderr — the runner scans both. Only annotate scrubbed, and its /\r?\n/ missed a lone \r (.NET line readers break on \r, \n and \r\n alike).

Verified against a hostile server: the old code emitted three attacker-controlled commands including ::stop-commands:: — letting a compromised registry suppress the very outage alert this PR adds. Now sanitized once at the boundary (the classifier sanitizes its own output, so every consumer is safe by construction). Re-verified: 0 injected commands, 0 surviving CRs.

The premise was wrong, and is corrected

The first version justified all this with registry #283 — an incident it cannot detect. #283 broke the ingest path; this collector reads the adoption feed, a different endpoint that stayed up and would have answered 200 with zeros. The collector was also created 2026-07-02, one day after the gap closed. That false story was in three comments, a test name and the README.

Rather than just delete it, this adds the detector that catches the real shape. The remaining #283 reference is explicitly flagged as the mechanism, not an observation.

Also fixed

  • exit 1 was being swallowed. continue-on-error meant the job went green — a scheduled run notifying nobody. The step now has an id and a Surface telemetry outage step re-raises after the commit, so the run goes red without a telemetry outage costing the other collectors their data.
  • git push || echo "No changes to push" swallowed every push failure and reported green with nothing pushed — undercutting the premise that the data is safely committed.
  • Feed request timeout (30s). A registry that accepts and never answers would hang the workflow until the job limit.
  • Number('') === 0 — my first guard let an unset ${{ vars.X }} set the tolerance to zero.
  • A future last-success date made age negative, which never exceeds the threshold — silently disabling escalation forever.
  • My test published a fabricated badge. writeBadge's path was hardcoded, so a fixture wrote "active CLI users (MAU)": "0" into data/ — a file the workflow commits and a shield renders. Caught by the gate; both paths are now injectable.
  • Raw U+2028 bytes I pasted in made lib/telemetry.js register as binary to file and grep.

Verification

  • 61 tests (was 39). New test/telemetry-collector.test.js drives the real script as a subprocess against fixture databases and a hostile HTTP server: injection on both streams, corrupt store, drained feed, sustained drain, healthy run, brand-new deployment, the require.main guard.
  • The impure glue had 0% coverage and every defect three review rounds found lived there. TELEMETRY_DB_PATH / TELEMETRY_BADGE_PATH exist so it is testable at all.
  • Build clean · HMA 98/100 (1 LOW, pre-existing) · no version bump · data/ untouched · the suite leaves the repo clean.

Behaviour today

REGISTRY_URL is unset and no snapshot exists, so this warns and exits 0 exactly as before — but now says so out loud.

https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A

The collector had three separate silent `exit 0` paths — REGISTRY_URL unset,
feed fetch failure, feed validation failure. Each was individually defensible
(a transient feed must not fail the daily workflow, and must never write zeros
over a good snapshot), but together they made an outage indistinguishable from
a healthy no-op. Registry #283 moved the ingest route on 2026-06-26 and live
CLI telemetry was dropped until the #299 alias on 2026-07-03 — seven days, no
signal, clean logs throughout.

It was also the only collector behaving this way: npm, docker, huggingface and
pypi all exit 1 on missing config.

The rule now, in classifyCollectionSkip (pure, no db/network/clock, so it is
actually testable):

  unset + never collected      -> warning, exit 0   (legitimately unprovisioned)
  unset + previously collected -> error,   exit 1   (config removed; not a blip)
  set   + never collected      -> error,   exit 1   (broken feed or wrong URL)
  set   + failed <= 2 days     -> warning, exit 0   (transient)
  set   + failed  > 2 days     -> error,   exit 1   (outage; dashboard is stale)

A single bad run stays tolerated. A sustained one fails. Failures also emit
GitHub Actions annotations so they surface in the run summary rather than in
step output nobody opens.

The workflow step is now continue-on-error. The step runs before generate-md,
generate-summary and the commit step, so a hard exit 1 there would silently
cost a full day of npm/pypi/docker/hf/chrome data for every other collector —
the fix must make failure loud without making it destructive.

No behaviour change in today's real state: REGISTRY_URL is unset and no
snapshot has ever been stored, so this warns and exits 0 exactly as before,
but now says so.

Verified end to end: unconfigured -> exit 0 + ::warning::; configured with an
unreachable feed -> exit 1 + ::error::. 39 tests pass, including a regression
test for the #283 shape (a 7-day gap must exit 1).

Claude-Session: https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A
README:104 claimed the collector 'skips gracefully when unset', which is no
longer the whole truth: it warns when unprovisioned, but errors when the
variable is removed after telemetry was previously collected, and when a
failure becomes sustained.

Adds the escalation table, the TELEMETRY_STALE_AFTER_DAYS knob, and states the
two properties the original silence existed to protect and which still hold: a
failed run never writes (zeros must not overwrite a good snapshot), and a
telemetry outage never costs the other collectors their data for the day.

Claude-Session: https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A
… outages

Adversarial review found the previous two commits shipped a false premise and
two real defects. Correcting all of it.

THE PREMISE WAS WRONG. I justified this change with registry #283, an incident
it cannot detect. #283 moved the INGEST path (CLIs POSTing heartbeats in). This
collector reads the ADOPTION FEED, a different endpoint that stayed up and would
have answered HTTP 200 with a valid all-zeros payload — normalizeAdoptionFeed
accepts that (correctly; it is well-formed), persist() writes it,
lastSuccessDate advances, and classifyCollectionSkip is never called. The
collector was also created 2026-07-02, one day before #283 was fixed, so it did
not exist during the gap. The claim appeared in three comments, a test name and
the README.

Rather than just delete the story, add the detector that actually catches that
shape: classifyFeedHealth compares against the previous snapshot and errors when
a live fleet reports mau=0 AND installs=0. Users do not all vanish overnight.
The zeros are still recorded as reported — we do not suppress what the feed
said; raising the alarm is the job. Only a total collapse of both counts
triggers: a decline, or MAU-only churn, is not assumed to be an outage.

HIGH — log injection. httpGetJson embeds up to 200 bytes of the registry's raw
response body into the message; a JSON parse error echoes body bytes even on a
200. That message reached an ::annotation:: on stdout AND console.error on
stderr, and the runner scans both. Only annotate() scrubbed, and its /\r?\n/
missed a lone \r — .NET line readers break on \r, \n and \r\n alike. Verified
against a hostile server: the old code emitted three attacker-controlled
commands including ::stop-commands::, letting a compromised registry suppress
the outage alert this code exists to raise. Now sanitized once at the boundary
(classifyCollectionSkip sanitizes its own output, so every consumer is safe by
construction), covering \r, \n, U+2028/29/0085, defanging `::`, and bounding
length. Re-verified: 0 injected commands, 0 surviving CRs.

HIGH — the exit 1 was swallowed. continue-on-error means the job goes green, and
a green scheduled run notifies nobody: the change relocated the silence rather
than ending it. The telemetry step now has an id, and a post-commit step
re-raises the failure so the run goes red and the notification fires — after the
day's npm/pypi/docker/hf/chrome data is safely committed.

MEDIUM — a FUTURE last-success date (clock skew, bad row) made age negative,
which never exceeds the threshold, pinning the collector to "warning" forever
and silently disabling escalation. Now errors.

MEDIUM — lastSuccessDate() mapped every db error to null, so a corrupt or
unreadable database asserted "has never succeeded" and pointed the operator at
the feed. Now three-valued: known / never / unknown, with unknown getting its
own honest message. A missing table is still legitimately 'never'.

MEDIUM — Number('') === 0 and Number.isInteger(0) is true, so my first guard let
an empty `${{ vars.UNSET }}` set the tolerance to zero and error on the first
blip. Empties are rejected before coercion.

Also: the impure glue was at 0% coverage and every real bug above lived in it.
collect-telemetry-stats.js now exports its internals for test, mirroring
collect-chrome-stats.js which already does this for parseListing.

Also: raw U+2028 bytes pasted into source made `file` and `grep` classify
lib/telemetry.js as binary data. Written as escapes now.

52 tests pass (was 39). New tests fail against the previous commit.

Claude-Session: https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A
The README justified the loud-failure work with registry #283, an incident this
collector structurally cannot detect: #283 broke the INGEST path, while this
reads the ADOPTION FEED — a different endpoint that stayed up and would have
answered 200 with zeros. Replaced with what is actually true, and documented the
zero-collapse detector that does catch that shape.

Also softened the same claim in classifyFeedHealth's docstring: the collector
was written 2026-07-02, after the gap closed, so it never observed the feed's
behaviour during #283. Stated as the mechanism, not as an observation.

Documents the log-injection boundary while here — untrusted feed text is
sanitized before reaching any log line, because the runner parses ::command::
on both stdout and stderr.

Claude-Session: https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A
Verification of the previous commit found the detector I added to catch an
ingest outage would not have caught one, and would then have alerted once.

WRONG ENVELOPE. It required mau === 0 AND installs === 0. Those are different
windows: total_installs is distinct installs over the 90-day retention, mau is
30 days (setup-database.js). When ingest breaks, MAU empties at T+30 while
installs coasts down until T+90:

  T+30  mau=0  installs=333   healthy   <- fleet provably dead, no alert
  T+60  mau=0  installs=167   healthy
  T+90  mau=0  installs=0     ERROR     <- first alert, 90 days late

Sixty days of zero monthly actives against a live install base passed as
healthy — and a test of mine encoded that blind spot as intentional ("could be
real churn"). Zero actives against 500 known installs is not churn. Now keyed on
MAU alone; installs surviving is stated as corroboration, because it is exactly
what a drained ingest looks like.

SELF-SILENCING. It compared against yesterday, so day one's zeros persisted and
became the baseline: every later day saw zero-following-zero and reported
healthy. One red run, then green forever while serving zeros — inverting this
module's own escalate-on-sustained rule. Now compares against the last snapshot
that actually had users, so the alarm keeps ringing and states the growing gap.

Also:

- Added a feed request timeout (30s, TELEMETRY_FEED_TIMEOUT_MS). A registry that
  accepts the connection and never answers would otherwise hang the daily
  workflow until the job limit killed it — a silent failure of a different shape
  that also takes the other collectors' commit down with it. Found while
  debugging my own test deadlock.
- dbPath is overridable via TELEMETRY_DB_PATH so the glue is testable. Every
  defect three review rounds found lived in that glue, and it had 0% coverage
  while the path was hardcoded.
- New test/telemetry-collector.test.js drives the REAL script as a subprocess
  against fixture databases and a hostile HTTP server: injection on both streams,
  corrupt store, drained feed, sustained drain, healthy run, brand-new
  deployment, and the require.main guard. The previous commit's export comment
  claimed it existed "so it needs to be reachable from a test" — no such test
  had been written. Now it has.

61 tests pass (was 52).

Claude-Session: https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A
…n rule

`git push || echo "No changes to push"` reported the run green whenever the push
failed for ANY reason — non-fast-forward, a rejected hook (this repo has a local
pre-push hook that blocks data/analytics.db), an auth error — with the day's data
never pushed. That directly undercuts the premise of the new Surface step, which
re-raises a telemetry outage on the grounds that the data is "safely committed".
Now distinguishes nothing-to-push from a failed push.

README: describes the drain rule as it now works — keyed on MAU alone (installs
is a 90-day window against MAU's 30, so requiring both hid ~60 days of a dead
pipeline) and compared against the last snapshot with users (comparing to
yesterday made the alert fire once and then go quiet forever).

Claude-Session: https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A
The final gate caught data/telemetry-badge.json in the diff. My own integration
test wrote it: I made dbPath injectable but left writeBadge's path hardcoded, so
the healthy-feed case wrote a real badge into data/ — one reading

    "label": "active CLI users (MAU)",  "message": "0"

That file is committed by the daily workflow and rendered as a shield, so a test
fixture would have published a fabricated "0 active users" claim as if it were
measured. It was introduced by becf327 and is removed here; it does not exist on
main.

writeBadge now honours TELEMETRY_BADGE_PATH, and the tests point it at a temp
dir. Production is unchanged — unset, it resolves to the committed path, and the
workflow still commits the real badge.

Verified: the full suite leaves data/ clean.

Claude-Session: https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
github-analytics-tracker Ready Ready Preview, Comment Jul 17, 2026 12:21am

Request Review

Nothing ran it. The only workflow in this repo was the daily collector, so every
test in test/ was decoration: it passed on whoever last remembered to run it
locally, and a change that broke it would merge green. That is the same shape as
the silent-collector bug the suite exists to guard — a check that never fires
looks identical to a check that passes.

Runs on pull_request and on push to main: npm test, then the build.

Also asserts the suite leaves the working tree clean. The tests drive the real
collector against fixture databases and write a badge; the badge path was
hardcoded until this branch, so a fixture wrote "active CLI users (MAU): 0" into
data/ — a file the daily workflow commits and a shield renders. That was caught
by hand during review. This makes it fail the build instead.

Claude-Session: https://claude.ai/code/session_01KdsHmRjPoLfFSjPWeC3A4A
@thebenignhacker
thebenignhacker merged commit 8fd967f into main Jul 17, 2026
3 checks passed
@thebenignhacker
thebenignhacker deleted the fix/telemetry-collector-loud-failure branch July 17, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant