fix(telemetry): the send log records where each share went, and the receiver sentence names it (#2571) - #2708
Draft
trinity-ability wants to merge 6 commits into
Draft
trinity-ability wants to merge 6 commits into
trinity-ability wants to merge 6 commits into
Conversation
Requirements before implementation (Rule #1). §45.2 FR-5 gains the `destination` sentence — the scheme + host + port of TELEMETRY_SHARING_URL at send time, an RFC 6454 origin derived through `strip_url_credentials`, so it never carries userinfo, path or query — plus the three additive status keys (`configured_destination` / `receiver_destination` / `destination_changed`) that let the panel name the host that actually answered. Entries written before the key existed render as unknown; no migration. The feature flow's "Delivery that survives a missing receiver" item 1 replaces "The log does not yet record which host answered" with what it now records and how the 404 wording is judged (by where that send went, not by today's URL), and the `_record_send` line in the flow diagram carries the key. `architecture/backend.md` gains one clause on the ent#437 paragraph. #2571 leaves the Deferred list in both docs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tests before the code. Backend (9 red): the `share_destination` origin table — userinfo, path, query and fragment dropped, case and the scheme's default port folded, an explicit port kept, IPv6 bracketed, total over unparseable input because it runs outside `share_now`'s own try; the same host written two ways as one destination (trailing dot, IDN vs A-label) while `localhost:8787` stays distinct from `localhost`; the key recorded on the 2xx, transport-error and schema-refusal paths with nothing credential-shaped reaching the persisted JSON; the issue's own scenario (a local receiver's 200 read beside the restored default) as `destination_changed`; legacy entries as unknown rather than as agreement with today's URL; and the router spreading the triple. `test_share_now_records_a_404_and_names_the_missing_receiver` is renamed to `test_404_hint_is_judged_by_the_recorded_destination_then_by_the_current_url` and goes red ON PURPOSE: a 404 from the default address stays worded that way after the env var is re-pointed, because the hint belongs to the send, not to the configuration read afterwards. That red is the fix, not a regression. Frontend (5 red): the host named in the sentence, the changed-address clause on every branch, a pre-#2571 entry never borrowing today's URL, no JS-isms for any wire value, and a source-structure guard over the panel's per-row destination and its `receiverCopy` call (no mount harness under environment: 'node'). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y it (#2571) `share_destination(url)` is one pure helper: `strip_url_credentials` first, `urlsplit` second, `canonical_host` on the name, and the port only when it is explicit and not the scheme's default — an RFC 6454 origin and nothing else, so the stored value cannot carry userinfo, path, query or fragment. It is canonical at write time, which makes the comparison downstream plain string equality with no second normaliser to drift: the two sides are different inputs captured at different times, so one host retyped in another form (trailing dot, IDN vs A-label) must still compare equal or the panel reports a move that never happened. It returns None rather than raising — load-bearing, since `share_now` builds its entry dict outside its own try and `get_status` must never 500 the panel. `share_now` sets one key on that dict, where it is built, so every path records it for free (schema refusal, transport error, non-2xx, 2xx, outer catch). `get_status` answers three additive keys — `configured_destination`, `receiver_destination`, `destination_changed` — and `destination_changed` is False whenever either side is unknown, because claiming a change without knowing fabricates the certainty this exists to remove. `receiver_hint` now judges default-vs-override by the destination the entry recorded, falling back to the current URL only for an entry written before the key existed. A 404 belongs to the send that got it; reading it against the URL configured afterwards is the same mistake as the one reported. No migration, no new setting key (the field lives inside the existing `telemetry_sharing_recent_sends` JSON, so the ent#435 sink guard is untouched), no router change, no model change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ved (#2571) `receiverCopy` takes an optional third argument — the destination the log recorded, the destination configured now, and whether they differ — so the sentence names the host that ANSWERED instead of the address configured at read time. When the configured address has since moved it appends one clause: "TELEMETRY_SHARING_URL now points at <configured>; no send has gone there since." That is true by construction: the newest send went elsewhere, so nothing later went there. Absence of the argument reproduces the shipped wording exactly — verified byte-identical for all four branches — so a legacy entry with no recorded destination, and an older backend during a rolling deploy, read as they always did. The sentence never borrows today's URL as if it were the one that answered. Each Recent sends row carries its destination as one more segment in the existing meta span, the value in <code> (the panel's machine-text idiom, the same one the env-var names and the share id use), with "unknown host" for a row written before the log recorded it. Zero new colour classes — the segment sits inside the span that already carries the row's gray — and no new loading gate. The store defaults the three keys so the panel never reads undefined. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#2571) Tail steps. `tests/registry.json`: the ent#437 entry's stale clause — the 404 hint decided by the default URL — now says the hint follows the destination that send recorded, and the entry gains the #2571 coverage (the origin's folding rules and its total-over-garbage contract, the key on every send path, the changed-address signal, legacy entries as unknown, the router spread). `feature-flows/telemetry-sharing.md`: the two Testing bullets name what the pytest file and the vitest spec now pin, including the source-structure guard over the panel's per-row destination. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…2571) Review finding. `share_destination` hand-rolled its own IPv6 arm (`if ":" in raw: host = f"[{raw}]"`) beside `url_validation.canonical_host`, which is a SECOND normalisation of the one field the module's own `effective_port` docstring names as the hazard — and the module already answers this exact question: `canonical_origin_host` is "a host as an ORIGIN key", written for ent#399, where comparing IPv6 literals textually permanently refused a registered peer against its own card. The same defect lands here as the sentence this issue exists to delete: `https://[::1]:8443` and `https://[0:0:0:0:0:0:0:1]:8443` are one address, so a receiver addressed one way and re-typed the other reported `destination_changed` and printed "no send has gone there since" about an address every send was reaching. That is the plan's own argument for adopting `canonical_host` (trailing dot, IDN vs A-label), applied to the case it missed. Folding cannot equate two hosts a resolver would separate: `ipaddress` REFUSES the ambiguous IPv4 spellings rather than folding them, so `http://0177.0.0.1` stays distinct from `http://127.0.0.1` — pinned. Verified over the whole `_ORIGINS` table plus the live default, localhost and host.docker.internal URLs: exactly three outputs change, all of them the IPv6 spellings, and every other row is byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Resolve by running |
|
Resolve by merging |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The send log kept what happened to each share (
sent_at,backfill,window_days,ok,http_status,error,payload) but not where it went. The panel then judged the newest entry against the currently configuredTELEMETRY_SHARING_URL, so an operator who pointed the env var at a local test receiver, got a 200 and restored the default read "The receiving service acknowledged the last send" beside the production address — an acknowledgement the production receiver never gave. The data was truthful; the sentence was not.Each entry now carries where that send went, and every reading of the log is judged by it.
destination— the RFC 6454 originscheme://host[:port]ofTELEMETRY_SHARING_URLat send time, and nothing else.strip_url_credentialsruns first, so no userinfo reaches the parser's output, and the value is reconstructed fromscheme/host/portalone — path, query and fragment are dropped by construction rather than filtered (a path can carry a token; an origin cannot). Canonical at write time: scheme lower-cased, host through the existingcanonical_origin_host(trailing dot, UTS-46, ASCII passthrough for an underscore host, IP literals folded to one spelling), the port kept only when explicit and not the scheme's default.share_destinationnever raises — load-bearing, not tidy:share_now's entry dict is built outside its owntry(bug(telemetry): the sharing heartbeat's 24h timer restarts with the backend, so an install that restarts daily shares once and never again #2618), so the key is set once, before anything can fail, and every path records it (2xx, transport error, schema refusal, outer catch).get_statuskeys —configured_destination,receiver_destination,destination_changed. The comparison is plain string equality because both sides are canonicalised by the same function;destination_changedisFalsewhenever either side is unknown, since claiming a change without knowing would fabricate exactly the certainty this issue exists to remove. No router change (the router spreads the status dict) and nomodels.pychange.receiver_hintis re-judged by the recorded destination — a 404 belongs to the send that received it, so thereceiver_not_live/receiver_404split now asks where that send went, falling back to the current URL only for an entry that recorded nothing. Reading a 404 against a URL configured afterwards is the same mistake the issue is filed about.·segment in the existing gray meta span (<code>, the panel's machine-text idiom) —unknown hostfor entries written before the key existed. No migration: the log is a JSON list insystem_settings, and the reader treats absence as unknown rather than backfilling it from today's URL.undefined.Fixes #2571
Verification
WAVE-4
/verify-local --skip-agent(full suites, before the rebase):tests/unit/healthtests/integrationnpm run test:unit(frontend)npm run check:tokensThe one integration red is
tests/integration/test_circuit_breaker.py::TestDormantState::test_dormant_probes_after_cooldown, proven pre-existing and unrelated: the test file and the services it exercises are byte-identical to the base tree, the same node-id fails from the pristine base against the same stack, and it passed in a sibling lane's run on the same host — a harness race between a 0.05 s monkeypatched cooldown and a/dataread-only import traceback.Post-rebase neighbourhood (rebased onto
682fce300, the #2701 merge train; rebase was clean, no conflicts):Raw-colour scan of the panel:
{raw_nongray: 0, raw_gray: 60, hardcoded_colors: 1}— byte-identical to itsraw-color-baseline.jsonentry. The new segment sits inside the existingtext-gray-500 dark:text-gray-400span, so it adds zero colour classes. Neither ratchet baseline is modified by this branch (five open PRs touchraw-color-baseline.json; regenerating it would turn a no-op into a five-way conflict). The panel has noloading-gate-baseline.jsonentry and gains no loading gate.Also run:
/review(one fix applied —canonical_origin_hostrather thancanonical_host, so an IPv6 literal is one address however it is spelled, ent#399) and/cso --diff(clean). A second voice (gemini-critic) reviewed the plan against the code: five objections, two accepted — the IDNA/trailing-dot canonicalisation above, and the product question recorded below.User-visible changes
Every string below is a delegated routine choice made in
receiverCopy's existing voice (short declaratives, the env var named in caps, no "your", mechanism stated). Each names the pattern it copies. No string was reworded at ship time.The receiver at <destination> acknowledged the last send.(ok, destination recorded)The receiver at ${shareUrl} answered 404.phrase]The receiving service answered 404 at the default address, <destination>. …The receiver at <destination> answered 404. Check TELEMETRY_SHARING_URL.The last send to <destination> failed; it is recorded below and retried automatically.failedsentence's register]TELEMETRY_SHARING_URL now points at <configured>; no send has gone there since.receiver_404sentence: env var in caps, short declaratives, no "your"]· <code>https://intake.abilityai.dev</code>· HTTP {{ n }}/· {{ send.error }}segments;<code>is the panel's machine-text idiom (env-var names in the hard-disabled banner, the share id)]· unknown hostfor a pre-change entryv-else-if="send.error"fallback arm of that span]Two open questions for the maintainer
Neither is decided. Both are recorded here as questions, with what the branch does today.
Q1 — what should the receiver sentence say when the newest entry predates this change? A pre-change entry records no host, so the
oksentence either stays as it is or admits the destination is unknown. Both are honest; one changes a shipped sentence for every existing install.The receiving service acknowledged the last send; its destination was not recorded.— the sentence is never more certain than the data, at the cost of one extra clause every existing install reads.unknown host, which is AC 4's own remedy.This was withdrawn from the delegated set on purpose: appending a clause to an already-shipped sentence is a new user-visible default on a surface otherwise untouched for those installs, and the written delegation covers the mismatch sentence, the row label and the row's "unknown" rendering. Switching to (a) later costs one string and one spec assertion; nothing else in the branch depends on the answer.
Q2 — the
receiver_404+ address-moved composition namesTELEMETRY_SHARING_URLtwice. When an overridden receiver answered 404 and the address has since moved, the two halves compose to:Check TELEMETRY_SHARING_URL.in this one branch, since the following sentence already says where the variable points.Two notes on the acceptance criteria
<ul>→<li>→<details>→<summary>with a badge, a timestamp and one gray meta span. There is no header row and no column structure anywhere in it, so "a column following the table's existing headers" has no referent. The pattern that does exist is the meta span's·-joined segments, and the host is one more of those — the AC is met on the surface's own pattern, not on a structure the surface does not have.test_share_now_records_a_404_and_names_the_missing_receiverwas renamed, totest_404_hint_is_judged_by_the_recorded_destination_then_by_the_current_url, and its tail changed on purpose: a 404 belongs to the send that got it. The old tail asserted that re-pointing the env var after the fact flipped the hint toreceiver_404— which is the bug, stated as an expectation. The new tail asserts the hint staysreceiver_not_livewhiledestination_changedgoesTrue, and keeps the old reading for a legacy entry that recorded nothing. This red against existing code was the failing-test-first signal, not a regression.Follow-ups (not filed — for the maintainer)
get_status()["share_url"]is still the raw, un-stripped URL (pre-existing, not introduced here). The three new keys are all credential-stripped origins, so this change reduces exposure rather than adding to it, but the raw field remains on an admin-gated read.docs/user-docs/operations/telemetry.mddescribing the per-row destination and the moved-address sentence for operators. Not required by the AC, which names only the flow doc and the requirements file (both updated).tests/integration/test_circuit_breaker.py::TestDormantState::test_dormant_probes_after_cooldownis host-dependent and flakes under load — a registry candidate, or an upstream fix to the 0.05 s cooldown race..claude/agents/test-runner.mdcatalog row for the new#2571test block — that file lives in the private.claudesubmodule and is out of scope for a public-repo PR._ORIGINSrowhttps://tok@LEGACY@host.test/checks thatLEGACYdid not survive, butshare_destinationlower-cases the host, so a leak would read aslegacyand slip the assertion; and the fragment (#f) is pinned only on the returned value, not on the persisted JSON the waytok/x=1/ the path are.Merge-gate note
The diff touches
src/frontend/, sofrontend-e2eonly runs if the PR carries theuilabel. This PR is opened as a draft with no labels, reviewers or assignees — labelling is the maintainer's call.🤖 Generated with Claude Code