Uh oh!
There was an error while loading. Please reload this page.
feat(telemetry): the CLI validated events and delivered none (backend#2217) - #542
Conversation
…#2217) `pendingSink()` returned nil, so every command outcome was validated and dropped. The ingest endpoint exists now (backend#1905) and speaks OTLP/HTTP JSON (backend#2213), so this is the delivery half. Option (c), as decided on the ticket: one inline POST with a ~1s budget for the whole step, spool to disk on any failure, drain at most 20 pending on the next invocation. No background daemon, no goroutine outliving the process, no retry loop -- the next command IS the retry. The deciding argument against fire-and-forget is that in a short-lived CLI async delivery is a lie: a goroutine outliving main() is killed at exit, so the honest options are "always block" or "always drop on failure", and dropping on failure discards exactly the partition-time events worth having. Three things worth a reviewer's attention: 1. `anyValue` switches on reflect.Kind, not on concrete type. The emitter's `checkAttrValue` deliberately admits named scalar types (`type Reason string`, time.Duration via telemetry.Duration) by kind; a `switch v := value.(type)` at the seam would match the dynamic type, miss those, and silently drop the values the layer above went out of its way to accept. 2. A 4xx other than 401/403/408/429 DISCARDS the batch. The endpoint answers 400 for a wholly unparseable batch; re-spooling that would wedge the spool forever, re-sent by every future command and pushing good records out at the cap. 401/403 retry because they are a credential state, not a payload verdict -- the next login makes them deliverable. 3. The spool keeps drop-OLDEST, which is not a contradiction of D7's amended drop-newest row. That row says drop-newest because `exporterhelper` sheds at the entrance and offers nothing else -- a platform constraint on the edge Collector. This spool is our own code and can do what D7 originally wanted, so it does, matching the installer's `tail -n` trim. `deliver` takes a resolved URL rather than an env. Found by its own test: with `api.BaseURL(env)` computed inside, the test posted to PRODUCTION. Passing the URL also means the record's label and its destination cannot disagree. No `timeUnixNano`. Per the #2213 decision no client-side timing is sent, so the receiver stamps arrival and "how late" is knowingly invisible; adding an event clock is a contract change, not a drive-by here. Verified: `make check` green (vet, full test suite, fmt, file-budget, style, tool-pins) and `make lint` clean (errcheck, ineffassign, misspell, staticcheck). Nine mutations run against the nine new assertions -- collapse the resourceLogs entries, encode int64 as a number, swap reflect for a type switch, invert the trim, retry a 400, send the legacy `Token` keyword, widen the spool to 0644, drop on retry, drop when unauthenticated -- all nine killed their test, none survived, none merely broke the build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
saqlainsyed007
left a comment
There was a problem hiding this comment.
This is a careful PR — the "async delivery is a lie in a short-lived CLI" framing, the reflect.Kind seam (I confirmed anyValue switches on kind so named scalar types survive), the drop-oldest spool with the D7 rationale, and the 4xx-discard-vs-401/403-retry split are all well-reasoned and the mutation table is exactly the right way to prove the guards. But I'm concurring with the open Bugbot finding at telemetry_transport.go:108 — it's a real High defect, verified against the code, and it defeats this PR's own wire-mapping guarantee #2 on the path that matters most:
The spool round-trip recodes integers as doubles.spooledEvent.Attributes is map[string]any (telemetry_otlp.go:37), and readSpool does a plain json.Unmarshal (:106), so every JSON number comes back as float64. anyValue then hits reflect.Float64 → DoubleValue (telemetry_otlp.go:97-99). So:
- the first, in-memory POST sends
tracebloc.cli.exit_code/duration_msas canonicalintValuestrings (nativeint), but - every drained retry — the partition-time events this whole transport exists to preserve — sends them as
doubleValue. Same event, two encodings, and the wrong one on the path you built the spool for.
TestIntegersAreEncodedAsStrings operates on the in-memory shape and never goes through writeSpool/readSpool, so it stays green while this breaks — the vacuity the house bar rejects.
Fix: decode spool numbers as json.Number (dec := json.NewDecoder(...); dec.UseNumber(), or unmarshal attributes through a decoder configured that way), then add a json.Number case to anyValue that emits intValue when the number is integral and doubleValue otherwise. That also keeps the spool file human-readable, which is a stated goal. Then make TestIntegersAreEncodedAsStrings (or a sibling) round-trip through the spool so the mutation "recode int as double on read-back" reddens.
Nothing else blocking from what I've read; once the spool preserves int-ness and a test covers the drained path, I'll finish the pass and approve. (CI is green; this and the Bugbot thread are the only things holding it.)
…review) @saqlainsyed007 and Bugbot, both right, and it defeated this PR's own wire-mapping guarantee on the path the design exists for. `readSpool` used `json.Unmarshal` into `map[string]any`, so every JSON number came back as float64 and `anyValue` routed it to `doubleValue`. The in-memory attempt sent `exit_code` as a canonical `intValue` string; every DRAINED retry -- the partition-time events the whole spool exists to preserve -- sent the same field as `doubleValue`. Same event, two encodings, wrong one on the path that matters. Reproduced before fixing rather than argued from the code: in-memory : "tracebloc.cli.exit_code":{"intValue":"2"} round-trip: "tracebloc.cli.exit_code":{"doubleValue":2} Fix is the one suggested: decode with `dec.UseNumber()` so numbers arrive as `json.Number`, and handle that in `anyValue` -- `intValue` when integral, `doubleValue` otherwise. It also keeps the spool file human-readable, which was a stated goal. THE ORDERING IN `anyValue` IS LOAD-BEARING, and this is the part worth reviewing: `json.Number` is a NAMED STRING TYPE, so the existing kind switch would have matched `reflect.String` and emitted `stringValue` -- turning an exit code into a string on the drained path, a different wrong answer from the float64 one. The json.Number branch therefore sits before the switch, not inside it. Why the existing test stayed green, since that is the reviewable lesson: `TestIntegersAreEncodedAsStrings` operates on the in-memory shape and never goes through writeSpool/readSpool. So it could not see this, which is the vacuity the house bar rejects. Two tests now cover the drained path: * `TestSpoolRoundTripPreservesIntegerEncoding` asserts the two encodings AGREE, not merely that the drained one looks right -- a test checking only the drained payload would be satisfied by both paths being wrong together. It also asserts the in-memory reference explicitly, so a regression there cannot make the comparison pass by both sides breaking. * `TestSpoolRoundTripKeepsRealsAsDoubles`, because a fix for integers that truncated every float would pass the first test. One note on the first draft of that test: it asserted "no doubleValue anywhere in the drained payload" and failed on CORRECT output, because the fixture carries `sampling_rate: 0.5` which must stay a double. Narrowed to per-attribute assertions -- an over-broad assertion reported as a code bug is its own defect. Verified: `make check` green (vet, full suite, fmt, file-budget, style, tool-pins), `make lint` clean. 13 mutations now, all killed: the original nine plus dropping `UseNumber` (the reported defect), letting json.Number fall through to the kind switch, routing integral numbers to doubleValue, and truncating reals to integers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 20, 2026
@saqlainsyed007 — fixed in Took your suggested fix as given — One thing your suggestion surfaced that I want on the record, because it is the same bug wearing a different type: On your vacuity point: agreed, and it is the more useful half of the review. I also got the first draft of that test wrong in a way worth naming: it asserted "no 13 mutations now, all killed — the original nine plus dropping |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 74fe2f1. Configure here.
Uh oh!
There was an error while loading. Please reload this page.
saqlainsyed007
left a comment
There was a problem hiding this comment.
The int→double fix I asked for is done and verified — readSpool now decodes with json.NewDecoder(...).UseNumber() and anyValue has a json.Number case first (correctly, since it's a named string type the kind switch would otherwise catch as String), emitting intValue for integral values and doubleValue otherwise. Spooled exit_code/duration_ms now survive the retry path as canonical ints. That concern is closed.
Holding on the new Bugbot finding at :84, which is real and is this PR's own invariant, one level out: the spool is a single host-wide pending.jsonl and deliver always POSTs the drained batch to the current run's URL/token. So after a login --env (or CLIENT_ENV change) with records queued, an event labelled deployment.environment=stg is delivered to prod's ingest and accepted under prod's token — exactly the "tagged stg, sent to prod" mismatch the in-run single-resolution fix was built to prevent, just across invocations rather than inside one. Telemetry-only blast radius, but it pollutes the wrong backend and mis-attributes.
Fix direction: stamp each spooled record with the env (or resolved URL) it was created under, and on drain either group by env and POST each batch to its own endpoint, or drop records whose env ≠ the current one with a note. If the team decides a queued-telemetry-across-env-switch is an acceptable edge, that's a legitimate call too — but then say so on the thread and resolve it, rather than leaving the invariant half-enforced silently.
CI is green; this Bugbot thread is the only thing between here and approval. Close it (fix or reasoned resolve) and I'll finish the pass.
…view) Bugbot, and it reopened one level up exactly the mismatch `deliver` takes a resolved URL to prevent. A single host-wide `pending.jsonl` meant records queued while signed in to one backend were drained by the NEXT invocation against another -- POSTed to its endpoint, with its token, still carrying the first one's `deployment.environment`. `tracebloc login --env dev` after a failed prod command was enough. Reproduced before fixing: a `deployment.environment=prod` record arrived at a dev endpoint with a dev token. The spool is now per environment, `pending-<env>.jsonl`, and `deliver` takes the resolved spool PATH rather than deriving one -- so label, spool and destination are three views of a single resolution in `pendingSink` instead of three chances to disagree. That is the third reason on that function's doc comment, and the second and third each bit once. The consequence is stated in the code rather than hidden: records for an environment the operator never uses again are never delivered. That is the right trade -- they are capped, there is usually no token for that environment anyway, and delivering them to the WRONG backend is not a better outcome than not delivering them. `spoolEnvSlug` strips anything outside [a-z0-9-] and lowercases. Unreachable with the closed dev/stg/prod set, and present because a path segment built from a string is a traversal waiting for that set to open; an unexpected value becomes one bucket rather than being dropped, since an undeliverable record is still evidence. The leak test asserts BOTH halves -- nothing prod-labelled reaches the dev endpoint, AND the prod spool still holds its record afterwards -- because a fix that simply discarded the other environment's queue would satisfy the first assertion alone. Verified: `make check` green, `make lint` clean, 16 mutations all killed (the original nine, the four from the int-encoding fix, plus reverting to a host-wide spool, and the slug ceasing to strip separators or normalise case). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 20, 2026
Second Bugbot finding fixed in
Spool is now @saqlainsyed007 this is a second substantive change since your review, so it wants your eyes again rather than just a re-request: 16 mutations all killed, |
saqlainsyed007
left a comment
There was a problem hiding this comment.
Approving — both findings are genuinely closed and verified:
- int→double (my first pass):
readSpooldecodes withUseNumber()andanyValuehandlesjson.Numberfirst, emittingintValuefor integral values — so a drained retry ships canonical ints, not doubles. - cross-env leak (
:84, my re-review): the spool is now per-env —telemetrySpoolPath(env)→pending-<slug>.jsonl— so a record spooled under one env stays in its own file and alogin --envswitch drains only the new env's file. Records are partitioned, not cross-delivered and not dropped ("still evidence"). That closes the label-vs-destination invariant across invocations, the way it already held within one.
Nicely, the fix is covered by TestTheSpoolDoesNotLeakAcrossEnvironments, and since env now lands in a filename you added TestSpoolEnvSlugRefusesPathTraversal to keep a crafted env from escaping the telemetry dir — good instinct. CI green, no open threads. The whole delivery design (inline-attempt-then-spool, drop-oldest, reflect.Kind seam, 4xx-discard-vs-401/403-retry) is solid. Approving.
Uh oh!
There was an error while loading. Please reload this page.
…d#2217) (#545) Completes backend#2217. The CLI half landed in #542; this is the installer half, built as option (b) rather than (a). WHY NOT (a), THE ROUTE THE TICKET ASSUMED. #2217 says "convert at the seam and POST", which presumes the installer can authenticate. It cannot: the ingest endpoint needs `Token`/`Bearer` under `IsAuthenticatedEdge`, and the installer holds `TRACEBLOC_CLIENT_ID`/`TRACEBLOC_CLIENT_PASSWORD` -- a provisioning pair with no exchange for a token -- and never reads the CLI's config (zero hits across `scripts/`). Having it read `~/.tracebloc/config.json` would also deliver NOTHING for the failures that matter most: `validate_config` and `early_data_dir_guard` run before provisioning, so no token exists on disk when those events are written, and those are exactly what the installer's `$TMPDIR` fallback was built to preserve. So the CLI carries them. It already owns the token and, since #542, the spool, drain loop and OTLP mapping -- this adds one more file to read and leaves the installer with no credential handling at all. THE UNPREDICTABLE FALLBACK PATH TURNED OUT NOT TO NEED AN INDEX FILE. I had expected to need one; `_telemetry_fallback_spool` uses `mktemp .../tracebloc-telemetry-XXXXXX`, so the NAME is unpredictable but the PATTERN is fixed. A glob over $TMPDIR / $HOME / /tmp finds them, the installer needs no change, and there is no shared state to keep in step. EVERY RECORD IS FILTERED BY ITS OWN ENVIRONMENT, and this is the part to review. Our spool is partitioned by env in the FILENAME; the installer's is not, and its records carry whatever CLIENT_ENV that run used. Forwarding blind would post a prod-labelled install failure to whichever backend this invocation points at -- the same leak #542's second finding was about. A record ships only when its `deployment.environment` matches this run's; the rest stay for a later invocation against that env. A record with NO environment is never forwarded (the contract omits rather than empties, so absent means unresolvable) but is also never dropped: it is still evidence. Their files are touched only on a path that CONSUMED them, and never on failure or when unauthenticated. A file we took nothing from is not rewritten at all. Verified: `make check` green, `make lint` clean, 16 mutations across the three suites all killed. One of the new nine earned its keep: it proved `TestInstallerRecordsSkipsAFileWithNothingForUs` VACUOUS -- it compared file bytes, and a rewrite of unchanged records produces identical bytes, so it passed under the mutation it existed to catch. It now asserts the real contract (the file never enters `remainder`) and the mutation kills it. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…eader (backend#2320) (#551) * fix(cli): resolve the backend env once per invocation, not once per reader (backend#2320) The env/base-URL resolution family, not the one site #540 named. Each of the last recuts produced one more finding about a site that answered "which backend?" differently from its neighbour (cli#528 review, #542 review, now #540) — the failure mode is additive, so only the SECOND site is ever a bug. - telemetry: recordCommandOutcome took the resolved env as a parameter instead of calling telemetryEnv(signedInEnv()) a second time. The label and the sink (spool path + POST destination) now derive from one value, which is what the comment above RecordCommandOutcome already claimed. This is #540's finding. - sessionEnv is now the single config -> session-env resolution point, and it normalises (trim + lower-case) like api.ResolveEnv. Returning cfg.CurrentEnv verbatim made it the one env-resolving function whose output was not normalised: invisible where the value only reaches api.BaseURL (which lower-cases again), load-bearing where it is COMPARED, or where one consumer trims and another does not — api.BaseURL does not trim, so " dev " fell through to PROD. - `cluster doctor` built its API client from cfg.CurrentEnv raw and `auth status --check` compared it raw against an already-normalised target. Both go through sessionEnv now: a doctor probing prod with a dev token reports "session expired" for a session that is fine, and the installer, whose contract is --check's exit code, re-ran login against a working session. - internal/doctor.backendHost derives its host from api.BaseURL instead of restating the same three hosts in a second switch. Behaviour-identical (BaseURL already lower-cases); it removes the copy that drifts. - A guard test pins the closed set of sanctioned resolution sites, so the next one fails a check instead of a review. NOT changed: api.BaseURL's unknown/empty -> prod fail-open. It is shared with the installer's _backend_url and contradicted by client-runtime's controller.py, so it is a three-component decision tracked on backend#2171. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(telemetry): reflow the BaseURL-mirroring note left ragged by the previous edit Comment-only; no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): `auth status` must report the env the client dials, not the stored string (backend#2320) The audit's last site: `auth status` printed cfg.CurrentEnv as its "backend" field while runAuthCheck — the machine-facing answer to the same question, 40 lines below in the same file — compares the resolved one. A status command that disagrees with the client is worse than no status command. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(cli): keep the doctor env test off the network (backend#2320) My own bug, and worth the comment it now carries. Past the session probe, `cluster doctor` loads the real kubeconfig and calls the real doctor.Run, whose checkBackendEgress probes backendHost("") — a live GET to https://api.tracebloc.io/. On a developer machine with a real k3d cluster the test therefore made a production request; the run time (~16s vs 0.01s stubbed) is the tell. Stubbing loadClusterFn returns right after the session probe, which is all this test needs: the env is decided before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(cli): make the resolution guard actually guard (backend#2320) Four review findings, all the same class — a check that verifies the FORM of a thing rather than the property the form exists to guarantee. Which is the class this PR is about, so the guard having it was the worst possible place for it. - Scan Go as Go. The hand-rolled comment stripper was fail-open on string literals: the `//` inside an `https://…` literal started a "comment" that ate the rest of the line, needle included, and a `/*` inside a literal like `"/*.json"` swallowed every needle below it to the next `*/` or EOF. Seven non-test files in internal/cli already carry an https:// literal, so this was one future line away. go/scanner with mode 0 drops comments and knows literals, so neither evasion exists. Literals are kept in the output — a needle inside a string is then a loud false positive, which is the cheap direction. - Walk the module root, not the test's own package dir. The guard covered 1 of the 17 packages under internal/, i.e. it was blind exactly where the next site is most likely to land: a new package written by someone who never reads internal/cli. Keys are now repo-relative, and internal/api, internal/config and internal/doctor join the allowlist with the reasons the PR body already gave. - An inert allowlist entry now FAILS. Checking only that a sanctioned file exists let my own change turn the telemetry.go entry into a licence: it matched no needle any more, so it checked nothing while silently pre-approving the next raw read in the very file whose double resolution this PR removes. The entry is gone and the staleness assertion stops the next one going inert unnoticed. - signedInEnv's docstring was false: it can no longer return "". Says so now, including that `if signedInEnv() == ""` cannot fire — and telemetryEnv's empty arm is marked production-unreachable-but-test-reachable rather than left to be traced. Also corrects an overclaim I made in the first draft of goCodeTokens' comment: go/scanner is lexical, so the error covers unterminated literals and comments — the faults that would desynchronise boundary tracking — not `func f( {`, which scans clean. Stated precisely rather than left flattering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(cli): one needle set, one matcher, one allowlist — each checked both ways (backend#2320) Adopts Lukas's unified shape for the guard, which is better than what I pushed in f33a3d1 in two concrete ways: - `matchesAnyNeedle` is now THE matcher, called from the detection sweep AND the allowlist audit. Two copies of "does this file resolve an env?" is the same shape as the two copies of "which env?" this PR removes, and it would let detection and allowlisting drift apart exactly where nobody looks. - The allowlist audit is per ENTRY, not per suite, so staleness, the empty-reason check and the needles-went-stale backstop all fall out of one loop and the failure names the entry to delete. Renaming a needle now reports all five entries by name instead of a global counter hitting zero. Kept a narrow anchor the per-entry loop genuinely cannot see: an EMPTY allowlist makes that loop vacuous, so a needle rename plus an empty allowlist would pass in silence. It asserts both counts are non-zero. Eight reproductions, all red, all restored green — the control, both string-literal evasions, the sibling-package site, a re-inerted sanctioned entry, an empty reason, a needle rename, and a lexical fault elsewhere in the module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
LukasWodka
commented
Aug 22, 2026
/fr-pass |

Half of backend#2217 — the CLI (Go) half. The installer (bash) half is a separate PR in
client; the two are independent and share only the wire mapping, which each repo derives from the receiver.What was wrong
pendingSink()returnednil, so every command outcome was validated and dropped. The comment said "when the ingest endpoint lands this returns the client that posts to it, and nothing else in this file changes" — which understated it, because there was no spool anywhere in the Go tree andEmitter.Emitcalls the sink synchronously on the command's exit path.The shape — option (c), as decided on the ticket
One inline POST attempt, ~1s budget for the whole step (drain included), spool on any failure, drain at most 20 pending on the next invocation. Explicit non-goals, written into the file so nobody adds them believing they were forgotten: no background daemon, no goroutine outliving the process, no retry loop. The next command is the retry.
The deciding argument against plain fire-and-forget, from the ticket: in a short-lived CLI, async delivery is a lie. A goroutine that outlives
main()is killed at exit, so (a)'s only honest forms are "always block" or "always drop on failure" — and dropping on failure discards precisely the partition-time events that are the most valuable thing the CLI can report.Three things worth a reviewer's attention
1.
anyValueswitches onreflect.Kind, not on concrete type. The emitter'scheckAttrValuedeliberately accepts every scalar kind via reflection so an idiomatic caller can pass a named type (type Reason string, ortime.Durationthroughtelemetry.Duration). Aswitch v := value.(type)at the seam matches the dynamic type, misses those, and silently drops at delivery exactly the values the layer above went out of its way to admit — a hole one level below the check that permits them.TestNamedScalarTypesSurviveTheSeamfails if anyone rewrites it that way.2. A 4xx other than 401/403/408/429 DISCARDS the batch. The endpoint answers
400when a whole batch is unparseable. Re-spooling that would wedge the spool: the same bad batch re-sent by every future command, forever, pushing good records out at the cap. 401/403 retry instead, because they are a credential state rather than a payload verdict — the nexttracebloc loginmakes those records deliverable.3. The spool keeps drop-OLDEST, and that is not a contradiction of D7. D7's amended row (rfcs#36) says drop-newest because
exporterhelpersheds at the entrance and offers nothing else — a platform constraint on the edge Collector, not a preference. This spool is our own code and can do what D7 originally wanted, so it does: the newest records describe the incident in progress. Same reasoning asscripts/lib/telemetry.sh'stail -ntrim, which it mirrors (0600, capped, oldest dropped). Called out in the file so it does not get "fixed" into agreement with a row that describes different machinery.The wire mapping
Derived from the receiver (
common/telemetry/otlp.py::parse_export_logs_request), not from recollection of the OTLP spec:resourceLogsentry per event, never one per batch.service.instance.idis fresh per run andservice.versionlegitimately differs between runs, so a drained spool carries genuinely different resources; collapsing them attributes every event to whichever run happened to be first. The receiver's parser calls this out by name."41230") — the canonical proto3 encoding. The receiver takes both; sending canonical means the payload is also readable by a stock OTLP consumer.boolValueneverintValuefor booleans. The receiver reads bool first precisely becauseisinstance(True, int); the sender should not make that necessary.timeUnixNano. Per the #2213 decision no client-side timing is sent, so the receiver stamps arrival and "how late" is knowingly invisible. Adding an event clock is a contract change, not a drive-by.{resource, attributes}shape, so a human reading a spool file gets something readable and older spooled files survive a mapping change.A defect this PR's own test found
deliveroriginally computedapi.BaseURL(env)internally, which left no seam — and its first test run posted to production. It now takes a resolved URL. That also removes a second failure mode: one resolution point means the record's label and its destination cannot disagree. The first draft also left at.Setenv("TRACEBLOC_TELEMETRY_URL_FOR_TEST", …)line that was a no-op — a dead assertion, which is the exact class this epic keeps finding; it is gone rather than papered over.Test plan
make checkgreen — vet, full suite,fmt-check,file-budget,check-style,check-tool-pins.make lintclean — errcheck, ineffassign, misspell, staticcheck (-checks all,-ST1005).Mutation-proved, 9/9, no survivors and no build-breaks. Each mutation asserts its anchor applied before running, because an inert mutation and real coverage produce identical logs:
resourceLogsfor the batchTestEachEventGetsItsOwnResourceLogsEntryTestIntegersAreEncodedAsStringsreflect.Kindfor a concrete type switchTestNamedScalarTypesSurviveTheSeamTestWriteSpoolKeepsNewestAndDropsOldestTestClassifyStatusTokenkeywordTestPostBatchSendsBearerAndJSONTestSpoolFileIsOwnerOnlyTestDeliverSpoolsWhenTheServerIsUnreachableTestDeliverSpoolsWhenNotSignedInNo test contacts the network: the partition tests aim at a closed listener and a loopback port.
Not in this PR
client, including the false#1906's forwarder reads both.comment the ticket calls out.tracebloc.event.id. A POST whose202is lost re-sends, which double-counts for a failure rate; nobody computes that rate yet (thesucceededdenominator does not exist either). Recorded on the ticket rather than solved with a contract change buying precision for an unconsumed metric.🤖 Generated with Claude Code
Note
Medium Risk
Adds authenticated HTTP delivery and on-disk event queues on every CLI exit path, including token use and env-partitioned files. Failures are silent and bounded, but a bug here can leak events across environments or hang the product.
Overview
CLI command-outcome events are no longer validated-and-dropped. Each invocation now makes one ~1s POST of OTLP/HTTP JSON to
/telemetry/v1/records/, then spools failures to disk and drains a bounded backlog on the next run.Label, spool file, and destination URL are derived from a single resolved env so a
stgrecord cannot be posted to prod. Spools are per-environment (pending-<env>.jsonl, 0600, cap 50, drop-oldest) so a laterlogin --env devcannot drain prod-labelled events with a dev token.The compact
{resource, attributes}shape stays on disk; conversion to OTLP happens at the seam (oneresourceLogsper event, int64 as proto3 JSON strings). Permanent 4xx (except 401/403/408/429) discards the batch so a bad payload cannot wedge the spool. All transport failures stay silent.Reviewed by Cursor Bugbot for commit c71acc4. Bugbot is set up for automated code reviews on this repo. Configure here.