feat(remote): report each probe to a format-scoped observer (S15 observability plan: 3/4)
Summary
Step 3 of the npm remote observability plan. remote.HealthMonitor gains an
optional per-format probe observer and reports one observation per settled
probe, carrying the repository identity, the probed host, the status, whether a
request reached the transport, and the pass verdict. No format implements the
hook yet: all six composition-root sites pass no observer, so behaviour on this
branch is byte-identical to main and TestHealthMonitor_Probe_ObserverLeavesTheProbeUnchanged
pins that.
The seam is shaped here and consumed by step 4, so this MR also settles the three things that are cheap now and expensive once npm reads it:
- The report is contained and ordered.
observeProberuns deferred, so the observation lands behind the durable write and the audit event on every return path, and it recovers a panicking observer. An unrecovered report ahead of the recording phase would have cost every remote of that format itsadvance, itsSetHealthStatusand itsRecordHealthTransition, so a dead upstream would never have flipped unhealthy. - The report context is detached and bounded.
context.WithoutCancel, matching the recording phase, under ahealthObserveTimeoutof its own.abandoned()matches onlycontext.Canceled, so a caller whose deadline expired settles the probe and reports; an observer honouring its context would otherwise have dropped exactly the probes at the tail of a saturated sweep, which are the slowest upstreams. The budget opens insideobserveProberather than where the report is deferred, so the recording phase's own five seconds cannot hand the report an already-expired context. - A local refusal is distinguishable from an unreachable upstream.
HealthProbeObservation.RequestSentis the discriminator, so step 4 branches on data rather than string-matchingUpstreamHostagainstinvalid-url.docs/dev/observability.mddefines the read routes'invalid-urlas a baseurl.Parserefuses, while this arm is any builder refusal, and the reachable one on npm is a stored token no header can carry.
Plan steps
This step's Depends on is none, and it is not stacked on another branch.
| # | Step | This MR |
|---|---|---|
| 1 | the metadata routes observe their reads | |
| 2 | retire the version field from npm_remote_proxy |
|
| 3 | HealthMonitor reports each probe to a format-scoped observer |
this one |
| 4 | npm books the probe's upstream response and its record | needs this one merged |
Accepted trade-offs and deliberate omissions
NewHealthMonitorstays at seven positional parameters, as the plan specifies. A variadicWithProbeObserverwould read better and matches this package's ownFlowOptions.Observeridiom, but switching is a deviation from the merged plan's step 3 and belongs in a plan amendment plus its ownrefactorMR, not here.NewHealthMonitor's andProbe's doc comments do not mention the observer. Each is a single comment segment of 14 and roughly 40 lines, so underscripts/ci/check-comment-caps.shtouching either charges every line of it against the 3-line exported cap. The optionality is recorded instead in a body-head comment on the constructor and onHealthProbeObserveritself.RequestBuilder.BuildRequest's contract is unchanged. It promises a non-nilHeaderand never a non-nilURL, and its doc block has the same caps bind.issueProbereads the host defensively instead, so a request carrying no URL settles as a failed probe rather than panicking, which is also what it did before this MR.- The report's deadline is a signal, not enforcement.
healthObserveTimeoutbounds an observer that honours its context; one that ignores the deadline still holds the probing goroutine, and with it one of the sweep'shealthSweepProbeConcurrencyslots. Running the report in its own goroutine would enforce it, at the cost of the ordering the deferred call buys and of an unbounded goroutine per stuck observer. The bound plus an explicit contract onHealthProbeObserveris the proportionate answer while step 4 is the only implementer. - The detached tail is now
healthRecordTimeout+healthObserveTimeout. Seven seconds against jobsriver's five-secondforceCancelGrace, where the recording phase alone already left zero margin. It costs a forced drain up to two extra seconds of waiting on a sweep worker, and only once a format wires an observer that actually blocks.healthObserveTimeout's doc points at the arithmetic for whoever changes either value. - No metric, log or span is added at this seam. An operator cannot yet tell a monitor with no observer from one whose probes never ran. The plan scopes emitters to the format packages, so closing that belongs with step 4 rather than here.
Diff size
790 reviewable LOC, past the 500 the development model asks about. Splitting would not help, because the split is almost entirely test code:
| Group | LOC |
|---|---|
Tests: health_test.go 573, healthjob_test.go 56, connection_test_handler_integration_test.go 2 |
631 |
Production: internal/remote/health.go 146, cmd/artifact-registry/wire_remote.go 13 |
159 |
Governing ADRs
Checked, and one applies as a conformance rather than a deviation.
- ADR-023 is the relevant one, and this seam is the shape it prescribes: the
interface is declared in the shared
internal/remotepackage, implemented per format, and injected by the composition root, exactly as theFormatCollectorshape atdocs/adr/023_code_structure_and_enforcement.md:289does.internal/remotegains no format import. - ADR-012 rules itself out at
:89, which states that LabKit's operational Prometheus metrics are "not part of the usage data collection decision". This step emits nothing anyway. - No deviation, so no handbook amendment MR is needed. The local
docs/adr/mirror was read rather than trusted by title../scripts/adr-freshness.shexits 1: the mirror is stale by two upstream commits, both on007_database_schema.md(amaven_packagestombstone index and thesize_bytesdecrement), neither of which bears on a diff with no schema change, no SQL and no migration. The one internal ADR is not readable from this environment, so treat its conformance as unverified rather than clear.
Testing
Unit tests only; no migration, no SQL, no schema change, so nothing here needs a database.
Covered: all four settled outcome shapes, both RequestSent arms, the no-host
marker on a builder refusal, silence on abandonment and on the three
pre-round-trip refusals, both triggers, a nil observer changing no durable write
and no audit event, the panicking observer contained with the write and the audit
event surviving, the report context arriving detached, a request carrying no URL,
all three non-recording shapes HealthProbeResult.Recorded names (counter
degraded, failed write, and lost compare-and-set), the report context carrying
a deadline of its own, and the observer driven through the real sweep
composition, which is the only place the concurrent ObserveProbe path runs.
Run locally on the head commit:
go build ./...,go vet ./...,gofmt -lcleango test -race -count=2 ./internal/remoteandgo test ./cmd/artifact-registry/...passscripts/ci/check-comment-caps.sh --base origin/mainpasses- Guardrail 7:
internal/managementapi/connection_test_handler_integration_test.goopens with//go:build integration, which CI lint never compiles, sogolangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false ./internal/remote/... ./internal/managementapi/... ./cmd/artifact-registry/...was run. It reports 11 issues, all of them pre-existing and none in a file this MR touches:wire_npm_boot_integration_test.go,wire_npm_observability_integration_test.go,wire_root_dispatcher_boot_integration_test.go, andhealthjob_integration_test.go.
E2e scenario impact: none. e2e.npm.remote.packument-cold-miss,
-cache-hit and -revalidate in docs/testing/e2e/npm.md all exist and none is
affected: no format implements the observer, so no served response, status, or
cache verdict changes.
Spec coverage
Spec: docs/specs/S15-npm-remote.md
Scope of this MR: the internal/remote probe-observer seam. No format
implements the hook yet, so every npm-side emitter row below is still open.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| P-1 | Packument cache miss | Owned by the merged packument-route MRs. Untouched here. |
| P-2 | dist.tarball rewrite |
Owned by the merged packument-route MRs. Untouched here. |
| P-3 | dist.shasum/dist.integrity preserved |
Owned by the merged packument-route MRs. Untouched here. |
| P-4 | Abbreviated packument | Owned by the merged packument-route MRs. Untouched here. |
| P-5 | Fresh hit | Owned by the merged packument-route MRs. Untouched here. |
| P-6 | Stale hit, etag matches | Owned by the merged packument-route MRs. Untouched here. |
| P-7 | Stale hit, etag differs | Owned by the merged packument-route MRs. Untouched here. |
| P-8 | New upstream version surfaces after the window | Owned by the merged packument-route MRs. Untouched here. |
| P-9 | Dist-tags document | Owned by the merged dist-tags-route MRs. Untouched here. |
| P-10 | Tag install resolves client-side | Owned by the merged dist-tags-route MRs. Untouched here. |
| P-11 | Single-flight on a metadata read | Owned by the merged packument-route MRs. Untouched here. |
| P-12 | Single-flight leader failure | Owned by the merged packument-route MRs. Untouched here. |
| T-1 | Tarball cache miss, fill | Owned by the merged tarball-route MRs. Untouched here. |
| T-2 | Fresh tarball cache hit | Owned by the merged tarball-route MRs. Untouched here. |
| T-3 | HEAD on a cache miss fills | Owned by the merged tarball-route MRs. Untouched here. |
| T-4 | Pinned upstream never revalidates | Owned by the merged tarball-route MRs. Untouched here. |
| T-5 | Stale tarball hit, etag matches | Owned by the merged tarball-route MRs. Untouched here. |
| T-6 | Stale tarball hit, upstream re-published | Owned by the merged tarball-route MRs. Untouched here. |
| T-7 | Stale tarball hit, no stored ETag | Owned by the merged tarball-route MRs. Untouched here. |
| T-8 | Tarball relayed unverified | Owned by the merged tarball-route MRs. Untouched here. |
| T-9 | Single-flight on a tarball read | Owned by the merged tarball-route MRs. Untouched here. |
| T-10 | Path-traversal filename rejected | Owned by the merged tarball-route MRs. Untouched here. |
| T-11 | Unparseable filename rejected | Owned by the merged tarball-route MRs. Untouched here. |
| T-12 | Outbound segment safety | Owned by the merged tarball-route MRs. Untouched here. |
| T-13 | A cache serve carries a content validator | Owned by the merged tarball-route MRs. Untouched here. |
| T-14 | Every serve shape is cache-separated | Owned by the merged tarball-route MRs. Untouched here. |
| T-15 | A client conditional request is answered | Owned by the merged tarball-route MRs. Untouched here. |
| W-1 | Publish rejected | Owned by the merged write-unavailability MR. Untouched here. |
| W-2 | Dist-tag mutation rejected | Owned by the merged write-unavailability MR. Untouched here. |
| W-3 | Unpublish routes rejected | Owned by the merged write-unavailability MR. Untouched here. |
| C-1 | Bearer attached | Owned by npm's HealthTargetResolver and request-builder suites. Untouched here. |
| C-2 | Credential redaction | Owned by the npm credential-store suites. Untouched here. |
| C-3 | Credential clearing on URL change | Owned by the S13 credential-manager suites. Untouched here. |
| C-4 | On-demand probe: HEAD within probe_timeout |
Probe mechanics stay with TestHealthMonitor_Probe_RequestDiscipline. This MR adds that the on-demand trigger reports too: TestHealthMonitor_Probe_ObservesBothTriggers/manual. |
| E-1 | Upstream 404 propagates | Owned by the merged read-route MRs. Untouched here. |
| E-2 | Transport failure with cache | Owned by the merged read-route MRs. Untouched here. |
| E-3 | Transport failure without cache | Owned by the merged read-route MRs. Untouched here. |
| E-4 | Upstream 5xx not a fallback trigger | Owned by the merged read-route MRs. Untouched here. |
Error cases
| # | Condition | Tests |
|---|---|---|
| EC-1 | Write verb on a remote repository: 405 | Owned by the merged write-unavailability MR. Untouched here. |
| EC-2 | Upstream 404 for a packument: 404 package_not_found |
Owned by the merged packument-route MRs. Untouched here. |
| EC-3 | Upstream 404 for a tarball: 404 file_not_found |
Owned by the merged tarball-route MRs. Untouched here. |
| EC-4 | Metadata document over max_remote_packument_size |
Owned by the merged packument-route MRs. Untouched here. |
| EC-5 | Transport failure, cache entry exists | Owned by the merged read-route MRs. Untouched here. |
| EC-6 | Transport failure, no cache entry: 503 | Owned by the merged read-route MRs. Untouched here. |
| EC-7 | Coalesced follower: wait timeout or leader lost: 503 | Owned by the merged read-route MRs. Untouched here. |
| EC-8 | Upstream 5xx propagated | Owned by the merged read-route MRs. Untouched here. On the probe path a 5xx is a failed probe, observed by TestHealthMonitor_Probe_ObservesEveryProbeOutcome/5xx_is_a_failed_probe. |
| EC-9 | Upstream non-404 4xx: upstream_rejected |
Owned by the merged read-route MRs. Untouched here. |
| EC-10 | Upstream status outside 4xx and 5xx: 503 | Owned by the merged read-route MRs. Untouched here. |
| EC-11 | URL-decoded {file_name} with .. or /: 400 |
Owned by the merged tarball-route MRs. Untouched here. |
| EC-12 | {file_name} does not parse: 404 |
Owned by the merged tarball-route MRs. Untouched here. |
| EC-13 | Package name fails the npm regex: 422 | Owned by the merged read-route MRs. Untouched here. |
| EC-14 | Validators on a proxied metadata read | Owned by the merged metadata-route MRs. Untouched here. |
Security considerations
| # | Concern | Tests |
|---|---|---|
| SC-1 | Credential hygiene: no token in logs or metrics | The observation carries no credential-bearing value: TestHealthMonitor_Probe_ObservesEveryProbeOutcome asserts the whole struct, whose only upstream-derived field is the host. The wide-event side is step 4's. |
| SC-2 | Credentials at rest | Owned by the S13 credential store. Untouched here. |
| SC-3 | SSRF | Owned by the S13 upstream client. This MR adds no new outbound call. |
| SC-4 | Cross-origin redirect token stripping | Owned by the S13 upstream client. Untouched here. |
| SC-5 | Outbound path-segment safety | Owned by the read routes and by TestHealthMonitor_Probe_RejectsNonRelativeHealthPath, which this MR extends with TestHealthMonitor_Probe_RefusalsBeforeTheRoundTripObserveNothing/health_path_is_not_relative: a refused path reports nothing either. |
| SC-6 | Tarball-URL rewrite keeps clients on the AR | Owned by the merged packument-route MRs. Untouched here. |
| SC-7 | Tarball integrity is client-enforced | Owned by the merged tarball-route MRs. Untouched here. |
| SC-8 | Error-payload hygiene | Owned by the merged read-route MRs. Untouched here. The observation is not client-visible. |
Observability surface
| Spec row | Tests |
|---|---|
Health probe: reachable taxonomy, a 401/403/404 is healthy |
TestHealthMonitor_Probe_ObservesEveryProbeOutcome/404_counts_as_reachable pins the reported Passed; the durable half stays with TestHealthMonitor_Probe_ReachableStatuses. |
Emitter coverage: npm_remote_upstream_responses_total{npm_operation="probe"} |
Not closed here. This MR lands the seam the emitter needs; the npm emitter is the next step of this plan. |
Emitter coverage: npm_remote_proxy operation probe |
Not closed here, same reason. |
Emitter coverage: npm_remote_cache_events_total, npm_remote_cache_fill_bytes, field coalesced |
Out of this MR's scope; the metadata-route step of this plan owns them. |
Emitter coverage: npm_remote_proxy field version |
Out of this MR's scope; the field-retirement step of this plan owns it. |
Seam behavior this MR pins beyond the spec text
The plan fixes where the report sits and what the silent arms are; the spec does not speak to either. Recorded here so a reviewer can check the reading.
| Behavior | Tests |
|---|---|
One observation per settled probe, carrying identity, host, status, Passed |
TestHealthMonitor_Probe_ObservesEveryProbeOutcome |
A BuildRequest failure that is not abandonment reports the no-host marker |
TestHealthMonitor_Probe_ObservesBuildFailureWithNoHostMarker |
| An abandoned probe reports nothing | TestHealthMonitor_Probe_AbandonmentObservesNothing |
| A refusal before the round trip reports nothing | TestHealthMonitor_Probe_RefusalsBeforeTheRoundTripObserveNothing |
| The report follows the round trip, not the durable write | TestHealthMonitor_Probe_ObservesWhatNothingDurableRecords |
| A nil observer changes no durable write and no audit event | TestHealthMonitor_Probe_ObserverLeavesTheProbeUnchanged |
| Scheduled and manual triggers both report | TestHealthMonitor_Probe_ObservesBothTriggers |
| The report context carries a deadline of its own | TestHealthMonitor_Probe_BoundsTheReport |
Resolved spec ambiguities: none. The spec fixes the probe taxonomy and the plan fixes the hook's shape and placement, so no assertion here rested on a reading that needed choosing.
Related to #800 (closed)