Skip to content

feat(telemetry): one outcome event per install, with nowhere to put a path (backend#1907) - #747

Merged
LukasWodka merged 12 commits into
developfrom
feat/1907-installer-outcome-telemetry
Aug 19, 2026
Merged

feat(telemetry): one outcome event per install, with nowhere to put a path (backend#1907)#747
LukasWodka merged 12 commits into
developfrom
feat/1907-installer-outcome-telemetry

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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.sh emits one contract-shaped event per install, from
install_cleanup — the EXIT trap — so it fires on every path including the
interrupted 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:

layerattributenotes
recordtracebloc.install.phasepreflightconnect, from the closed a–f set
recordtracebloc.install.phase_<name>_mssix fixed keys, one per phase
recordtracebloc.install.duration_ms / .exit_codeints
recordtracebloc.install.client_statesummary.sh's closed diagnosis set
recordtracebloc.install.cli_on_paththe #736 PATH case, as a 1/0
recorderror.typeclosed vocabulary, failures only (§8.4)
recordtracebloc.install.source / .source_linebasename + line, never the path
resourceservice.name / tracebloc.componentinstaller / install (§10.1)
resourceservice.version, os.type, host.arch, deployment.environment, service.instance.id

Ctrl-C (130/143) is cancelled and carries no error.type.

"No arguments, no paths, no data" is a shape, not a rule

Every value goes through one writer, _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
filesystem path contains /. A proxy credential contains : and @. A token
is 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_PHASE reached the record, and it was
shaped exactly like a legal value, so the token regex waved it through.
telemetry_phase_begin is the only writer and already closes the set — but
"the only writer is careful" is a property that stops being true silently.

TB_ERR_CMD is deliberately never emitted (unexpanded command text is still
free text), and service.instance.id is a fresh per-run value rather than the
hostname — 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.sh holds no copy of any
vocabulary
. It parses:

  1. install-k8s.sh's step_header calls → the phase letters
  2. summary.sh's CLIENT_STATE writers → the client states
  3. gen-manifest.sh's FILES array → the source basenames
  4. install.sh's immutable-release-tag regex → the service.version shape
  5. common.sh's print_help → the documented opt-out variables

and for the error classes — which have no second declaration to parse — it
exercises telemetry_error_class over the full cross-product of its two
closed 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's Source-of-truth drift job, which is a
required 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 own CLIENT_STATE
docstring listed five states while _diagnose_not_ready produces 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 -q closes the pipe at its first hit, so under the
installer's set -o pipefail a producer takes SIGPIPE and the pipeline returns
141 — on a successful match.
This is the backend#1778 defect, already
documented in summary.sh's own comments.

  1. printf '%s' "$key" | grep -qE … in the shape checks.
  2. tr -dc 'a-f0-9' < /dev/urandom | head -c 16 for the instance id — this one
    at source time, so it killed the entire installer under set -e before
    it printed a line.

Every unit-level test passed throughout both. The only thing that went red was
the test that drives install_cleanup for real, under the installer's own shell
options. That test stays, and a second one now exercises the whole surface under
set -euo pipefail explicitly.

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.

mutationreddened
the phase is trusted at the render boundary2 tests
CLIENT_STATE passed through unchecked2 tests
_telemetry_attr sanitises instead of dropping1 test
the source attribute emits the whole location1 test
the version shape loosens to the generic token1 test
the urandom | head SIGPIPE pipeline restored2 tests
the opt-out is ignored1 test
the emit-once latch removed1 test
cancel folded into failed3 tests
an unknown CLIENT_ENV exported under a guess1 test
the spool made unbounded1 test
step_header stops driving the phase clock2 tests
a 7th install step added with no phase nameagreement guard
summary.sh gains an unknown client stateagreement guard
a script dropped from the source vocabularyagreement guard
the classifier returns an unregistered answeragreement guard
a class registered that nothing producesagreement guard
the version regex drifts from install.shagreement guard
the phase parse made inertexit 2, fail closed
opt-out doc/code drift, both directionsagreement guard
the bootstrap fixture's FILES parse made inertsetup fails closed

Two mutations were rerun after strengthening a test that had stayed green:
CLIENT_STATE and the phase were being tested with a value containing /,
which _telemetry_attr's shape refuses regardless — so the test proved the
shape guard and said nothing about the vocabulary guard. It now uses
degraded / verifying, which are shape-legal and exactly what a future edit
to summary.sh would 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 printf completes before grep -q exits and no SIGPIPE
occurs. 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_deliver therefore writes the install log and a bounded, 0600
local spool
at $HOST_DATA_DIR/telemetry/pending.jsonl (last 50 events, in a
0700 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?

#736 casevisible?
CLI on ~/.local/bin, PATH advice only printedYes.tracebloc.install.cli_on_path=0 rides every event. install-cli.sh has always computed this — it asks whether a fresh login shell resolves tracebloc — and only ever printed advice about it. It becomes a rate.
apt-get hung on the dpkg lockYes.tracebloc.install.phase_prerequisites_ms rides 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 to duration_ms, which is asserted.)
cluster info on the wrong kubeconfig contextNot here — that is the CLI's, and it is covered in tracebloc/cli#527 (with an honest limit stated there: a run that succeeds against a wrong-but-valid context still looks like a success).

Two of the three are this PR's; the third is the sibling's.

Also here

  • install-bootstrap.bats held a hand-written second copy of install.sh's
    FILES array, in two places — so adding one lib turned ten unrelated
    supply-chain tests red. It now derives the list from the array (the same parse
    gen-manifest.sh uses) and fails closed on an inert parse. That is a landmine
    removed, not a workaround for this PR.
  • scripts/manifest.sha256 regenerated; telemetry.sh added to both FILES
    arrays, which gen-manifest.sh cross-checks.
  • --help gains 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:

  1. the spool's mkdir disarmed the NFS guard_telemetry_deliver created
    HOST_DATA_DIR from the EXIT trap on the very path where
    early_data_dir_guard had just refused it, and that guard skips an existing
    directory by design. Run 1 refused and created it; run 2 installed MySQL onto
    NFS. Customer database corruption, reintroducing client#432.
  2. --help emitted install.run.succeeded — a free success in the denominator
    of the failure rate this ticket exists to produce.
  3. the still-running phase had no duration — the dpkg-lock case invisible in
    its likeliest form, and phase_connect_ms absent on every success. This
    undercut the DoD claim in the table above.
  4. pre-log failures were reported nowhere — fallout from fixing (1): log is a
    no-op until setup_log_file, which runs after the guards, so the NFS refusal
    itself became permanently invisible. Events now fall back to a mktemp'd file
    in $TMPDIR. My own NFS test had been masking this by setting
    LOG_FILE=/dev/null.
  5. source lookup aborts the whole event — demoted; a command substitution in an
    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_event can see any of them.
Every replacement test now drives the real install-k8s.sh, install_cleanup,
_assess_handoff, or early_data_dir_guard. Full reproductions and mutation
tables 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 check green (lint, drift incl. the new guard, helm-lint, helm-vocab);
bats scripts/tests/*.bats green (31 new tests); shellcheck -S warning -x clean on every new file; bash -n clean; gen-manifest.sh --check
current; 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.sh and wires it through the bash installer so install_cleanup emits one contract-shaped outcome (install.run.succeeded / .failed / .cancelled / .skipped) per committed run, with phase timings, exit code, closed vocabularies for errors/client state, and cli_on_path — values pass a strict shape gate so paths and credentials cannot be recorded. Delivery is log + bounded pending.jsonl spool only until backend ingest exists; opt-out via TRACEBLOC_NO_TELEMETRY / DO_NOT_TRACK.

Integration:telemetry.sh is fetched and manifest-signed; step_header drives the phase clock; telemetry_run_started skips --help/terminal exits; telemetry_run_skipped / telemetry_rerun_handoff distinguish healthy handoffs from real failures vs stray exit 2; GPU reboot exit 2 declares the handoff. --help documents 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.bats exercises trap paths, NFS/pre-log/bootstrap TMPDIR fallbacks, and privacy. install-bootstrap.bats parses install.sh’s FILES instead of duplicating the list; summary.sh comment aligns with image_pull_ca; .gitleaks.toml allowlists telemetry canary fixtures.

Reviewed by Cursor Bugbot for commit 9a8d62c. Bugbot is set up for automated code reviews on this repo. Configure here.

… 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>
@LukasWodkaLukasWodka self-assigned this Aug 18, 2026
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

⚠️quality / gitleaks is red, and it needs one command from a human

What it found: the curl-auth-user rule, on a test fixture in
scripts/tests/telemetry.bats that spelled out curl -u $CANARY:hunter2 …. It
was a canary — the whole point of that test is to prove TB_ERR_CMD never
reaches the record — but the rule is right: a source file containing that
spelling is a finding whatever the surrounding test claims, and a reviewer
scanning a diff cannot tell a canary from the real thing at a glance.

Already fixed in the code (f495186): the fixture now carries a path instead
of a credential, and the credential half of the same assertion is covered by
HTTPS_PROXY, which encodes user:pass in a proxy URL — what a hospital
network actually configures, and what gitleaks does not flag.

Why the check is still red: gitleaks scans the commit range
(3a618e2..f495186, "2 commits scanned"), so the line is still found in the
first commit's diff even though it no longer exists in the tree.

The fix, and why I have not applied it. The remedy is to squash the branch so
the blob never appears in any commit:

git rebase -i --autosquash origin/develop # or: git reset --soft origin/develop && git commit
git push --force-with-lease

I do not force-push without being asked to — that is an explicit rule for AI
sessions in this repo, and it applies to my own branches too.

What I deliberately did NOT do: add this to .gitleaks-baseline.json. That
file is for findings that predate the gate; putting a brand-new one in it to
make my own PR green would be weakening a security check to avoid a rebase.

Everything else on this PR is green.

Comment threadscripts/lib/telemetry.sh
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadscripts/lib/telemetry.sh
Comment threadscripts/lib/common.sh
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

Bugbot round 1 — one fixed, one demoted with evidence

✅ "Non-install exits report success" (High) — real, fixed

Reproduced exactly as described:

$ HOST_DATA_DIR=$T bash scripts/install-k8s.sh --help
$ cat $T/telemetry/pending.jsonl
{"resource":{…},"attributes":{"event.name":"install.run.succeeded",
"tracebloc.install.phase":"bootstrap","tracebloc.install.exit_code":0,…}}

This is the worst possible bug in this PR and the severity is right: --help is
the command people run most while a real install is broken, and a free success
in the denominator moves the failure rate — the one number the whole ticket
exists to produce — in the direction that hides the problem.

Fix: a run-started latch, not a phase test. main() calls
telemetry_run_started once the terminal commands (--help, --diagnose,
prepare-host) have had their chance to dispatch; telemetry_emit_outcome
returns early without it. A latch rather than "phase != bootstrap" because a
genuine failure in the bootstrap phase — the leftover-data guard,
validate_configis an install attempt and must still be reported.

Second, related thing found while fixing it: the assess handoff exits 0
having run no step, so counting it as install.run.succeeded would make the
success count grow 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 do people re-run an installer that was
already done" a question we can ask.

Not covered, deliberately:prepare-host. It is a different command with its
own registry component (§10.1 gives installer the components
install/preflight/upgrade), and reporting it as tracebloc.component=install
would be mislabelling it rather than measuring it.

⚠️ "Source lookup aborts whole event" (Medium) — mechanism does not exist

The predicted mechanism is that the unprotected command substitutions feeding
tracebloc.install.source abort telemetry_render_event under set -e and take
the whole outcome with them. A command substitution in an argument position
does not propagate its status to the enclosing command
— only an assignment
(x=$(false)) does. Constructed and measured on bash 3.2.57 (the system
bash on macOS, the version that actually matters here) and on 5.x:

$ TB_ERR_LOC='/home/someone/evil.sh:9' # deliberately not one of our scripts
$ telemetry_emit_outcome 1 # under set -euo pipefail
REACHED_END rc=0 spooled: 1

The event is rendered, the process survives, one line is spooled. Reported as
unreachable rather than fixed — a fix for a path that cannot be taken costs more
than the finding.

But the invariant is worth having, so it is now pinned: an unrecognised
source location drops the field, never the event
— asserted through a subshell
under set -euo pipefail, checking the event still renders, still carries
error.type, and carries neither tracebloc.install.source nor the rejected
filename.

Mutation proofs for the new code

Every new guard broken, watched go red, restored, anchor asserted to have
applied
:

mutationreddened
the run-started latch removed2 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 succeeded1 test
assess.sh stops marking the handoff skipped1 test
a bootstrap failure classified as something else1 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 (a
validate_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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

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

Copy link
Copy Markdown
ContributorAuthor

Bugbot round 2 — the third finding was real too, and it hit the DoD claim

I missed this one in my first reply: it came from Bugbot's earlier review pass
and my query only fetched the later one. It is the most consequential of the three.

✅ "Final phase duration never recorded" (Medium) — real, fixed

telemetry_phase_begin only closes a phase when the next one starts, and
nothing closed the active phase before render. Reproduced — a run cancelled after
22 minutes stuck in prerequisites:

"tracebloc.install.phase":"prerequisites"
"tracebloc.install.duration_ms":1330000
"tracebloc.install.phase_preflight_ms":0
← no prerequisites duration at all

This undercut the DoD claim in the PR body. I wrote that per-phase durations
make the dpkg-lock case visible. They did — but only in the version where the
install goes on to succeed. In its likeliest real form the user is stuck twenty
minutes in prerequisites and then kills it or gives up, never reaching step c,
and the number was silently absent. Likewise phase_connect_ms was missing on
every successful install: the readiness wait, up to READY_TIMEOUT (600s),
the single longest phase in the run.

And as you noted, the remainder trick could not recover it, because bootstrap
had no key at all — the loop iterated the letter map, and bootstrap has no step
letter. So download + verify + leftover-guard + assess time was an unnamed
remainder.

My own test passed throughout. "A slow phase is visible on a run that
SUCCEEDED" only ever measured a phase that a laterstep_header had closed —
the exact shape of a test that proves the easy half of its own claim.

Fix: the live delta is added at read time, not by a "close the phase" call
in the emit path, so telemetry_render_event stays idempotent (the tests call it
repeatedly; a render that mutated the accumulators would report different numbers
each time). The loop now iterates _telemetry_phase_names, so bootstrap and
unknown get keys too. The clock is read once per event, so the per-phase
numbers and the total are exactly consistent.

New invariant, asserted:the phase durations sum EXACTLY to duration_ms
every millisecond of the run attributed to exactly one phase, with the individual
attributions also pinned so a compensating pair of errors cannot pass, and a
counted == 4 anchor so an inert loop cannot.

That test needed a fake clock, and the reason is worth recording: 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. The first version of the test failed for exactly that reason — sum
1200000 against a total of 900000 — and the fixture was the bug, not the code.

Mutation proofs

mutationreddened
the active phase's live delta dropped (the original bug)3 tests
the loop reverts to the letter map (bootstrap unattributed)2 tests
the live delta counted for every phase (double counting)1 test
the clock read per attribute instead of onceinert — see below

The fourth is reported as inert rather than counted: _telemetry_now_ms has
second resolution (BSD date has no %N, and half the install base is macOS),
so two reads inside the same second are identical and the guard only matters
across a second boundary. It is kept because it is free, not because a test
proves it.


Running total across both rounds

3 findings, 2 fixed, 1 demoted with measured evidence. Two of my own
mutations came back inert along the way and were fixed by moving the tests end to
end rather than by counting them.

29 tests in telemetry.bats (up from 20), make check green, the required
Source-of-truth drift guard green.

quality / gitleaks remains red for the history reason documented above and
needs one force-push from a human.

@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadscripts/lib/telemetry.sh
…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

Copy link
Copy Markdown
ContributorAuthor

Bugbot round 3 — the most serious one yet, fixed

✅ "Spool mkdir undoes NFS guard" (High) — real, and worse than the first two

The first two findings corrupted a metric. This one corrupted a customer's
database. Reproduced exactly as described:

guard exit=1
HOST_DATA_DIR created on the REJECTED volume? YES
./nfs-volume/.tracebloc/telemetry

_telemetry_deliver ran from the EXIT trap and mkdir -p'd
$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 skips an existing directory on
purpose
([[ -d "$target" ]] && return 0 — "an EXISTING data dir has no at-risk
mkdir here", client#441), so:

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 the InnoDB corruption client#432 exists to prevent, reintroduced by the
telemetry that was only supposed to watch.

The principle I got wrong: an observer that changes the install's own
preconditions is not an observer. Adding a write to the EXIT trap put telemetry
upstream of a safety gate, and nothing in the design made me look for that.

Fix: the spool only writes into a data dir that already exists. A run that
dies before then 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). The forwarder (#1906) picks up every later run.

Both directions mutation-proven, because "don't write" is trivially satisfied
by "never write":

mutationreddened
the HOST_DATA_DIR existence check removed (guard disarmed again)the new test
delivery disabled outright (a "fix" that kills the feature)4 other tests

The regression test carries an anchor asserting the guard actually refused
(non-zero exit + the "network filesystem" message), so "no directory" cannot
silently mean "the fixture never ran the guard".


Final tally across three rounds

4 findings, 3 fixed, 1 demoted with measured evidence. In order of what they
would have cost:

  1. spool mkdir disarms the NFS guard → customer database corruption. Fixed.
  2. --help emits install.run.succeeded → the failure rate the whole ticket
    exists to produce, moved in the direction that hides problems. Fixed.
  3. the still-running phase had no duration → the dpkg-lock case invisible in
    its likeliest real form; phase_connect_ms absent on every success. Fixed.
  4. source lookup aborts the whole event → mechanism does not exist on bash
    3.2 or 5.x; demoted with a reproduction attempt, invariant pinned anyway.

Three of those were invisible to my own tests, and the pattern is consistent: all
three were about which code paths reach the emitter, not about what the
emitter does with its inputs. Unit tests of telemetry_render_event cannot see
any of them. Every replacement test now drives the real install-k8s.sh, the real
install_cleanup, the real _assess_handoff, or the real
early_data_dir_guard.

30 tests in telemetry.bats (up from 20). make check green, Source-of-truth drift green, full bats scripts/tests/*.bats green locally.

quality / gitleaks remains red for the commit-history reason documented earlier
and needs one force-push from a human.

@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadscripts/lib/telemetry.sh Outdated
…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

Copy link
Copy Markdown
ContributorAuthor

Bugbot round 4 — right again, and it was fallout from my own round-3 fix

✅ "Early failures drop outcome events" (High) — real, fixed

This is the sharpest of the four, because it is a consequence of the previous
fix
: stopping telemetry from disarming the NFS guard also made the NFS refusal
itself invisible.

Reproduced:

$ early_data_dir_guard # target reads as nfs, LOG_FILE unset as on the real path
guard exit=1
any telemetry written anywhere? 0

_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 aftervalidate_config and early_data_dir_guard — deliberately,
because client#432 refuses a network data dir before logging starts. So on
exactly those paths there was no log and (correctly, since the last commit) no
data dir, and the rendered event was dropped on the floor.

Those pre-log failures are also precisely the class the run-started latch was
built to preserve, so losing them quietly undid that work too.

And you were right about the test. My NFS test set LOG_FILE=/dev/null, which
made log appear to succeed and hid the whole thing. It now leaves LOG_FILE
unset, as the real path does, and asserts the refusal is reported. A second
test covers the general pre-log case.

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. #1906's forwarder
reads both locations.

The false comment is replaced with what actually holds and why — a comment that
claims something untrue is itself the defect.

mutationreddened
the fallback removed (early failures dropped again)2 tests
the fallback given a predictable shared path1 test
the fallback's 0600 mode dropped1 test
the data-dir existence test dropped (NFS guard disarmed again)2 tests

Final tally — 5 findings across 4 rounds

4 fixed, 1 demoted with measured evidence. Every single real one was about
which code paths reach the emitter, never about what the emitter does with its
inputs:

#findingwould have cost
1spool mkdir disarms the NFS guardcustomer database corruption (client#432 reintroduced)
2--help emits install.run.succeededthe failure rate, moved the way that hides problems
3the still-running phase has no durationthe dpkg-lock case, invisible in its likeliest form
4pre-log failures reported nowherethe NFS refusal, permanently invisible
5source lookup aborts the eventdemoted — mechanism absent on bash 3.2 and 5.x

No unit test of telemetry_render_event can see any of 1–4. Every replacement
test now drives the real install-k8s.sh, install_cleanup, _assess_handoff,
or early_data_dir_guard — and one of my own tests had been actively masking a
bug, which is the thing worth remembering out of all of this.

31 tests in telemetry.bats (up from 20). make check green, Source-of-truth drift green.

quality / gitleaks remains red for the commit-history reason documented earlier
and needs one force-push from a human.

@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.skipped will almost never fire on the curl | bash path. 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.sh re-runs. Fine, just much narrower than "how often do people re-run an installer that was already done".
  • quality / gitleaks is 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.

Comment threadscripts/lib/telemetry.sh
Comment threadscripts/lib/telemetry.sh Outdated
Comment threadscripts/lib/telemetry.sh
Comment threadscripts/lib/telemetry.sh Outdated
Comment threadscripts/lib/telemetry.sh Outdated
…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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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
saadqbal previously approved these changes Aug 18, 2026

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment threadscripts/lib/telemetry.sh Outdated
…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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadscripts/lib/telemetry.sh Outdated
Comment threadscripts/lib/telemetry.sh
saadqbal
saadqbal previously approved these changes Aug 18, 2026

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

Comment threadscripts/lib/telemetry.sh
…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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

LukasWodkaand others added 2 commits August 19, 2026 12:13
…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>
@LukasWodka
LukasWodka requested review from aptracebloc and removed request for saadqbalAugust 19, 2026 11:46
@LukasWodka
LukasWodka merged commit c564a15 into developAug 19, 2026
71 of 72 checks passed
@LukasWodka
LukasWodka deleted the feat/1907-installer-outcome-telemetry branch August 19, 2026 11:48
LukasWodka added a commit that referenced this pull request Aug 19, 2026
…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

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.]+)?$'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

LukasWodka added a commit that referenced this pull request Aug 19, 2026
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

Copy link
Copy Markdown
ContributorAuthor

/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.

Sign up for freeto 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.

2 participants

@LukasWodka@saadqbal