From fb04bb8dcb919d41a8f6a4280156e954eeb2ccad Mon Sep 17 00:00:00 2001 From: Wicliff Wolda Date: Thu, 1 Oct 2026 14:32:20 +0200 Subject: [PATCH] Name every mounted route in requestEcosystem - Map /apk to alpine, matching what the handler writes to the database - Report /helm, /homebrew, /generic and /swift under their own names - Test every one of the 23 mounts, so the next route added cannot quietly fall through to "other" Part 1 of 6 splitting #381 up. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 ++ internal/server/middleware.go | 10 +++++++++- internal/server/middleware_test.go | 26 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eb265244..491c6a81 100644 --- a/README.md +++ b/README.md @@ -1140,6 +1140,8 @@ The proxy exposes Prometheus metrics at `GET /metrics`. All metric names are pre | `proxy_circuit_breaker_state` | gauge | `registry` | Artifact-fetch circuit breaker state per upstream registry (0 closed, 2 open). Published once that registry's breaker has tripped. | | `proxy_circuit_breaker_trips_total` | counter | `registry` | Circuit breaker trips per upstream registry. | +The `ecosystem` label on `proxy_requests_total` and `proxy_request_duration_seconds` is the mounted route a request arrived on, not the ecosystem recorded against the package it served: `/gem` reports as `rubygems`, `/go` as `golang`, `/composer` as `packagist`, `/apk` as `alpine` and `/v2` as `oci`. Anything outside a package route -- the UI, `/health`, `/metrics`, `/stats` -- reports as `other`, and so did `/apk`, `/helm`, `/homebrew`, `/generic` and `/swift` before they were listed; traffic on those five routes now appears under its own name instead. + Cache size and artifact count are refreshed every 60 seconds. Circuit breaker state is read from the fetcher on each scrape of `/metrics` and each `/health` request, so `proxy_circuit_breaker_trips_total` counts the trips visible between those reads — a breaker that opens and recovers entirely between two scrapes is not counted. The remaining metrics update on each request. The breaker metrics carry one series per upstream host, but only for hosts whose breaker has tripped at least once since startup. A breaker is created per host the proxy fetches artifacts from, and for some ecosystems that host comes from upstream metadata rather than from configuration (composer takes it from a package's `dist.url`, helm from the chart URLs in `index.yaml`), so publishing every host would let upstream content grow the series count for the lifetime of the process. Once a host has tripped it keeps reporting, so a recovery still shows up as a transition to 0 rather than as a series that vanishes. `/health` is not a persistent time series and lists every breaker, tripped or not. diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 53ed6e46..adfd9827 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -81,12 +81,20 @@ func (s *Server) LoggerMiddleware(next http.Handler) http.Handler { }) } +// requestEcosystem names the ecosystem a request path belongs to. +// +// Every mounted package route must appear here. An unlisted one falls to +// "other" along with the UI, health and metrics paths, pooling a real +// ecosystem's traffic with traffic that belongs to no ecosystem at all. func requestEcosystem(path string) string { segment, _, _ := strings.Cut(strings.TrimPrefix(path, "/"), "/") switch segment { case "npm", "cargo", "hex", "pub", "pypi", "maven", "gradle", "nuget", - "conan", "conda", "cran", "julia", "debian", "rpm": + "conan", "conda", "cran", "julia", "debian", "rpm", + "helm", "homebrew", "generic", "swift": return segment + case "apk": + return "alpine" case "gem": return "rubygems" case "go": diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go index c031fa27..595d7ca3 100644 --- a/internal/server/middleware_test.go +++ b/internal/server/middleware_test.go @@ -302,3 +302,29 @@ func TestResponseWriter_WriteHeader(t *testing.T) { }) } } + +// Every mounted package route must map to a named ecosystem. One that falls to +// "other" pools its traffic with UI, health and metrics traffic in +// proxy_requests_total and proxy_request_duration_seconds. +func TestRequestEcosystemCoversEveryMountedRoute(t *testing.T) { + mounts := map[string]string{ + "/npm/x": "npm", "/cargo/x": "cargo", "/gem/x": "rubygems", "/go/x": "golang", + "/hex/x": "hex", "/pub/x": "pub", "/pypi/x": "pypi", "/maven/x": "maven", + "/gradle/x": "gradle", "/nuget/x": "nuget", "/composer/x": "packagist", + "/conan/x": "conan", "/conda/x": "conda", "/cran/x": "cran", + "/julia/x": "julia", "/swift/x": "swift", "/v2/x": "oci", + "/apk/x": "alpine", "/debian/x": "debian", "/rpm/x": "rpm", + "/helm/x": "helm", "/homebrew/x": "homebrew", "/generic/x": "generic", + } + for path, want := range mounts { + if got := requestEcosystem(path); got != want { + t.Errorf("requestEcosystem(%q) = %q, want %q", path, got, want) + } + } + + for _, path := range []string{"/ui/", "/health", "/metrics", "/stats", "/"} { + if got := requestEcosystem(path); got != "other" { + t.Errorf("requestEcosystem(%q) = %q, want %q", path, got, "other") + } + } +}