Uh oh!
There was an error while loading. Please reload this page.
feat(telemetry): one outcome event per install, with nowhere to put a path (backend#1907) - #747
Conversation
… path (backend#1907) The installer is the highest-variance, least-observed step in the product: it runs on machines we have never seen, under package managers, proxies and shells we do not control, and it reports to nobody. Each of the backend#736 failures — the CLI landing in ~/.local/bin with PATH advice only printed, apt-get appearing hung because unattended-upgrades held the dpkg lock — was invisible until a customer happened to mention it. scripts/lib/telemetry.sh emits one contract-shaped event per run from install_cleanup, the EXIT trap, so it fires on every path including the interrupted and the fatal one. It carries the phase reached, per-phase durations, the exit code, the client state, OS/arch, the version, an error class, and — for the #736 PATH case specifically — TB_CLI_ON_FRESH_PATH, which install-cli.sh has always computed and only ever printed advice about. "NO ARGUMENTS, NO PATHS, NO DATA" IS A SHAPE, NOT A RULE. Every value goes through _telemetry_attr, which admits a string only if it matches ^[A-Za-z0-9._-]{1,64}$ and an integer only if it is one. A path contains '/', a proxy credential contains ':' and '@', a token is longer than 64 characters, a name contains a space. Values that fail are dropped, never trimmed: a redactor has to imagine what it is stripping, and a shape only admits what it was told to. The phase and the client state are additionally checked against their closed sets at the render boundary — a canary assigned straight to TB_TELEMETRY_PHASE reached the record before that line existed, and it was shaped exactly like a legal value, so the token regex waved it through. The vocabularies are DERIVED, and a new guard proves it. scripts/tests/telemetry-vocabulary-agreement.sh parses install-k8s.sh's step_header calls, summary.sh's CLIENT_STATE writers, gen-manifest.sh's FILES array and install.sh's release-tag regex, and compares each to telemetry.sh's declaration; the error classes have no second declaration, so it exercises the classifier over the full cross-product and checks both that every answer is registered and that every registered class is reachable. It runs in drift-checks' `Source-of-truth drift` job, which is required — a guard in a job nobody must wait for is advice. It found one thing on its first run: summary.sh's own CLIENT_STATE docstring had been missing image_pull_ca since #424. TWO REAL BUGS THE TESTS CAUGHT, both of the same shape and both fatal: `printf | grep -q` returns 141 on a match under `set -o pipefail`, and so does `tr -dc < /dev/urandom | head -c 16` — the latter at SOURCE time, which killed the whole installer before it printed a line. Every unit-level test passed throughout; only the test that runs install_cleanup for real under the installer's own shell options went red. That test stays. install-bootstrap.bats held a hand-written second copy of install.sh's FILES array, in two places, so adding a lib turned ten unrelated supply-chain tests red. It now derives the list, and fails closed on an inert parse. WHAT IS NOT CONNECTED: the transport. The 17 Aug decision (rfcs#28) replaced the Collector gateway with an ingest endpoint on the backend — backend#1905, which does not exist yet — so _telemetry_deliver writes the install log and a bounded 0600 local spool that #1906's forwarder can drain, and posts nothing. Opt-out (default on) via TRACEBLOC_NO_TELEMETRY or DO_NOT_TRACK, documented in --help — and that promise is itself checked, because a user who exports a stale name believes they have opted out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 18, 2026
bugbot run |
LukasWodka
commented
Aug 18, 2026
Sibling PR for the CLI half of backend#1907: tracebloc/cli#527. Independent — neither depends on the other, and the transport for both is tracebloc/backend#1905. |
…ixture (backend#1907) gitleaks' curl-auth-user rule fired on the TB_ERR_CMD fixture, and it was right to: a source file containing that spelling is a finding whatever the surrounding test claims, and a reviewer scanning the diff has no way to tell a canary from the real thing at a glance. The fixture's purpose is unchanged — TB_ERR_CMD holds the failing command UNEXPANDED, which is free text carrying a path, and must not be emitted. It now carries a path instead of a credential. The credential half of the same test is already covered by HTTPS_PROXY, which encodes user:pass in a proxy URL and is what a hospital network actually configures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 18, 2026
bugbot run |
LukasWodka
commented
Aug 18, 2026
|
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
commented
Aug 18, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
commented
Aug 18, 2026
Bugbot round 1 — one fixed, one demoted with evidence✅ "Non-install exits report success" (High) — real, fixedReproduced exactly as described: This is the worst possible bug in this PR and the severity is right: Fix: a run-started latch, not a phase test. Second, related thing found while fixing it: the assess handoff exits 0 Not covered, deliberately:
|
| mutation | reddened |
|---|---|
| the run-started latch removed | 2 tests |
| the latch set at source time (always on) | 1 test |
main stops setting the latch (feature dead) | 1 test |
| the skipped handoff reported as succeeded | 1 test |
assess.sh stops marking the handoff skipped | 1 test |
| a bootstrap failure classified as something else | 1 test |
Two of those came back inert on the first pass — "main stops setting the
latch" and "assess.sh stops marking the handoff" — because the tests set the
flags themselves and so could not see the wiring disappear. That is the same
class as the bug you found: a unit test of the emitter cannot observe which exits
reach the trap. Both are now driven end to end through the real entrypoint (avalidate_config rejection, which lands after the latch and before step a) and
through the real _assess_handoff with tracebloc mocked. Both mutations redden
now.
26 tests in telemetry.bats, make check green.
install_cleanup is the EXIT trap, so it fires for every exit of install-k8s.sh —
including the terminal commands that touch no machine. `--help` exits 0 and was
emitting a full install.run.succeeded with phase `bootstrap`. Reproduced:
$ HOST_DATA_DIR=$T bash scripts/install-k8s.sh --help
{"attributes":{"event.name":"install.run.succeeded",
"tracebloc.install.phase":"bootstrap","tracebloc.install.exit_code":0,…}}
That is the worst bug this feature could have. `--help` is the command people run
MOST while a real install is broken, so a free success lands in the denominator
of the failure RATE — the one number the ticket exists to produce — and moves it
in the direction that hides the problem. Found by Bugbot on client#747.
The fix is a latch, not a phase test: main() calls telemetry_run_started once
--help / --diagnose / prepare-host have had their chance to dispatch, and
telemetry_emit_outcome returns early without it. A phase test would have been
wrong — a genuine failure IN the bootstrap phase (the leftover-data guard,
validate_config) is an install attempt and must still be reported, which is now
pinned by a test that drives the real entrypoint into a validate_config
rejection.
Found while fixing it: the assess handoff exits 0 having run no step, so
counting it as succeeded would grow the success count with re-runs on machines
nothing happened to. It now emits install.run.skipped — a registered outcome
verb (contract §6.4), so no new vocabulary — which also makes "how often is the
installer re-run on a machine that was already done" answerable.
prepare-host is deliberately still not reported: it is a different command with
its own registry component (§10.1), and filing it under
tracebloc.component=install would be mislabelling it rather than measuring it.
TWO OF THE SIX NEW MUTATIONS CAME BACK INERT on the first pass — "main stops
setting the latch" and "assess.sh stops marking the handoff" — because the tests
set those flags themselves and so could not see the WIRING disappear. That is
the same class as the bug Bugbot found: a unit test of the emitter cannot observe
which exits reach the trap. Both are now driven end to end, through the real
install-k8s.sh and the real _assess_handoff, and both mutations redden.
Bugbot's second finding (the source lookup aborting the whole event under set -e)
is reported as unreachable with evidence rather than fixed: a command
substitution in an ARGUMENT position does not propagate its status to the
enclosing command, measured on bash 3.2.57 and 5.x. The invariant is pinned
anyway — an unrecognised source location drops the field, never the event.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>LukasWodka
commented
Aug 18, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
1 issue from previous review remains unresolved.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 29495f7. Configure here.
…kend#1907) telemetry_phase_begin only closes a phase when the NEXT one starts, and nothing closed the active phase before the event rendered. That lost the most important number in the file, and it lost it in exactly the case the ticket names: * on every SUCCESSFUL install, phase_connect_ms was absent — the readiness wait, up to READY_TIMEOUT (600s), the single longest phase; * on every failure and every cancel, the phase named by tracebloc.install.phase had no duration. The dpkg-lock case in its likeliest real form is stuck twenty minutes in `prerequisites` and then killed or given up on, never reaching step c. Reproduced before fixing: "tracebloc.install.phase":"prerequisites" "tracebloc.install.phase_preflight_ms":0 <no prerequisites duration at all> * `bootstrap` had no key at all, because the loop iterated the letter map and bootstrap has no step letter. So the download + verify + leftover-guard + assess time was an unnamed remainder — which is also why subtracting the other keys from duration_ms could not recover the missing active phase. Found by Bugbot on client#747. My own "a slow phase is visible" test passed throughout, because it only ever measured a phase a later step_header had closed — the exact shape of a test that proves the easy half. The live delta is added at READ time rather than by a "close the phase" call in the emit path, so render stays idempotent: the tests call it repeatedly, and a render that mutated the accumulators would report different numbers each time. The clock is now read once per event, so the per-phase numbers and the total are exactly consistent — which is an invariant a test asserts. That test needed a FAKE CLOCK. The obvious fixture is wrong: winding _TB_TELEMETRY_PHASE_STARTED_MS backwards after the step_headers have already attributed that time invents milliseconds that never elapsed, and the first version failed for precisely that reason (sum 1200000 vs total 900000). Four mutations run. Three redden — dropping the live delta, going back to the letter map, and counting the delta for every phase. The fourth (reading the clock per attribute instead of once) is INERT and is reported as inert rather than counted: _telemetry_now_ms has second resolution, so two reads inside the same second are identical, and the guard only matters across a second boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 18, 2026
Bugbot round 2 — the third finding was real too, and it hit the DoD claimI missed this one in my first reply: it came from Bugbot's earlier review pass ✅ "Final phase duration never recorded" (Medium) — real, fixed
This undercut the DoD claim in the PR body. I wrote that per-phase durations And as you noted, the remainder trick could not recover it, because My own test passed throughout. "A slow phase is visible on a run that Fix: the live delta is added at read time, not by a "close the phase" call New invariant, asserted:the phase durations sum EXACTLY to That test needed a fake clock, and the reason is worth recording: the obvious Mutation proofs
The fourth is reported as inert rather than counted: Running total across both rounds3 findings, 2 fixed, 1 demoted with measured evidence. Two of my own 29 tests in
|
LukasWodka
commented
Aug 18, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
…refused (backend#1907)
_telemetry_deliver ran from the EXIT trap and did `mkdir -p
"$HOST_DATA_DIR/telemetry"` unconditionally — including on the path where
early_data_dir_guard had just REFUSED that directory for being on a network
filesystem and called `error`.
That guard deliberately skips an existing directory ("an EXISTING data dir has no
at-risk mkdir here", client#441), so anything creating the directory behind its
back disarms it for every later run:
run 1: guard refuses (dir absent) -> error -> EXIT trap -> telemetry creates it
run 2: guard sees the dir, returns 0 -> MySQL installs onto NFS
which is exactly the InnoDB corruption client#432 exists to prevent, reintroduced
by the telemetry that was only supposed to watch. Reproduced before fixing:
guard exit=1
HOST_DATA_DIR created on the REJECTED volume? YES
./nfs-volume/.tracebloc/telemetry
An observer that changes the install's own preconditions is not an observer. The
spool now only writes INTO a data dir that already exists; a run that dies before
that still reports through the install log, which is what a support bundle
collects and which _choose_log_file has already placed somewhere safe (falling
back to $TMPDIR).
Found by Bugbot on client#747 — the third real finding of three rounds, and the
most serious: the other two corrupted the metric, this one corrupted a customer's
database.
Both directions mutation-proven: removing the existence check reddens the new
test, and disabling delivery outright reddens four others, so the fix cannot pass
by simply turning the feature off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>LukasWodka
commented
Aug 18, 2026
Bugbot round 3 — the most serious one yet, fixed✅ "Spool mkdir undoes NFS guard" (High) — real, and worse than the first twoThe first two findings corrupted a metric. This one corrupted a customer's
which is the InnoDB corruption client#432 exists to prevent, reintroduced by the The principle I got wrong: an observer that changes the install's own Fix: the spool only writes into a data dir that already exists. A run that Both directions mutation-proven, because "don't write" is trivially satisfied
The regression test carries an anchor asserting the guard actually refused Final tally across three rounds4 findings, 3 fixed, 1 demoted with measured evidence. In order of what they
Three of those were invisible to my own tests, and the pattern is consistent: all 30 tests in
|
LukasWodka
commented
Aug 18, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
…re (backend#1907) Fallout from the previous commit, and the worst kind: the fix that stopped telemetry disarming the NFS guard also made the NFS refusal itself invisible. _telemetry_deliver's comment claimed "the install log always gets it". It does not. `log` is a no-op until setup_log_file sets LOG_FILE, and setup_log_file runs AFTER validate_config and early_data_dir_guard — deliberately, because #432 refuses a network data dir BEFORE logging starts. So on exactly those paths there was no log AND (correctly, since the previous commit) no data dir, and the rendered event was discarded: $ early_data_dir_guard # target reads as nfs guard exit=1 any telemetry written anywhere? 0 A run refused for being on NFS is a real, actionable field failure, and it was the single case producing no record at all — invisible to the very failure rate this feature exists to produce. Those pre-log failures are also precisely the class the run-started latch was built to preserve, so losing them undid that too. Found by Bugbot on client#747, which also spotted that this file's own NFS test masked the bug by setting LOG_FILE=/dev/null. That test now leaves LOG_FILE unset, as the real path does, and asserts the refusal IS reported. Fix: when there is no data dir, spool to a mktemp'd file in $TMPDIR. mktemp and not a fixed name — /tmp is world-writable on Linux and the installer runs privileged steps, so a predictable path is a symlink target for an append that may be running under sudo; mktemp creates with O_EXCL. This mirrors _choose_log_file's own fallback, so an early-failure run leaves one small file beside the install log it already leaves there, rather than a new class of litter. #1906's forwarder reads both locations. A comment that claims something untrue is itself the defect (workspace CLAUDE.md rule 7), so the false claim is replaced with what actually holds and why. Four mutations, all reddening: removing the fallback, giving it a predictable shared path, dropping its 0600 mode, and dropping the data-dir existence test (which would disarm the NFS guard again). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 18, 2026
Bugbot round 4 — right again, and it was fallout from my own round-3 fix✅ "Early failures drop outcome events" (High) — real, fixedThis is the sharpest of the four, because it is a consequence of the previous Reproduced:
Those pre-log failures are also precisely the class the run-started latch was And you were right about the test. My NFS test set Fix: when there is no data dir, spool to a The false comment is replaced with what actually holds and why — a comment that
Final tally — 5 findings across 4 rounds4 fixed, 1 demoted with measured evidence. Every single real one was about
No unit test of 31 tests in
|
LukasWodka
commented
Aug 18, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 46a33de. Configure here.
saadqbal
left a comment
There was a problem hiding this comment.
Four findings, all about which runs reach the emitter or what gets past the shape guard — nothing wrong with the render logic itself. The $TMPDIR one is the significant one: it quietly undoes the round-4 fix on the primary macOS path.
Two things that don't fit on a line in the diff:
install.run.skippedwill almost never fire on thecurl | bashpath.install.sh:144exec traceblocs on a healthy machine before install-k8s.sh is fetched at all, so assess.sh's gate only sees direct./install-k8s.shre-runs. Fine, just much narrower than "how often do people re-run an installer that was already done".quality / gitleaksis still red. Your comment explains it and what it needs; noting it's the one thing blocking merge.
Vocabulary guard and the 31 bats tests are green here on 46a33de.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…tes (backend#1907) Four findings from @saadqbal's review, each reproduced before it was fixed, plus the nit. The first one is the significant one: it silently undid round 4 on the primary macOS path. 1. The fallback spool landed inside the bootstrap's own scratch dir. install.sh:238 does `TMPDIR="$(mktemp -d)"` and :239 traps `rm -rf "$TMPDIR"`. A plain assignment to a name that is ALREADY EXPORTED keeps the export attribute — and TMPDIR is always exported on macOS — so install-k8s.sh inherited the doomed directory and `mktemp "${TMPDIR:-/tmp}/…"` wrote the record into it. Reproduced end-to-end: the spooled file was gone the moment the bootstrap returned. So the NFS refusal, and every other pre-setup_log_file failure, still produced no record anywhere on macOS — the exact hole the fallback closed. _telemetry_fallback_dir now disqualifies TMPDIR when the running installer is inside it, which is true precisely when TMPDIR is the bootstrap's scratch dir, and falls back to $HOME (never $HOME/.tracebloc — telemetry must not create HOST_DATA_DIR) and then /tmp. DERIVED rather than agreed: asking install.sh to export its original TMPDIR under another name would work only when the bootstrap is new, and install.sh is served from a URL a user may have curl'd months ago. Both sides of that comparison are resolved with `pwd -P`. The first cut compared them as written and missed every Mac, because /var is a symlink to /private/var — caught by re-running the reproduction against the fix, not by reading it. 2. grep is line-based, so a value with a newline passed the shape check. The one input shape "nowhere for a path to go" does not cover, because what lands is not a path — it is a second line: TB_VERSION=$'v1.9.3\n","tracebloc.install.injected":"yes' → "service.version":"v1.9.3 ","tracebloc.install.injected":"yes",… A forged attribute AND one record split across two lines of a .jsonl spool, so #1906's forwarder reads two malformed events. Reproduced on all four checks — the key, str and int shapes in _telemetry_attr, _telemetry_version, and _telemetry_source_line's inline regex. All now `[[ =~ ]]`, which anchors at end of string. As a bonus it removes the external process, so the backend#1778 SIGPIPE class the here-strings were working around cannot recur here at all. The agreement check proved the two version regexes were byte-identical while they behaved differently, and reported that as "the service.version shape is install.sh's own release-tag gate" — a claim about behaviour that byte-identity does not support, because each side was matched with a different operator. It now checks both: byte-identity, then verdict agreement over a corpus, each side evaluated the way the file that owns it evaluates it. The corpus is written down independently of either matcher and the check fails closed if it contains no embedded-newline input, since without one it degenerates into the byte check. 3. exit 2 is the "complete this step and re-run" handoff, not a failure. gpu-nvidia.sh:55 exits 2 after install_nvidia_drivers SUCCEEDED, to ask for a reboot. That call sits under step_header b, so every unattended GPU host's first install booked an `install.run.failed` with error.type=prerequisites_failed — a fabricated prerequisite failure in the rate this ticket exists to produce. Same shape as the --help bug, opposite direction; install_cleanup has treated 2 as its own outcome ("Re-run required") since client#681. It now renders install.run.cancelled and carries no error.type. It rides an existing verb rather than a new one because §6.4's outcome list is closed and adding to it is a PR against the contract, not an emitter's unilateral call; of the registered verbs, `cancelled` is the only terminal one that is true here. exit_code stays on the record, so 2 (handoff) and 130/143 (Ctrl-C) remain separable — which is why the exit code is an attribute in the first place. Event names are now a declared closed set with a guard. The guard derives the emitted names two ways — the literals in the case statement, and what the function actually renders over the installer's exit codes — rather than reading the declaration twice, and checks §6.1's grammar. It cannot check the §6.4 half from this repo: the verb registry is in rfcs, and a hand-copied second list of verbs would be the defect rather than the fix. 4. The chmod ran before the trim replaced the file. `tail > "${spool}.tmp"` creates under the process umask and `mv` keeps the tmp file's mode, so 0600 did not survive. common.sh's `umask 077` normally covers it, but _install_userspace_tools (setup-linux.sh:893) and its macOS twin set `umask 022` and restore it only afterwards. Reproduced: spool 644. The chmod now runs on the inode that survives, before the mv — one chmod, not two, because a second one on the spool afterwards is unreachable belt and braces that no test can redden. The test pinning 600 could not see any of this, because load_lib sources common.sh first and every test therefore ran under 077. There is now one that sets umask 022 and asserts the umask actually took. 5. nit: the comments claimed coverage the file does not have. `bootstrap` means "install-k8s.sh before step a", not "everything before step a" — download and verify happen in install.sh, which never sources this file and whose EXIT trap is `rm -rf "$TMPDIR"`. install.sh in TB_TELEMETRY_SOURCES is unreachable for the same reason: TB_ERR_LOC has exactly one writer, common.sh's _record_err. Both comments now say so. No bootstrap telemetry added. Also noted on install.run.skipped, which reads wider than it is: install.sh:132 reaches a healthy machine and `exec tracebloc`s at :144 before install-k8s.sh is fetched, so on curl|bash the assess gate is never reached at all. Tests: 4 new bats tests (36 in telemetry.bats, 1161 across the suite, all green), 3 new checks in the agreement guard. Every fix mutation-proved: 11 mutations, 11 reddened, each with its anchor asserted. Two first-pass mutations came back inert and are fixed rather than counted — one removed a redundant chmod nothing could observe (the redundancy is now gone), the other rewrote the guard's own detector alongside its corpus so the detector matched its mutated needle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 18, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit eaa848a. Configure here.
saadqbal
left a comment
There was a problem hiding this comment.
All five addressed, and the fixes are better than what I suggested in two places — deriving "is the installer running inside this directory?" instead of asking install.sh to export a second name is the right call for a bootstrap users may have curl'd months ago, and the pwd -P catch on both sides is the kind of thing only re-running the reproduction finds.
Verified on this head rather than taken: the agreement check is green including the new behavioural version corpus and the exit-2 branch; bats scripts/tests/telemetry.bats is 36/36; and I re-checked the four shape guards under /bin/bash 3.2.57 — [[ =~ ]] refuses every embedded-newline input and still admits the legal spellings, and the {1,15} interval works there.
One non-blocking note below on exit 2. quality / gitleaks is still the only red check and still needs the squash from a human.
Uh oh!
There was an error while loading. Please reload this page.
…ange scripts/gen-manifest.sh output, required by the Static analysis R8 gate after any installer script changes (backend#1907). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 18, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
saadqbal
left a comment
There was a problem hiding this comment.
Re-approving — the marker is the right shape and goes further than the note asked for. Declaring at the exit 2 site, clearing at source time so an inherited _TB_TELEMETRY_RERUN_HANDOFF=1 can't pose as a handoff, and failing closed toward failed for an undeclared 2 all land the way round I'd want. unexpected_exit_2 as its own row rather than the phase bucket is better than what I suggested: a stray 2 stays distinguishable from a real prerequisite failure instead of just being counted.
(d4) deriving the site list from grep -n 'exit 2' over the runtime, and failing closed on zero sites with the TRACEBLOC_DOCKER_FIRST_RUN_EXIT precedent named, is the part that will still be true in a year.
Verified on a00b1c9: agreement check green (13 checks, including both exit-2 directions and the site sweep), bats scripts/tests/telemetry.bats 40/40, gpu-nvidia.bats green, gen-manifest.sh --check current, shellcheck -S warning -x clean on the three touched files apart from the pre-existing SC2034 at gpu-nvidia.sh:253. dispatch_gpu_setup is a plain call chain from install_linux, no subshell, so the marker really does survive to the trap — test 30 pins it.
Two follow-ups, neither for this PR: install_cleanup still prints "Re-run required. Complete the step above" for an undeclared 2, so the user gets the message telemetry now correctly refuses to believe — it can key on the same marker. And the dead TRACEBLOC_DOCKER_FIRST_RUN_EXIT read at common.sh:1022 deserves its own ticket now that you've traced its producer to 8c3a3d4.
quality / gitleaks is still the only red check, still needs the squash.
… for (backend#1907) Two Bugbot findings, both reproduced before either was touched. 1. HIGH — a failed data-dir spool write dropped the outcome event. `_telemetry_deliver` had `|| return 0` on both the spool's `mkdir -p` and its append, so a data dir that EXISTS but refuses the write ended the function with the record nowhere — never reaching the $TMPDIR fallback added in 46a33de. That is not an exotic path: HOST_DATA_DIR present but unwritable is precisely when `_choose_log_file` (common.sh:769-775) has already fallen back to a mktemp log, and on curl|bash that log sits in the bootstrap's own doomed TMPDIR, so the `log` line kept nothing either. The fallback existed and was unreachable in exactly the case it was written for. Reproduced both halves, each with an anchor proving the fixture was not inert: * a 0500 data dir with no telemetry/ yet. Anchor A: `_choose_log_file` really does fall back out of that dir (called, not asserted). Anchor B: telemetry/ was not created afterwards, so the mkdir really failed. Result: no data-dir spool, no fallback file. The event was gone. * pending.jsonl replaced by a directory, so the append cannot open it. Same result, and it holds for root too. Both now fall through. The single-write guarantee comes from control flow rather than a flag: the `return 0` sits after a SUCCESSFUL append and nowhere else, so the fallback is reachable only on a path that wrote nothing. Everything after the append — chmod, trim, mv — may fail freely, because the line is already on disk and re-filing it would turn one install into two rows. The control case still writes exactly one spool line and zero fallback files. The trim moved into `_telemetry_trim_spool` so the append's success is the last thing in that branch. Inline, the trim sat between the append and the return, which is what made it easy to write the `|| return` that skipped the fallback decision in the first place. Also fixed on the same line: `2>/dev/null` now precedes the `>>`. Redirections apply left to right and a failing `>>` is reported by the SHELL, not by printf, so the old order printed `…/pending.jsonl: Is a directory` — with the customer's path — out of an EXIT trap. 2. LOW — a line number was emitted with no file to attach it to. `tracebloc.install.source` and `tracebloc.install.source_line` were two independent gates over one fact. A location whose basename is outside TB_TELEMETRY_SOURCES dropped the file and kept the number. Reproduced: `/home/someone/evil.sh:9` rendered `"tracebloc.install.source_line":9` with no source key, and so did `?:118` — the shape the ERR trap produces whenever BASH_SOURCE is empty, which is a real installer path, not a synthetic one. A line with no file is not a partial answer; it is a confident wrong one. The gate is now `_telemetry_source_basename`'s own exit status, so TB_TELEMETRY_SOURCES stays the single declaration of what counts as one of our files. Deliberately not gated the other way: TB_ERR_LOC has exactly one writer in the tree (install-k8s.sh:118) and it always appends `:${LINENO}`, so source-without-line is unreachable — a branch for it would be belt and braces no test could redden. A file with no line is honest information anyway. Mutation results — 10 mutations, each asserting its own anchor applied first: M1 both halves return instead of falling through RED M2 only the mkdir half returns RED M3 only the append half returns RED M4 a successful append no longer returns (double file) RED M5 source and line gated independently again RED M5b the same mutation vs the PRE-EXISTING source test RED (it now covers the line) M6 common.sh dropped from TB_TELEMETRY_SOURCES GREEN — survivor, by design M6b cluster.sh dropped (the anchor names it) RED M7a the trim's else-branch cleanup removed RED M7b a failed trim treated as success RED M7c the trim is never called RED M7d a failed mv leaves the trimmed copy behind RED M6 is a deliberate survivor and is reported rather than hidden: the pairing test derives its expectation FROM the vocabulary, so a consistent removal moves both sides. Verified that the gate which owns the vocabulary catches it instead — telemetry-vocabulary-agreement.sh goes red with "source basenames disagrees with gen-manifest.sh's FILES array". The independent anchor at the end of the pairing test also names cluster.sh in its own right, which is why M6b reddens. M7 found a defect in this commit's own first draft. The trim-failure fixture broke `tail` with a PATH shim, and common.sh:8 PREPENDS the system directories to PATH, so the real `tail` ran and both assertions passed against a trim that had worked perfectly. Rewritten to use a shell function — which beats PATH lookup outright — plus a behavioural anchor: SPOOL_MAX=3 against 7 spooled lines must leave 7, or the trim ran after all. scripts/manifest.sha256 regenerated (Static analysis R8). Full suite: 1168 bats tests green. `make lint` clean; telemetry.sh clean under `shellcheck -S warning -x` and `bash -n` on bash 3.2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 19, 2026
bugbot run |
Uh oh!
There was an error while loading. Please reload this page.
…ng (backend#1907) Third Bugbot finding on this branch, reproduced before it was touched. `_assess_handoff` marks the run skipped and then hands the user the interactive `tracebloc` home screen before `exit 0`, with install-k8s.sh:122's `trap 'exit 130' INT` live. The 130/143 branch of telemetry_render_event booked `cancelled` without consulting `_TB_TELEMETRY_SKIPPED`, so Ctrl-C on that screen — the most ordinary thing a user does there — filed a cancelled install for a run that installed nothing. Reproduced through the real INT and EXIT traps, not just the render function, with the skipped latch set identically in both arms: skipped run, ordinary exit 0 -> install.run.skipped skipped run, then Ctrl-C (130) -> install.run.cancelled <- the defect `cancelled` asserts that an install was cancelled. On this path there was no install to cancel, and the row would land in the denominator of "how often do installs not complete" — inflating it with runs that never attempted anything. Same class and same direction as the `--help` bug fixed earlier on this branch: it makes the product look worse while telling nobody anything actionable. No contract change. §6.4's verb list is untouched — no verb added, removed or redefined. Both `skipped` and `cancelled` were already registered and already emitted by this file; only which of the two a given run books changes, and `skipped` is the true one here. A wrong verb from a closed list is an ordinary defect. SKIPPED DOES NOT WIN OVER A FAILURE, and the asymmetry is deliberate. The flag is consulted only on the exits that mean nothing was installed — 0 and the two signals. A skipped run that then dies with a real non-zero stays `failed` and keeps its error.type, so the `*)` branch does not look at the flag. The shorter spelling — hoisting a blanket "skipped wins" ahead of the case — would have hidden a genuine failure, which is why it is not used and why there is a test for it. The comment claiming 130/143 were "unconditional" is now false and was rewritten rather than left to mislead the next reader. Mutation results, each asserting its anchor applied first: N1 130/143 unconditional again (the reported defect) RED N2 130/143 ALWAYS skipped (deletes the cancelled signal) RED <- positive control N3 the flag test inverted RED N4 only 130 consults the flag, 143 forgotten RED N5 blanket skipped-wins hoisted ahead of the case RED N5b only the failure branch consults the flag RED N2 is the control that matters: without it, "renders skipped" would be satisfied by a change that never renders cancelled at all, which would silently delete the interrupted-install signal. N5b exists because N5 tripped an earlier assertion (exit_code) before reaching the failure-swallowing one — it mutates only the `*)` branch, leaving exit_code untouched, and proves that assertion is live rather than decorative. telemetry-vocabulary-agreement.sh still passes unchanged: the case statement gains no new event-name literal, and its (b) sweep already exercised 130 x skipped=1, so the input domain was already derived from the producer. scripts/manifest.sha256 regenerated (Static analysis R8). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 19, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit ccbbbbf. Configure here.
…ml (backend#1907) The telemetry canary tests feed a fabricated `curl -u $CANARY:hunter2` through the redaction guards to prove they strip credentials. The value is never a real secret, but a git-mode range scan keeps re-finding it in earlier commits of this branch (commit 72af29e) even after the fixture was refactored, so a code change cannot clear it. Per code-quality.yml, a deliberate false positive belongs in .gitleaks.toml (commit-independent), not the baseline. Scoped to the exact canary match; default rules extended. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regenerated scripts/manifest.sha256 from the merged tree (client#741, client#752 landed the installer changes on develop). gen-manifest.sh --check passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
…review) Both findings from Arturo's review. Built on top of b4ab27b (a concurrent session's develop merge) rather than a second resolution of my own -- see the note at the end, which is the more useful half of this message. FIX 1 -- `make parse` failed open, and #753 had just promoted it to be the entire content of the required `Lint` check. `find scripts ... | xargs -0 -n1 bash -n` loses find's status across the pipe (recipes run under dash, no pipefail) and xargs with no input runs nothing and exits 0, so in a tree with no scripts/ dir it printed find: scripts: No such file or directory all shell scripts parse and exited 0, under both shells. It now materialises the list, refuses a zero count, and prints the derived count. Proven four ways: missing dir -> non-zero, zero .sh files -> non-zero, real parse error -> non-zero, clean tree -> "all 39 shell scripts parse". The first attempt at that third anchor was INERT -- `if [ -z "$x" ; then` is valid bash, the missing bracket being a runtime not a parse error -- so it was redone with an unterminated `if`, after checking that bash -n rejects it standalone. FIX 2 -- the superset claim was true set-wise and false scope-wise. The deleted jobs swept their 34 files on every run; `quality / shellcheck` reads only the PR diff, its caller passes all-files: false and declares no schedule. So #753 as written left NO CI job doing a whole-tree error-severity sweep -- demonstrable on #754 itself, where that job is green in 8s reading "Shell files to check: 0". `Lint` therefore runs `make lint` (parse + derived whole-tree shellcheck) rather than `make parse`. Costs no install, shellcheck being preinstalled is the whole finding, and it restores `make lint` == CI `Lint` parity (backend#1850). The two survivors are complementary: whole-tree here, diff-scoped in the org job, neither installing anything, neither holding a file list. Verified green on the 44-file derived set under the RUNNER's shellcheck 0.9.0 via koalaman/shellcheck:v0.9.0, not merely a local 0.11.0, before arming it on a required check. WHY THIS SITS ON b4ab27b. I had resolved the same develop conflict locally and resolved the Makefile with `git checkout --ours`, which takes the whole file and so silently DROPPED develop's non-conflicting addition of `scripts/tests/telemetry-vocabulary-agreement.sh` to the `drift` target. Nothing would have failed: the guard would simply have stopped running. b4ab27b kept it, so that resolution is the base and mine was discarded. This is the same shape as the #755 hazard flagged on the PR -- the damage from a merge is in what disappears without a marker, not in what conflicts -- and it caught me one hour after I wrote that warning. The conflict itself is the thesis: to add one script, #747 had to hand-edit the enumerated list in three places. The derivation picked it up with no edit, 42 -> 44 on its own. Refs #753
saadqbal
left a comment
There was a problem hiding this comment.
Post-hoc read (this is already on develop as c564a15), and a privacy-focused one: every field the record can carry, traced to its source.
The short version is that the shape gate holds. Nine attributes, all of them either a closed vocabulary checked at the render boundary (event.name, phase, client_state, error.type, source) or a bounded integer (exit_code, duration_ms, cli_on_path, source_line, the eight phase_*_ms). _telemetry_attr is the only writer for all of them, it drops rather than repairs, and [[ =~ ]] closes the newline hole a grep would have left. I could not find a free-text channel: TB_ERR_CMD is never read, TB_ERR_LOC is reduced to a basename that must be in the source vocabulary, and there is no stderr capture anywhere in the file. The bats "derived guard" walks the whole rendered object and requires every value to be int-or-token, which is a much better test than greping for the absence of a canary.
Two things about enforcement rather than shape, neither of them a defect here:
The seven resource fields are printf'd straight into the JSON and do not pass through _telemetry_attr — so the "one writer" argument in the header covers attributes only. Each of the seven is independently constrained today (installer/install constants, three closed case sets, the version regex, a $RANDOM id), so nothing gets past. But there is no allowlist of field names, and the token shape ^[A-Za-z0-9._-]{1,64}$ admits an identifier as readily as it refuses a path: alice, db-01.internal, hospital-vm-03.example.com all match it. What is enforced is "no path, no credential, no whitespace", which is what the title claims; "no identifier" is currently a property of the code's shape rather than of a gate. backend#2165's allowlist is the stronger form if this grows.
Opt-out is honoured on every path that can emit, because telemetry_emit_outcome is the only one and the check sits ahead of both render and deliver. It is documented in --help and the golden — but not in install-k8s.sh's own "Environment variable overrides" header block (where TRACEBLOC_ALLOW_NETWORK_FS, TRACEBLOC_SKIP_LEFTOVER_GUARD etc. live), not in docs/SECURITY.md, and install-k8s.ps1 has no equivalent. Worth a follow-up rather than a change here. The help copy also doesn't mention that a file appears at $HOST_DATA_DIR/telemetry/pending.jsonl and keeps 50 runs; a customer reading "records ONE outcome event per run" wouldn't expect that.
On the parts of the brief that turned out to be moot: there is no transport. No curl, no POST, no endpoint, no token, nothing with a timeout — delivery is log plus a local 0600 spool in a 0700 dir. So the installer cannot block, cannot hang on an air-gapped site, and cannot fail an install on a telemetry error, and there is no -k/--insecure question to ask. Nothing leaves the customer's machine in this PR at all; the review that actually decides "does customer data leave the environment" is #1905/#1906, not this one.
Exactly-once holds: the _TB_TELEMETRY_EMITTED latch is set before any other work in telemetry_emit_outcome, and the run-started latch keeps --help out. Zero-times cases are all deliberate and documented in the code — install.sh's own fetch/manifest/cosign failures (different process, different EXIT trap), --diagnose (clears the trap), prepare-host (swaps it). The outcome is right on the paths that matter, including the two that were wrong earlier in the branch (declared exit 2, and Ctrl-C on the skipped handoff).
Manifest: regenerated correctly. I recomputed all 19 entries against the tree at this head rather than trusting the diff — every one matches, including the three files whose content changed and the new telemetry.sh.
Coordination note for backend#2123, not a defect here: its metric filters key on service.name and deployment.environment, and both live inside the nested resource object in this record, so a filter written as $.service.name won't match — it needs $.resource.service.name. Worth settling before the filters are cut, since nothing emits to CloudWatch until #1905 anyway.
Split is roughly 1050 lines of implementation and wiring against 1830 of tests and drift guards, plus 10 of help copy. The vocabulary-agreement script is the best thing in the PR — deriving the sets from their producers and failing closed on an inert parse is the right shape, and exercising the classifier over its cross-product instead of restating it is genuinely better than a second declaration.
Two nits inline.
(Process note: I did not run /code-review on this one — this session's working directory is the tracebloc-engine checkout, not client, and that skill resolves its target against the cwd, so it would have reviewed the wrong repository's diff. Everything above is from a direct read of origin/pull/747/head.)
| # over a corpus, each side evaluated by the operator its own file uses. The byte | ||
| # check alone was not enough and said it was: the two regexes were identical | ||
| # while install.sh's `[[ =~ ]]` refused an input this file's `grep -qE` admitted. | ||
| TB_TELEMETRY_VERSION_RE='^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.]+)?$' |
There was a problem hiding this comment.
nit: this is the one field in the record with no length bound — ([.-][A-Za-z0-9.]+)? is open-ended where the token shape is {1,64} — and service.version is also one of the seven resource fields that go straight into the printf rather than through _telemetry_attr, so the "one writer" argument doesn't cover it.
The "install.sh refuses anything that isn't a release tag" reasoning doesn't constrain it either. common.sh:1128 is TB_VERSION="${TB_VERSION:-${TRACEBLOC_INSTALL_REF:-}}", so the caller's own TB_VERSION wins over the ref the bootstrap exported — and on the direct ./install-k8s.sh path (or under TRACEBLOC_ALLOW_UNVERIFIED=1) install.sh's gate never ran at all. TB_VERSION=v1.0.0-alice.hospital.internal ./install-k8s.sh lands verbatim in the version column.
Not a leak — the charset has no /, :, @ or space, so a path or a proxy credential still can't fit — but it's an unbounded, environment-supplied string in a record where everything else is capped. A {1,32} on the tail would close it.
| 'v1.9.3.post1' # the dotted suffix it allows | ||
| 'main' # a branch name: both must refuse | ||
| 'v1.9' # two segments: both must refuse | ||
| 'v1.9.3-'"$(printf '%064d' 0)" # 64 trailing chars: both must refuse |
There was a problem hiding this comment.
nit: this row doesn't test what the comment says. ([.-][A-Za-z0-9.]+)? matches 64 zeros, so v1.9.3-000…0 (71 chars) is admitted by both gates, not refused:
$ RE='^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.]+)?$'
$ c="v1.9.3-$(printf '%064d' 0)"; [[ $c =~ $RE ]] && echo admitted
admitted
The check still passes, because it only asserts the two gates agree — but the row is proving "both admit" while the comment reads as a length bound being exercised. Same underlying point as the note on TB_TELEMETRY_VERSION_RE.
COMMENTS (Asad, #754) -- three he named plus one he did not, since the point of his finding is that a stale comment is this PR's whole subject: * `lint:` still said the halves "live in different places" and `parse:` still said `Lint` runs `make parse`. Both were true of the previous commit and false after it. Fixed. * `check:` said "the 42 the derivation actually finds" -- 44 on this head. * the header block also carried a bare "yields 42", which he did not flag. Rather than update the numbers, the counts are GONE from the prose. They move -- 42 when #753 was measured, 44 once #747 landed -- and both targets already print their live count when they run. That is the BATS_TEST_COUNT reasoning three declarations up, applied to the thing that just rotted twice in one day. The one surviving figure is pinned to a commit ("measured on develop at 8de5d64") so it reads as history rather than as current state. BATS INSTALL -- bounded and retried, in both workflows. This is the finding landing on its own author: the first draft left these two apt calls alone on the grounds that hardening installs was #753's scope, and then `unit-bash`'s `Install bats` stalled at 11:58 today, burned the whole 10-minute budget and reported `cancelled` with an empty log and `Run bats` skipped -- the exact signature #753 documents for shellcheck. A required check is the wrong place to leave a known unbounded external call. Sizing is measured, not guessed, and the first attempt at it was WRONG: 120 s bounds x 3 attempts plus backoff is 780 s worst case, which overruns the 600 s budget and would have reported `cancelled` again -- reintroducing the bug the bound exists to remove. Healthy numbers are install 9 s and bats 155 s (measured from run history), so: 60 s bounds, 3 attempts, backoff only BETWEEN attempts (10 + 20, no trailing sleep), worst case 390 s + ~155 s of bats, and unit-bash's budget raised 10 -> 15 to match its sibling. ~9 min inside 15. Two details that are load-bearing: * `sudo timeout`, not `timeout sudo` -- timeout must be root to signal apt-get directly instead of signalling sudo and trusting the TERM to be forwarded. * `if [ "$i" -lt 3 ]; then sleep; fi`, not `[ ... ] && sleep` -- the default Actions shell is `bash -e`, where a bare test returning false on the last iteration aborts the script BEFORE the ::error:: line prints, turning a diagnosed failure back into a silent one. * `-qq` dropped from the install and `bats --version` kept as the proof-of-install marker: its absence is what identified the stall. Verified by extracting the LIVE run block out of the YAML with a yaml parser and executing it under `bash -e` against a stubbed apt-get -- not a retyped copy. Always-fails -> 3 warnings, the ::error:: line, exit 1, 30 s. Fails-once -> warning then success. Healthy -> version marker, exit 0, no sleep. Refs #753
LukasWodka
commented
Aug 19, 2026
/fr-pass Best-effort functional review passed (triage: merged clean, non-interactive; behavioral evidence limited while e2e journey is red — backend#2206). Advancing to Ready for prod. |

Parent epic: tracebloc/backend#1872 · Ticket: tracebloc/backend#1907 · Contract: the telemetry contract · Sibling: tracebloc/cli#527
The installer is the highest-variance, least-observed step in the product: it
runs on machines we have never seen, under package managers, proxies and shells
we do not control, and it reports to nobody. Each of the backend#736 failures
was invisible until a customer happened to mention it.
scripts/lib/telemetry.shemits one contract-shaped event per install, frominstall_cleanup— the EXIT trap — so it fires on every path including theinterrupted one and the one that dies under
errexit. That is where §6.5's"terminal event on every path" is actually honoured; anywhere else and the
failure rate is uncomputable.
What is emitted
install.run.succeeded/.failed/.cancelled, with:tracebloc.install.phasepreflight…connect, from the closed a–f settracebloc.install.phase_<name>_mstracebloc.install.duration_ms/.exit_codetracebloc.install.client_statetracebloc.install.cli_on_patherror.typetracebloc.install.source/.source_lineservice.name/tracebloc.componentinstaller/install(§10.1)service.version,os.type,host.arch,deployment.environment,service.instance.idCtrl-C (130/143) is
cancelledand carries noerror.type."No arguments, no paths, no data" is a shape, not a rule
Every value goes through one writer,
_telemetry_attr, which admits a stringonly if it matches
^[A-Za-z0-9._-]{1,64}$and an integer only if it is one. Afilesystem path contains
/. A proxy credential contains:and@. A tokenis longer than 64 characters. A person's name contains a space. Values that
fail are dropped, never trimmed or escaped — a value that had to be repaired
to be safe is a value we did not understand, and shipping our guess about it is
how a redactor leaks.
The phase and the client state are additionally checked against their closed
sets at the render boundary. That line exists because of a test failure: a
canary assigned straight to
TB_TELEMETRY_PHASEreached the record, and it wasshaped exactly like a legal value, so the token regex waved it through.
telemetry_phase_beginis the only writer and already closes the set — but"the only writer is careful" is a property that stops being true silently.
TB_ERR_CMDis deliberately never emitted (unexpanded command text is stillfree text), and
service.instance.idis a fresh per-run value rather than thehostname — field hostnames here are overwhelmingly
<firstname>-macbook, which§7.3 forbids outright.
The vocabularies are derived, and a required check proves it
scripts/tests/telemetry-vocabulary-agreement.shholds no copy of anyvocabulary. It parses:
install-k8s.sh'sstep_headercalls → the phase letterssummary.sh'sCLIENT_STATEwriters → the client statesgen-manifest.sh'sFILESarray → the source basenamesinstall.sh's immutable-release-tag regex → theservice.versionshapecommon.sh'sprint_help→ the documented opt-out variablesand for the error classes — which have no second declaration to parse — it
exercises
telemetry_error_classover the full cross-product of its twoclosed input sets, checking both that every answer is registered and that
every registered class is reachable. Comparing a declaration to itself would be
self-consistent and therefore blind.
It runs in
drift-checks.yaml'sSource-of-truth driftjob, which is arequired check — a guard in a job nobody must wait for is advice, not a gate.
It found something on its first run:
summary.sh's ownCLIENT_STATEdocstring listed five states while
_diagnose_not_readyproduces six(
image_pull_ca, added by #424, never made it into the comment). Fixed here.Two real bugs the tests caught — both fatal, both invisible to unit tests
Same shape twice:
grep -qcloses the pipe at its first hit, so under theinstaller's
set -o pipefaila producer takes SIGPIPE and the pipeline returns141 — on a successful match. This is the backend#1778 defect, already
documented in
summary.sh's own comments.printf '%s' "$key" | grep -qE …in the shape checks.tr -dc 'a-f0-9' < /dev/urandom | head -c 16for the instance id — this oneat source time, so it killed the entire installer under
set -ebeforeit printed a line.
Every unit-level test passed throughout both. The only thing that went red was
the test that drives
install_cleanupfor real, under the installer's own shelloptions. That test stays, and a second one now exercises the whole surface under
set -euo pipefailexplicitly.Mutation proofs
Every guard broken, watched go red, restored — and every anchor asserted to
have applied, because an inert mutation and real coverage are identical in a
log.
CLIENT_STATEpassed through unchecked_telemetry_attrsanitises instead of droppingurandom | headSIGPIPE pipeline restoredCLIENT_ENVexported under a guessstep_headerstops driving the phase clocksummary.shgains an unknown client stateinstall.shTwo mutations were rerun after strengthening a test that had stayed green:
CLIENT_STATEand the phase were being tested with a value containing/,which
_telemetry_attr's shape refuses regardless — so the test proved theshape guard and said nothing about the vocabulary guard. It now uses
degraded/verifying, which are shape-legal and exactly what a future editto
summary.shwould produce.One mutation came back inert and is reported as such rather than counted:
putting
_telemetry_attr's key check back through a pipe reddens nothing,because for a short key
printfcompletes beforegrep -qexits and no SIGPIPEoccurs. The here-string is still the correct idiom (and is what fixes the value
checks, where the input can be long), but this PR does not claim coverage it
does not have.
What remains to be connected
The transport, and only the transport. The ticket said "rides the gateway
and token"; the 17 Aug decision replaced the Collector gateway with an ingest
endpoint on the backend (rfcs#28),
which is tracebloc/backend#1905, in flight and not yet available.
_telemetry_delivertherefore writes the install log and a bounded, 0600local spool at
$HOST_DATA_DIR/telemetry/pending.jsonl(last 50 events, in a0700 directory) — which tracebloc/backend#1906's forwarder can drain — and posts
nothing. That one function is the whole change when #1905 lands.
The ticket body has been corrected so it no longer says "blocked by the gateway".
Would the three named field failures now be visible?
~/.local/bin, PATH advice only printedtracebloc.install.cli_on_path=0rides every event.install-cli.shhas always computed this — it asks whether a fresh login shell resolvestracebloc— and only ever printed advice about it. It becomes a rate.apt-gethung on the dpkg locktracebloc.install.phase_prerequisites_msrides the success event too — that failure produces no non-zero exit at all, it just takes twenty minutes. It also rides the cancel and failure events, for the phase that was still running when the process ended, which is the likelier real shape: stuck twenty minutes and then killed. (That second half was broken in the first two commits and is Bugbot finding 3 — see the comments; the phase durations now sum exactly toduration_ms, which is asserted.)cluster infoon the wrong kubeconfig contextTwo of the three are this PR's; the third is the sibling's.
Also here
install-bootstrap.batsheld a hand-written second copy ofinstall.sh'sFILESarray, in two places — so adding one lib turned ten unrelatedsupply-chain tests red. It now derives the list from the array (the same parse
gen-manifest.shuses) and fails closed on an inert parse. That is a landmineremoved, not a workaround for this PR.
scripts/manifest.sha256regenerated;telemetry.shadded to bothFILESarrays, which
gen-manifest.shcross-checks.--helpgains a "Usage reporting" section; the copy golden is regenerated.Review history
Bugbot found 5 issues across 4 rounds: 4 fixed, 1 demoted with measured
evidence (the mechanism does not exist on bash 3.2 or 5.x). In order of what they
would have cost:
mkdirdisarmed the NFS guard —_telemetry_delivercreatedHOST_DATA_DIRfrom the EXIT trap on the very path whereearly_data_dir_guardhad just refused it, and that guard skips an existingdirectory by design. Run 1 refused and created it; run 2 installed MySQL onto
NFS. Customer database corruption, reintroducing client#432.
--helpemittedinstall.run.succeeded— a free success in the denominatorof the failure rate this ticket exists to produce.
its likeliest form, and
phase_connect_msabsent on every success. Thisundercut the DoD claim in the table above.
logis ano-op until
setup_log_file, which runs after the guards, so the NFS refusalitself became permanently invisible. Events now fall back to a
mktemp'd filein
$TMPDIR. My own NFS test had been masking this by settingLOG_FILE=/dev/null.argument position does not propagate its status, measured on bash 3.2 and 5.x.
All four real ones were invisible to my own tests, for the same reason: they
were about which code paths reach the emitter, not about what the emitter does
with its inputs — and one of my tests was actively masking a bug. No unit test of
telemetry_render_eventcan see any of them.Every replacement test now drives the real
install-k8s.sh,install_cleanup,_assess_handoff, orearly_data_dir_guard. Full reproductions and mutationtables are in the PR comments.
Two of my own mutations came back inert and were fixed by moving the tests end
to end rather than by counting them; a third is reported as inert rather than
counted. Details in the comments.
Test plan
make checkgreen (lint, drift incl. the new guard, helm-lint, helm-vocab);bats scripts/tests/*.batsgreen (31 new tests);shellcheck -S warning -xclean on every new file;bash -nclean;gen-manifest.sh --checkcurrent; the mutations above plus 10 more from the review rounds (see comments).
🤖 Generated with Claude Code
Note
Medium Risk
Changes installer EXIT-trap behavior and data-dir/spool logic on failure paths (including NFS refusal); mistakes could skew failure metrics or recreate dirs the guard rejects, though transport is local-only and heavily tested.
Overview
Adds
scripts/lib/telemetry.shand wires it through the bash installer soinstall_cleanupemits one contract-shaped outcome (install.run.succeeded/.failed/.cancelled/.skipped) per committed run, with phase timings, exit code, closed vocabularies for errors/client state, andcli_on_path— values pass a strict shape gate so paths and credentials cannot be recorded. Delivery is log + boundedpending.jsonlspool only until backend ingest exists; opt-out viaTRACEBLOC_NO_TELEMETRY/DO_NOT_TRACK.Integration:
telemetry.shis fetched and manifest-signed;step_headerdrives the phase clock;telemetry_run_startedskips--help/terminal exits;telemetry_run_skipped/telemetry_rerun_handoffdistinguish healthy handoffs from real failures vs stray exit2; GPU rebootexit 2declares the handoff.--helpdocuments usage reporting.Guards & tests:
telemetry-vocabulary-agreement.sh(required drift job,make drift, shellcheck) derives phases, client states, sources, version regex, and event names from producers;telemetry.batsexercises trap paths, NFS/pre-log/bootstrap TMPDIR fallbacks, and privacy.install-bootstrap.batsparsesinstall.sh’sFILESinstead of duplicating the list;summary.shcomment aligns withimage_pull_ca;.gitleaks.tomlallowlists telemetry canary fixtures.Reviewed by Cursor Bugbot for commit 9a8d62c. Bugbot is set up for automated code reviews on this repo. Configure here.