Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
package metrics
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-12 18:44:12 +02:00
|
|
|
// isolateRecentFailures empties the process-wide failure history for the
|
|
|
|
|
// test and again when it ends, so neither earlier tests nor repeated runs
|
|
|
|
|
// reach its assertions.
|
|
|
|
|
func isolateRecentFailures(t *testing.T) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
ResetRecentFailures()
|
|
|
|
|
t.Cleanup(ResetRecentFailures)
|
|
|
|
|
}
|
|
|
|
|
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
func TestRecentFailuresNewestFirst(t *testing.T) {
|
2026-10-12 18:44:12 +02:00
|
|
|
isolateRecentFailures(t)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
|
|
|
|
|
RecordUpstreamError("npm", "fetch_failed", FailureDetail{PURL: "pkg:npm/left-pad@1.0.0"})
|
|
|
|
|
RecordUpstreamError("npm", "fetch_failed", FailureDetail{PURL: "pkg:npm/right-pad@2.0.0"})
|
|
|
|
|
|
|
|
|
|
got := RecentFailures("proxy_upstream_errors_total", map[string]string{"ecosystem": "npm", "error_type": "fetch_failed"})
|
|
|
|
|
if len(got) != 2 {
|
|
|
|
|
t.Fatalf("got %d failures, want 2", len(got))
|
|
|
|
|
}
|
|
|
|
|
if got[0].PURL != "pkg:npm/right-pad@2.0.0" || got[1].PURL != "pkg:npm/left-pad@1.0.0" {
|
|
|
|
|
t.Errorf("failures not newest first: %q, %q", got[0].PURL, got[1].PURL)
|
|
|
|
|
}
|
|
|
|
|
if got[0].At.IsZero() {
|
|
|
|
|
t.Error("failure has no timestamp")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRecentFailuresAreBoundedPerSeries(t *testing.T) {
|
2026-10-12 18:44:12 +02:00
|
|
|
isolateRecentFailures(t)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
|
|
|
|
|
for i := range recentFailuresPerSeries + 5 {
|
|
|
|
|
RecordStorageError("write", FailureDetail{Path: fmt.Sprintf("npm/pkg-%d.tgz", i)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := RecentFailures("proxy_storage_errors_total", map[string]string{"operation": "write"})
|
|
|
|
|
if len(got) != recentFailuresPerSeries {
|
|
|
|
|
t.Fatalf("got %d failures, want the cap of %d", len(got), recentFailuresPerSeries)
|
|
|
|
|
}
|
|
|
|
|
if want := fmt.Sprintf("npm/pkg-%d.tgz", recentFailuresPerSeries+4); got[0].Path != want {
|
|
|
|
|
t.Errorf("newest failure = %q, want %q", got[0].Path, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRecentFailuresAreKeptPerSeries(t *testing.T) {
|
2026-10-12 18:44:12 +02:00
|
|
|
isolateRecentFailures(t)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
|
|
|
|
|
RecordIntegrityFailure("npm", FailureDetail{PURL: "pkg:npm/a@1"})
|
|
|
|
|
RecordIntegrityFailure("pypi", FailureDetail{PURL: "pkg:pypi/b@1"})
|
|
|
|
|
|
|
|
|
|
npm := RecentFailures("proxy_integrity_failures_total", map[string]string{"ecosystem": "npm"})
|
|
|
|
|
if len(npm) != 1 || npm[0].PURL != "pkg:npm/a@1" {
|
|
|
|
|
t.Errorf("npm series = %+v, want only pkg:npm/a@1", npm)
|
|
|
|
|
}
|
|
|
|
|
if got := RecentFailures("proxy_integrity_failures_total", map[string]string{"ecosystem": "deb"}); got != nil {
|
|
|
|
|
t.Errorf("unseen series = %+v, want nil", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRecordFailureStillCountsTheMetric(t *testing.T) {
|
2026-10-12 18:44:12 +02:00
|
|
|
isolateRecentFailures(t)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
|
|
|
|
|
before := testutil.ToFloat64(HealthProbeFailures.WithLabelValues("read"))
|
|
|
|
|
RecordHealthProbeFailure("read", FailureDetail{Err: errors.New("disk gone")})
|
|
|
|
|
if diff := testutil.ToFloat64(HealthProbeFailures.WithLabelValues("read")) - before; diff != 1 {
|
|
|
|
|
t.Errorf("counter delta = %.0f, want 1", diff)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := RecentFailures("proxy_health_probe_failures_total", map[string]string{"step": "read"})
|
|
|
|
|
if len(got) != 1 || got[0].Error != "disk gone" {
|
|
|
|
|
t.Errorf("failures = %+v, want one carrying the error text", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestScanErrorRecordsDetail(t *testing.T) {
|
2026-10-12 18:44:12 +02:00
|
|
|
isolateRecentFailures(t)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
|
|
|
|
|
RecordScanError("NPM", "clamav", "timeout", FailureDetail{PURL: "pkg:npm/a@1", Filename: "a-1.tgz"})
|
|
|
|
|
|
|
|
|
|
got := RecentFailures("proxy_scan_errors_total",
|
|
|
|
|
map[string]string{"ecosystem": "npm", "scanner": "clamav", "error_type": "timeout"})
|
|
|
|
|
if len(got) != 1 || got[0].Filename != "a-1.tgz" {
|
|
|
|
|
t.Errorf("failures = %+v, want one keyed by the normalised ecosystem", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The analytics page is unauthenticated, and private registry URLs carry
|
|
|
|
|
// credentials in the userinfo or the query string. Neither may be kept.
|
|
|
|
|
func TestRecentFailuresStripCredentials(t *testing.T) {
|
2026-10-12 18:44:12 +02:00
|
|
|
isolateRecentFailures(t)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
|
|
|
|
|
RecordUpstreamError("npm", "fetch_failed", FailureDetail{
|
|
|
|
|
URL: "https://user:s3cret@registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc#frag",
|
|
|
|
|
Err: errors.New(`Get "https://user:s3cret@registry.example.com/pkg?sig=xyz": connection refused`),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
got := RecentFailures("proxy_upstream_errors_total", map[string]string{"ecosystem": "npm", "error_type": "fetch_failed"})
|
|
|
|
|
if len(got) != 1 {
|
|
|
|
|
t.Fatalf("got %d failures, want 1", len(got))
|
|
|
|
|
}
|
|
|
|
|
if want := "https://registry.example.com/pkg/-/pkg-1.0.0.tgz"; got[0].URL != want {
|
|
|
|
|
t.Errorf("URL = %q, want %q", got[0].URL, want)
|
|
|
|
|
}
|
|
|
|
|
if want := `Get "https://registry.example.com/pkg": connection refused`; got[0].Error != want {
|
|
|
|
|
t.Errorf("Error = %q, want %q", got[0].Error, want)
|
|
|
|
|
}
|
|
|
|
|
for _, secret := range []string{"s3cret", "token", "sig=", "user:"} {
|
|
|
|
|
if strings.Contains(got[0].URL+got[0].Error, secret) {
|
|
|
|
|
t.Errorf("recorded failure still contains %q", secret)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRecentFailuresReturnsACopy(t *testing.T) {
|
2026-10-12 18:44:12 +02:00
|
|
|
isolateRecentFailures(t)
|
Record the detail behind each failure counter
- Failure recorders take a FailureDetail: PURL, filename, URL, storage path, error
- Keep the last 20 failures per counter series in memory, reset on restart
- Strip userinfo, query and fragment from URLs, including those inside error text
- Pass the detail from upstream, storage, integrity, health probe and scan call sites
- Package names and URLs stay out of Prometheus labels
2026-10-12 09:41:42 +02:00
|
|
|
|
|
|
|
|
RecordStorageError("read", FailureDetail{Path: "a"})
|
|
|
|
|
labels := map[string]string{"operation": "read"}
|
|
|
|
|
RecentFailures("proxy_storage_errors_total", labels)[0].Path = "mutated"
|
|
|
|
|
|
|
|
|
|
if got := RecentFailures("proxy_storage_errors_total", labels)[0].Path; got != "a" {
|
|
|
|
|
t.Errorf("stored failure was mutated through the returned slice: %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|