Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion internal/server/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
26 changes: 26 additions & 0 deletions internal/server/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Loading