Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-11 00:29:15 -05:00
pkg-proxy/internal/metrics/metrics.go

507 lines
16 KiB
Go
Raw Permalink Normal View History

2026-02-03 22:40:23 +00:00
// Package metrics provides Prometheus metrics collection for the proxy.
package metrics
import (
"net/http"
"strconv"
"sync"
2026-02-03 22:40:23 +00:00
"time"
2026-08-16 18:01:59 +01:00
"github.com/git-pkgs/purl"
2026-02-03 22:40:23 +00:00
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
// Request metrics
RequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_requests_total",
Help: "Total number of requests by ecosystem and status",
},
[]string{"ecosystem", "status"},
)
RequestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "proxy_request_duration_seconds",
Help: "Request duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"ecosystem", "status"},
)
// Cache metrics
CacheHits = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_cache_hits_total",
Help: "Total number of cache hits by ecosystem",
},
[]string{"ecosystem"},
)
CacheMisses = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_cache_misses_total",
Help: "Total number of cache misses by ecosystem",
},
[]string{"ecosystem"},
)
CacheSize = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "proxy_cache_size_bytes",
Help: "Total size of cached artifacts in bytes",
},
)
CachedArtifacts = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "proxy_cached_artifacts_total",
Help: "Total number of cached artifacts",
},
)
Add age-based retention for cached artifacts (#428) * Add age-based retention for cached artifacts Cached artifacts stayed forever unless storage.max_size pushed them out. storage.retention adds a default, per-ecosystem and per-package duration after which an artifact nobody downloaded gets evicted. Ecosystems opt in one at a time: a handler registers with retention.Default, and until it does, configuring that ecosystem (or one of its packages) fails validation, and the default doesn't touch it. This commit registers none, so nothing changes yet. Startup logs which ecosystems the default covers and which it doesn't. An artifact counts as expired when both its fetch time and its last access are older than the cutoff. Checking only the last access would evict a refetched artifact again right away, since clearing a record keeps its old access time and a cache miss doesn't record a hit. The sweep scans artifacts in id windows, so no single query holds the SQLite connection for long, and checks each row against its own rule. Expired records are cleared and their files go through pending_deletes instead of being deleted directly, because a buffered hit can make an artifact that's being served right now look expired. Buffered hits are flushed before each sweep, and one sweep clears at most 10,000 artifacts. Since a big first sweep can queue a lot, reclaim now keeps working through due entries for up to 30 seconds per tick instead of stopping after 100, and pending_deletes gets an index on queued_at. Also: - proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and retention evictions. - gradle.build_cache.max_age accepts "7d", as its comment always said. Refs #306 * Document storage retention Describes the new storage.retention block, the order rules apply in, that the default only covers ecosystems that support retention yet, and what to expect from the first sweep over an old cache. Also lists the new eviction counter and notes that gradle max_age takes days. Refs #306 * Fix retention timing on Postgres and tighten the sweep On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns without a zone. They hold the local time the proxy wrote, but lib/pq reads them back labelled UTC. East of UTC that makes every artifact look hours younger than it is, so the sweep evicted late: two hours in Berlin, nine in Tokyo. The candidates' times are now read back as local time, and the database tests run on both SQLite and Postgres, with one that sets the local zone to Asia/Tokyo. A few smaller things: - When a delete fails, reclaim stops for the rest of the minute instead of trying every queued path. Otherwise a storage outage turns into thirty seconds of failed deletes and warnings every minute. - If the buffered hits can't be written, the sweep skips that round. Running on stale access times could clear something that is being downloaded right now. - DefaultCanonical only accepts a package key when rebuilding it gives the same PURL. For alpine and deb it would otherwise produce keys that never match. - sweep_interval has to be at least a minute. - New tests cover the eviction counter and an artifact that gets a hit between the scan and the clear. - The docs no longer say nothing changes without retention, and they point out that the example's ecosystem and package rules only pass validation once those ecosystems support retention. Refs #306 * Reject qualifiers and subpaths in retention package keys A rule names a whole package. Qualifiers like ?type=pom were dropped on the way to the stored PURL and the rule quietly covered every file of the package. Now such a key fails validation. Refs #306 * Say when a package key doesn't match how packages are stored A package rule for an ecosystem that supports retention, written in a form its packages aren't stored under (pkg:npm/babel/core without the @), failed with "retention is not supported for npm packages". Once the ecosystem is enabled that is no longer true. Such a key now fails with "does not match how npm packages are stored" and points to the package rule forms in the docs. Keys for ecosystems without retention support keep the old message. Refs #306 * Note that re-running the mirror keeps packages cached A mirror run over a package that is still cached goes through the normal cache lookup and records a download, so the package's age starts over. The docs only said that mirrored packages age from the time they were mirrored. Refs #306 * Say when an ecosystem takes no package rules Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule. Refs #306 * Use helm as the example of an ecosystem without package rules Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart. Refs #306 * Say that each cached file ages on its own The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately. Refs #306 * Clear an expired artifact and queue its file in one transaction The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries. Refs #306 * Show artifact evictions on the analytics page proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order. Refs #306 * Store artifact times in UTC fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read. Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early. Refs #306 --------- Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
ArtifactsEvicted = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_artifacts_evicted_total",
Help: "Total number of cached artifacts evicted, by reason (lru or retention) and ecosystem",
},
[]string{"reason", "ecosystem"},
)
2026-02-03 22:40:23 +00:00
// Upstream metrics
UpstreamFetchDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "proxy_upstream_fetch_duration_seconds",
Help: "Upstream fetch duration in seconds",
Buckets: []float64{.1, .25, .5, 1, 2.5, 5, 10, 30},
},
[]string{"ecosystem"},
)
UpstreamErrors = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_upstream_errors_total",
Help: "Total number of upstream fetch errors by type",
},
[]string{"ecosystem", "error_type"},
)
// Circuit breaker metrics
CircuitBreakerState = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "proxy_circuit_breaker_state",
Help: "Circuit breaker state (0=closed, 1=half-open, 2=open)",
},
[]string{"registry"},
)
CircuitBreakerTrips = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_circuit_breaker_trips_total",
Help: "Total number of circuit breaker trips",
},
[]string{"registry"},
)
UpstreamRateLimitHolds = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_upstream_rate_limit_holds_total",
Help: "Holds placed on an upstream host after it rate limited the proxy",
},
[]string{"host"},
)
2026-02-03 22:40:23 +00:00
// Storage metrics
StorageOperationDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "proxy_storage_operation_duration_seconds",
Help: "Storage operation duration in seconds",
Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1},
},
[]string{"operation"},
)
StorageErrors = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_storage_errors_total",
Help: "Total number of storage errors by operation",
},
[]string{"operation"},
)
// Active requests
ActiveRequests = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "proxy_active_requests",
Help: "Number of currently active requests",
},
)
IntegrityFailures = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_integrity_failures_total",
Help: "Cached artifacts that failed hash verification on read",
},
[]string{"ecosystem"},
)
Add storage backend probe to /health (closes #73) (#119) * config: add Health.StorageProbeInterval * metrics: add proxy_health_probe_failures_total counter * server: add storageProbe with happy-path test * server: add storageProbe failure-mode tests * server: add healthCache with TTL, single-flight, transition logging * server: wire storage probe into /health * server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer Also fix Windows file-locking issue in storageProbe: close the reader explicitly before Delete so the file handle is released prior to os.Remove. * server: clean up stale comment in storageProbe * docs: document storage health probe and new metric * docs: regenerate Swagger for /health JSON response * server: simplify rc.Close error handling in storageProbe * server: defer probe cleanup so size/open/read/verify failures don't leak objects Previously, storageProbe only called Delete on the success path. Any failure between Store and the final Delete (size mismatch, Open error, mid-stream read failure, content mismatch) left the probe object orphaned in the storage backend. With caching disabled and Kubernetes-rate probing, the leak could accumulate noticeably on backends like S3. Use a named return + defer to attempt Delete after every successful Store. The earlier-step failure remains the primary error; Delete failure only surfaces as step="delete" when nothing else went wrong. Add a table-driven test that asserts cleanup runs for each non-delete failure path. Reported by Copilot on #119. * config: validate health.storage_probe_interval in Config.Validate The new duration field was only validated at use time in newHealthCache. The existing codebase already validates other duration fields (MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in Config.Validate() so misconfiguration fails fast at startup with a config-key-specific error. Match that pattern. The parse-at-use code in newHealthCache stays as a safety net, mirroring the MetadataTTL precedent. Reported by Copilot on #119. * docs: lowercase "counter" in metrics table for consistency Other rows in the table use lowercase type names (counter/gauge/histogram). Match that style. Reported by Copilot on #119. * docs: include size-check step in /health probe description The probe is write → size-check → read → verify → delete; the architecture note was missing the size-check step. Reported by Copilot on #119. * server: address andrew's review on #119 - Drop unused callerCtx parameter from healthCache.Check (Check is now parameter-less; the comment-only "accepted for symmetry" justification wasn't carrying its weight). - Emit "storage": {"status": "skipped"} on DB short-circuit instead of omitting the key, so monitors expecting a fixed key set keep working. - Reject negative storage_probe_interval at config validation time (previously parsed and silently behaved like "0"). - Extract HealthConfig.Validate to keep Config.Validate under the gocognit threshold and match the existing GradleBuildCacheConfig pattern. - README Health Check section: note that /health is intended as a readiness probe rather than a liveness probe (Check holds a mutex for up to the 10s probe timeout). - cmd/proxy/main.go godoc: column-align the new env var with the surrounding Gradle entries. Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
HealthProbeFailures = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_health_probe_failures_total",
Help: "Total number of storage health probe failures, by step (write|size|read|verify|delete).",
},
[]string{"step"},
)
Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298) Runs fetched artifacts through pluggable external scanners after they're staged in storage but before they're committed to the cache DB, so a block verdict deletes the object instead of ever exposing it to a client. Scanners pull the staged bytes themselves via a short-lived HMAC-signed internal route rather than the proxy pushing bytes to them, keeping the mechanism storage-backend-agnostic and avoiding uploading potentially huge artifacts through the proxy's own egress. Hardening baked in from the start: the internal scan-fetch route is gated both at router-mount time and in the handler so it's inert whenever scanning is disabled or unsigned; the signing key is mandatory whenever scanning is enabled, enforced directly in scanner.NewGroup rather than relying on callers to invoke config validation; the scan call and the delete-on-block cleanup both run on a context detached from the client's, so a client disconnecting mid-scan can't be mistaken for a scanner failure, doesn't cause a legitimate artifact to be deleted, and doesn't leave a genuinely blocked artifact's bytes orphaned in storage; and scanner infrastructure errors (connection failures, internal hostnames) are never forwarded verbatim to anonymous clients, only a generic message. The scan-error metric also correctly distinguishes a scanner's own timeout from being cancelled because a sibling scanner already decided the verdict. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 05:06:44 -04:00
// Per-ecosystem gauges, derived from the database rather than incremented
// in the request path, and refreshed on the same tick as the cache gauges
// above.
EcosystemDownloadedBytes = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "proxy_ecosystem_downloaded_bytes",
Help: "Accumulated bytes served from cache per ecosystem (cache hits x artifact size)",
},
[]string{"ecosystem"},
)
EcosystemDownloads = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "proxy_ecosystem_artifact_downloads",
Help: "Accumulated artifact downloads served from cache per ecosystem",
},
[]string{"ecosystem"},
)
EcosystemCacheSize = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "proxy_ecosystem_cache_size_bytes",
Help: "Size of cached artifacts per ecosystem in bytes",
},
[]string{"ecosystem"},
)
EcosystemCachedArtifacts = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "proxy_ecosystem_cached_artifacts",
Help: "Number of cached artifacts per ecosystem",
},
[]string{"ecosystem"},
)
EcosystemPackages = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "proxy_ecosystem_packages",
Help: "Number of known packages per ecosystem",
},
[]string{"ecosystem"},
)
EcosystemVersions = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "proxy_ecosystem_versions",
Help: "Number of known package versions per ecosystem",
},
[]string{"ecosystem"},
)
ResponseBytes = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_response_bytes_total",
Help: "Total response body bytes written to clients, by ecosystem",
},
[]string{"ecosystem"},
)
ClientRequests = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_client_requests_total",
Help: "Total requests by client tool, as identified from the User-Agent",
},
[]string{"client"},
)
ClientResponseBytes = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_client_response_bytes_total",
Help: "Total response body bytes written to clients, by client tool",
},
[]string{"client"},
)
Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298) Runs fetched artifacts through pluggable external scanners after they're staged in storage but before they're committed to the cache DB, so a block verdict deletes the object instead of ever exposing it to a client. Scanners pull the staged bytes themselves via a short-lived HMAC-signed internal route rather than the proxy pushing bytes to them, keeping the mechanism storage-backend-agnostic and avoiding uploading potentially huge artifacts through the proxy's own egress. Hardening baked in from the start: the internal scan-fetch route is gated both at router-mount time and in the handler so it's inert whenever scanning is disabled or unsigned; the signing key is mandatory whenever scanning is enabled, enforced directly in scanner.NewGroup rather than relying on callers to invoke config validation; the scan call and the delete-on-block cleanup both run on a context detached from the client's, so a client disconnecting mid-scan can't be mistaken for a scanner failure, doesn't cause a legitimate artifact to be deleted, and doesn't leave a genuinely blocked artifact's bytes orphaned in storage; and scanner infrastructure errors (connection failures, internal hostnames) are never forwarded verbatim to anonymous clients, only a generic message. The scan-error metric also correctly distinguishes a scanner's own timeout from being cancelled because a sibling scanner already decided the verdict. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 05:06:44 -04:00
// Scanning metrics
ScanDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "proxy_scan_duration_seconds",
Help: "Pre-cache artifact scan duration in seconds, by ecosystem and scanner",
Buckets: prometheus.DefBuckets,
},
[]string{"ecosystem", "scanner"},
)
ScanBlocked = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_scan_blocked_total",
Help: "Total number of artifacts blocked by a pre-cache scan, by ecosystem and scanner",
},
[]string{"ecosystem", "scanner"},
)
ScanErrors = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_scan_errors_total",
Help: "Total number of pre-cache scan errors, by ecosystem, scanner, and error type",
},
[]string{"ecosystem", "scanner", "error_type"},
)
2026-02-03 22:40:23 +00:00
)
func init() {
// Register all metrics with Prometheus
prometheus.MustRegister(
RequestsTotal,
RequestDuration,
CacheHits,
CacheMisses,
CacheSize,
CachedArtifacts,
Add age-based retention for cached artifacts (#428) * Add age-based retention for cached artifacts Cached artifacts stayed forever unless storage.max_size pushed them out. storage.retention adds a default, per-ecosystem and per-package duration after which an artifact nobody downloaded gets evicted. Ecosystems opt in one at a time: a handler registers with retention.Default, and until it does, configuring that ecosystem (or one of its packages) fails validation, and the default doesn't touch it. This commit registers none, so nothing changes yet. Startup logs which ecosystems the default covers and which it doesn't. An artifact counts as expired when both its fetch time and its last access are older than the cutoff. Checking only the last access would evict a refetched artifact again right away, since clearing a record keeps its old access time and a cache miss doesn't record a hit. The sweep scans artifacts in id windows, so no single query holds the SQLite connection for long, and checks each row against its own rule. Expired records are cleared and their files go through pending_deletes instead of being deleted directly, because a buffered hit can make an artifact that's being served right now look expired. Buffered hits are flushed before each sweep, and one sweep clears at most 10,000 artifacts. Since a big first sweep can queue a lot, reclaim now keeps working through due entries for up to 30 seconds per tick instead of stopping after 100, and pending_deletes gets an index on queued_at. Also: - proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and retention evictions. - gradle.build_cache.max_age accepts "7d", as its comment always said. Refs #306 * Document storage retention Describes the new storage.retention block, the order rules apply in, that the default only covers ecosystems that support retention yet, and what to expect from the first sweep over an old cache. Also lists the new eviction counter and notes that gradle max_age takes days. Refs #306 * Fix retention timing on Postgres and tighten the sweep On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns without a zone. They hold the local time the proxy wrote, but lib/pq reads them back labelled UTC. East of UTC that makes every artifact look hours younger than it is, so the sweep evicted late: two hours in Berlin, nine in Tokyo. The candidates' times are now read back as local time, and the database tests run on both SQLite and Postgres, with one that sets the local zone to Asia/Tokyo. A few smaller things: - When a delete fails, reclaim stops for the rest of the minute instead of trying every queued path. Otherwise a storage outage turns into thirty seconds of failed deletes and warnings every minute. - If the buffered hits can't be written, the sweep skips that round. Running on stale access times could clear something that is being downloaded right now. - DefaultCanonical only accepts a package key when rebuilding it gives the same PURL. For alpine and deb it would otherwise produce keys that never match. - sweep_interval has to be at least a minute. - New tests cover the eviction counter and an artifact that gets a hit between the scan and the clear. - The docs no longer say nothing changes without retention, and they point out that the example's ecosystem and package rules only pass validation once those ecosystems support retention. Refs #306 * Reject qualifiers and subpaths in retention package keys A rule names a whole package. Qualifiers like ?type=pom were dropped on the way to the stored PURL and the rule quietly covered every file of the package. Now such a key fails validation. Refs #306 * Say when a package key doesn't match how packages are stored A package rule for an ecosystem that supports retention, written in a form its packages aren't stored under (pkg:npm/babel/core without the @), failed with "retention is not supported for npm packages". Once the ecosystem is enabled that is no longer true. Such a key now fails with "does not match how npm packages are stored" and points to the package rule forms in the docs. Keys for ecosystems without retention support keep the old message. Refs #306 * Note that re-running the mirror keeps packages cached A mirror run over a package that is still cached goes through the normal cache lookup and records a download, so the package's age starts over. The docs only said that mirrored packages age from the time they were mirrored. Refs #306 * Say when an ecosystem takes no package rules Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule. Refs #306 * Use helm as the example of an ecosystem without package rules Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart. Refs #306 * Say that each cached file ages on its own The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately. Refs #306 * Clear an expired artifact and queue its file in one transaction The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries. Refs #306 * Show artifact evictions on the analytics page proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order. Refs #306 * Store artifact times in UTC fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read. Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early. Refs #306 --------- Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
ArtifactsEvicted,
2026-02-03 22:40:23 +00:00
UpstreamFetchDuration,
UpstreamErrors,
CircuitBreakerState,
CircuitBreakerTrips,
UpstreamRateLimitHolds,
2026-02-03 22:40:23 +00:00
StorageOperationDuration,
StorageErrors,
ActiveRequests,
IntegrityFailures,
Add storage backend probe to /health (closes #73) (#119) * config: add Health.StorageProbeInterval * metrics: add proxy_health_probe_failures_total counter * server: add storageProbe with happy-path test * server: add storageProbe failure-mode tests * server: add healthCache with TTL, single-flight, transition logging * server: wire storage probe into /health * server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer Also fix Windows file-locking issue in storageProbe: close the reader explicitly before Delete so the file handle is released prior to os.Remove. * server: clean up stale comment in storageProbe * docs: document storage health probe and new metric * docs: regenerate Swagger for /health JSON response * server: simplify rc.Close error handling in storageProbe * server: defer probe cleanup so size/open/read/verify failures don't leak objects Previously, storageProbe only called Delete on the success path. Any failure between Store and the final Delete (size mismatch, Open error, mid-stream read failure, content mismatch) left the probe object orphaned in the storage backend. With caching disabled and Kubernetes-rate probing, the leak could accumulate noticeably on backends like S3. Use a named return + defer to attempt Delete after every successful Store. The earlier-step failure remains the primary error; Delete failure only surfaces as step="delete" when nothing else went wrong. Add a table-driven test that asserts cleanup runs for each non-delete failure path. Reported by Copilot on #119. * config: validate health.storage_probe_interval in Config.Validate The new duration field was only validated at use time in newHealthCache. The existing codebase already validates other duration fields (MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in Config.Validate() so misconfiguration fails fast at startup with a config-key-specific error. Match that pattern. The parse-at-use code in newHealthCache stays as a safety net, mirroring the MetadataTTL precedent. Reported by Copilot on #119. * docs: lowercase "counter" in metrics table for consistency Other rows in the table use lowercase type names (counter/gauge/histogram). Match that style. Reported by Copilot on #119. * docs: include size-check step in /health probe description The probe is write → size-check → read → verify → delete; the architecture note was missing the size-check step. Reported by Copilot on #119. * server: address andrew's review on #119 - Drop unused callerCtx parameter from healthCache.Check (Check is now parameter-less; the comment-only "accepted for symmetry" justification wasn't carrying its weight). - Emit "storage": {"status": "skipped"} on DB short-circuit instead of omitting the key, so monitors expecting a fixed key set keep working. - Reject negative storage_probe_interval at config validation time (previously parsed and silently behaved like "0"). - Extract HealthConfig.Validate to keep Config.Validate under the gocognit threshold and match the existing GradleBuildCacheConfig pattern. - README Health Check section: note that /health is intended as a readiness probe rather than a liveness probe (Check holds a mutex for up to the 10s probe timeout). - cmd/proxy/main.go godoc: column-align the new env var with the surrounding Gradle entries. Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
HealthProbeFailures,
EcosystemDownloadedBytes,
EcosystemDownloads,
EcosystemCacheSize,
EcosystemCachedArtifacts,
EcosystemPackages,
EcosystemVersions,
ResponseBytes,
ClientRequests,
ClientResponseBytes,
Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298) Runs fetched artifacts through pluggable external scanners after they're staged in storage but before they're committed to the cache DB, so a block verdict deletes the object instead of ever exposing it to a client. Scanners pull the staged bytes themselves via a short-lived HMAC-signed internal route rather than the proxy pushing bytes to them, keeping the mechanism storage-backend-agnostic and avoiding uploading potentially huge artifacts through the proxy's own egress. Hardening baked in from the start: the internal scan-fetch route is gated both at router-mount time and in the handler so it's inert whenever scanning is disabled or unsigned; the signing key is mandatory whenever scanning is enabled, enforced directly in scanner.NewGroup rather than relying on callers to invoke config validation; the scan call and the delete-on-block cleanup both run on a context detached from the client's, so a client disconnecting mid-scan can't be mistaken for a scanner failure, doesn't cause a legitimate artifact to be deleted, and doesn't leave a genuinely blocked artifact's bytes orphaned in storage; and scanner infrastructure errors (connection failures, internal hostnames) are never forwarded verbatim to anonymous clients, only a generic message. The scan-error metric also correctly distinguishes a scanner's own timeout from being cancelled because a sibling scanner already decided the verdict. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 05:06:44 -04:00
ScanDuration,
ScanBlocked,
ScanErrors,
2026-02-03 22:40:23 +00:00
)
}
// Handler returns an HTTP handler for the Prometheus /metrics endpoint.
func Handler() http.Handler {
return promhttp.Handler()
}
// RecordRequest tracks request metrics with timing.
func RecordRequest(ecosystem string, status int, duration time.Duration) {
statusStr := strconv.Itoa(status)
RequestsTotal.WithLabelValues(ecosystem, statusStr).Inc()
RequestDuration.WithLabelValues(ecosystem, statusStr).Observe(duration.Seconds())
}
// RecordResponse tracks what a client actually downloaded.
//
// Distinct from proxy_ecosystem_downloaded_bytes: that gauge is derived from
// the database and counts cache hits multiplied by artifact size, while this
// counts body bytes as they are written, including metadata responses and
// cache misses.
//
// client must come from a closed set -- a User-Agent is attacker-controlled, so
// passing it through raw would mint a time series per request.
func RecordResponse(ecosystem, client string, bytes int64) {
ClientRequests.WithLabelValues(client).Inc()
if bytes <= 0 {
return
}
ResponseBytes.WithLabelValues(ecosystem).Add(float64(bytes))
ClientResponseBytes.WithLabelValues(client).Add(float64(bytes))
}
2026-02-03 22:40:23 +00:00
// RecordCacheHit increments cache hit counter.
func RecordCacheHit(ecosystem string) {
2026-08-16 18:01:59 +01:00
CacheHits.WithLabelValues(purl.NormalizeEcosystem(ecosystem)).Inc()
2026-02-03 22:40:23 +00:00
}
Add age-based retention for cached artifacts (#428) * Add age-based retention for cached artifacts Cached artifacts stayed forever unless storage.max_size pushed them out. storage.retention adds a default, per-ecosystem and per-package duration after which an artifact nobody downloaded gets evicted. Ecosystems opt in one at a time: a handler registers with retention.Default, and until it does, configuring that ecosystem (or one of its packages) fails validation, and the default doesn't touch it. This commit registers none, so nothing changes yet. Startup logs which ecosystems the default covers and which it doesn't. An artifact counts as expired when both its fetch time and its last access are older than the cutoff. Checking only the last access would evict a refetched artifact again right away, since clearing a record keeps its old access time and a cache miss doesn't record a hit. The sweep scans artifacts in id windows, so no single query holds the SQLite connection for long, and checks each row against its own rule. Expired records are cleared and their files go through pending_deletes instead of being deleted directly, because a buffered hit can make an artifact that's being served right now look expired. Buffered hits are flushed before each sweep, and one sweep clears at most 10,000 artifacts. Since a big first sweep can queue a lot, reclaim now keeps working through due entries for up to 30 seconds per tick instead of stopping after 100, and pending_deletes gets an index on queued_at. Also: - proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and retention evictions. - gradle.build_cache.max_age accepts "7d", as its comment always said. Refs #306 * Document storage retention Describes the new storage.retention block, the order rules apply in, that the default only covers ecosystems that support retention yet, and what to expect from the first sweep over an old cache. Also lists the new eviction counter and notes that gradle max_age takes days. Refs #306 * Fix retention timing on Postgres and tighten the sweep On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns without a zone. They hold the local time the proxy wrote, but lib/pq reads them back labelled UTC. East of UTC that makes every artifact look hours younger than it is, so the sweep evicted late: two hours in Berlin, nine in Tokyo. The candidates' times are now read back as local time, and the database tests run on both SQLite and Postgres, with one that sets the local zone to Asia/Tokyo. A few smaller things: - When a delete fails, reclaim stops for the rest of the minute instead of trying every queued path. Otherwise a storage outage turns into thirty seconds of failed deletes and warnings every minute. - If the buffered hits can't be written, the sweep skips that round. Running on stale access times could clear something that is being downloaded right now. - DefaultCanonical only accepts a package key when rebuilding it gives the same PURL. For alpine and deb it would otherwise produce keys that never match. - sweep_interval has to be at least a minute. - New tests cover the eviction counter and an artifact that gets a hit between the scan and the clear. - The docs no longer say nothing changes without retention, and they point out that the example's ecosystem and package rules only pass validation once those ecosystems support retention. Refs #306 * Reject qualifiers and subpaths in retention package keys A rule names a whole package. Qualifiers like ?type=pom were dropped on the way to the stored PURL and the rule quietly covered every file of the package. Now such a key fails validation. Refs #306 * Say when a package key doesn't match how packages are stored A package rule for an ecosystem that supports retention, written in a form its packages aren't stored under (pkg:npm/babel/core without the @), failed with "retention is not supported for npm packages". Once the ecosystem is enabled that is no longer true. Such a key now fails with "does not match how npm packages are stored" and points to the package rule forms in the docs. Keys for ecosystems without retention support keep the old message. Refs #306 * Note that re-running the mirror keeps packages cached A mirror run over a package that is still cached goes through the normal cache lookup and records a download, so the package's age starts over. The docs only said that mirrored packages age from the time they were mirrored. Refs #306 * Say when an ecosystem takes no package rules Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule. Refs #306 * Use helm as the example of an ecosystem without package rules Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart. Refs #306 * Say that each cached file ages on its own The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately. Refs #306 * Clear an expired artifact and queue its file in one transaction The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries. Refs #306 * Show artifact evictions on the analytics page proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order. Refs #306 * Store artifact times in UTC fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read. Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early. Refs #306 --------- Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
// RecordArtifactEvicted counts one evicted artifact. ecosystem is the
// package record's value, normalized like the cache hit counter; an artifact
// without a package record counts as "other".
func RecordArtifactEvicted(reason, ecosystem string) {
if ecosystem == "" {
ecosystem = "other"
}
ArtifactsEvicted.WithLabelValues(reason, purl.NormalizeEcosystem(ecosystem)).Inc()
}
2026-02-03 22:40:23 +00:00
// RecordCacheMiss increments cache miss counter.
func RecordCacheMiss(ecosystem string) {
2026-08-16 18:01:59 +01:00
CacheMisses.WithLabelValues(purl.NormalizeEcosystem(ecosystem)).Inc()
2026-02-03 22:40:23 +00:00
}
// RecordUpstreamFetch tracks upstream fetch duration.
func RecordUpstreamFetch(ecosystem string, duration time.Duration) {
UpstreamFetchDuration.WithLabelValues(ecosystem).Observe(duration.Seconds())
}
// RecordUpstreamError increments upstream error counter and keeps the detail
// for /ui/analytics.
func RecordUpstreamError(ecosystem, errorType string, detail FailureDetail) {
2026-02-03 22:40:23 +00:00
UpstreamErrors.WithLabelValues(ecosystem, errorType).Inc()
recordFailure("proxy_upstream_errors_total",
map[string]string{"ecosystem": ecosystem, "error_type": errorType}, detail)
2026-02-03 22:40:23 +00:00
}
// RecordStorageOperation tracks storage operation duration.
func RecordStorageOperation(operation string, duration time.Duration) {
StorageOperationDuration.WithLabelValues(operation).Observe(duration.Seconds())
}
// RecordIntegrityFailure increments the integrity failure counter and keeps
// the detail for /ui/analytics.
func RecordIntegrityFailure(ecosystem string, detail FailureDetail) {
IntegrityFailures.WithLabelValues(ecosystem).Inc()
recordFailure("proxy_integrity_failures_total", map[string]string{"ecosystem": ecosystem}, detail)
Add storage backend probe to /health (closes #73) (#119) * config: add Health.StorageProbeInterval * metrics: add proxy_health_probe_failures_total counter * server: add storageProbe with happy-path test * server: add storageProbe failure-mode tests * server: add healthCache with TTL, single-flight, transition logging * server: wire storage probe into /health * server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer Also fix Windows file-locking issue in storageProbe: close the reader explicitly before Delete so the file handle is released prior to os.Remove. * server: clean up stale comment in storageProbe * docs: document storage health probe and new metric * docs: regenerate Swagger for /health JSON response * server: simplify rc.Close error handling in storageProbe * server: defer probe cleanup so size/open/read/verify failures don't leak objects Previously, storageProbe only called Delete on the success path. Any failure between Store and the final Delete (size mismatch, Open error, mid-stream read failure, content mismatch) left the probe object orphaned in the storage backend. With caching disabled and Kubernetes-rate probing, the leak could accumulate noticeably on backends like S3. Use a named return + defer to attempt Delete after every successful Store. The earlier-step failure remains the primary error; Delete failure only surfaces as step="delete" when nothing else went wrong. Add a table-driven test that asserts cleanup runs for each non-delete failure path. Reported by Copilot on #119. * config: validate health.storage_probe_interval in Config.Validate The new duration field was only validated at use time in newHealthCache. The existing codebase already validates other duration fields (MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in Config.Validate() so misconfiguration fails fast at startup with a config-key-specific error. Match that pattern. The parse-at-use code in newHealthCache stays as a safety net, mirroring the MetadataTTL precedent. Reported by Copilot on #119. * docs: lowercase "counter" in metrics table for consistency Other rows in the table use lowercase type names (counter/gauge/histogram). Match that style. Reported by Copilot on #119. * docs: include size-check step in /health probe description The probe is write → size-check → read → verify → delete; the architecture note was missing the size-check step. Reported by Copilot on #119. * server: address andrew's review on #119 - Drop unused callerCtx parameter from healthCache.Check (Check is now parameter-less; the comment-only "accepted for symmetry" justification wasn't carrying its weight). - Emit "storage": {"status": "skipped"} on DB short-circuit instead of omitting the key, so monitors expecting a fixed key set keep working. - Reject negative storage_probe_interval at config validation time (previously parsed and silently behaved like "0"). - Extract HealthConfig.Validate to keep Config.Validate under the gocognit threshold and match the existing GradleBuildCacheConfig pattern. - README Health Check section: note that /health is intended as a readiness probe rather than a liveness probe (Check holds a mutex for up to the 10s probe timeout). - cmd/proxy/main.go godoc: column-align the new env var with the surrounding Gradle entries. Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
}
// RecordHealthProbeFailure increments the health probe failure counter and
// keeps the detail for /ui/analytics.
Add storage backend probe to /health (closes #73) (#119) * config: add Health.StorageProbeInterval * metrics: add proxy_health_probe_failures_total counter * server: add storageProbe with happy-path test * server: add storageProbe failure-mode tests * server: add healthCache with TTL, single-flight, transition logging * server: wire storage probe into /health * server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer Also fix Windows file-locking issue in storageProbe: close the reader explicitly before Delete so the file handle is released prior to os.Remove. * server: clean up stale comment in storageProbe * docs: document storage health probe and new metric * docs: regenerate Swagger for /health JSON response * server: simplify rc.Close error handling in storageProbe * server: defer probe cleanup so size/open/read/verify failures don't leak objects Previously, storageProbe only called Delete on the success path. Any failure between Store and the final Delete (size mismatch, Open error, mid-stream read failure, content mismatch) left the probe object orphaned in the storage backend. With caching disabled and Kubernetes-rate probing, the leak could accumulate noticeably on backends like S3. Use a named return + defer to attempt Delete after every successful Store. The earlier-step failure remains the primary error; Delete failure only surfaces as step="delete" when nothing else went wrong. Add a table-driven test that asserts cleanup runs for each non-delete failure path. Reported by Copilot on #119. * config: validate health.storage_probe_interval in Config.Validate The new duration field was only validated at use time in newHealthCache. The existing codebase already validates other duration fields (MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in Config.Validate() so misconfiguration fails fast at startup with a config-key-specific error. Match that pattern. The parse-at-use code in newHealthCache stays as a safety net, mirroring the MetadataTTL precedent. Reported by Copilot on #119. * docs: lowercase "counter" in metrics table for consistency Other rows in the table use lowercase type names (counter/gauge/histogram). Match that style. Reported by Copilot on #119. * docs: include size-check step in /health probe description The probe is write → size-check → read → verify → delete; the architecture note was missing the size-check step. Reported by Copilot on #119. * server: address andrew's review on #119 - Drop unused callerCtx parameter from healthCache.Check (Check is now parameter-less; the comment-only "accepted for symmetry" justification wasn't carrying its weight). - Emit "storage": {"status": "skipped"} on DB short-circuit instead of omitting the key, so monitors expecting a fixed key set keep working. - Reject negative storage_probe_interval at config validation time (previously parsed and silently behaved like "0"). - Extract HealthConfig.Validate to keep Config.Validate under the gocognit threshold and match the existing GradleBuildCacheConfig pattern. - README Health Check section: note that /health is intended as a readiness probe rather than a liveness probe (Check holds a mutex for up to the 10s probe timeout). - cmd/proxy/main.go godoc: column-align the new env var with the surrounding Gradle entries. Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
// step is one of: "write", "size", "read", "verify", "delete".
func RecordHealthProbeFailure(step string, detail FailureDetail) {
Add storage backend probe to /health (closes #73) (#119) * config: add Health.StorageProbeInterval * metrics: add proxy_health_probe_failures_total counter * server: add storageProbe with happy-path test * server: add storageProbe failure-mode tests * server: add healthCache with TTL, single-flight, transition logging * server: wire storage probe into /health * server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer Also fix Windows file-locking issue in storageProbe: close the reader explicitly before Delete so the file handle is released prior to os.Remove. * server: clean up stale comment in storageProbe * docs: document storage health probe and new metric * docs: regenerate Swagger for /health JSON response * server: simplify rc.Close error handling in storageProbe * server: defer probe cleanup so size/open/read/verify failures don't leak objects Previously, storageProbe only called Delete on the success path. Any failure between Store and the final Delete (size mismatch, Open error, mid-stream read failure, content mismatch) left the probe object orphaned in the storage backend. With caching disabled and Kubernetes-rate probing, the leak could accumulate noticeably on backends like S3. Use a named return + defer to attempt Delete after every successful Store. The earlier-step failure remains the primary error; Delete failure only surfaces as step="delete" when nothing else went wrong. Add a table-driven test that asserts cleanup runs for each non-delete failure path. Reported by Copilot on #119. * config: validate health.storage_probe_interval in Config.Validate The new duration field was only validated at use time in newHealthCache. The existing codebase already validates other duration fields (MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in Config.Validate() so misconfiguration fails fast at startup with a config-key-specific error. Match that pattern. The parse-at-use code in newHealthCache stays as a safety net, mirroring the MetadataTTL precedent. Reported by Copilot on #119. * docs: lowercase "counter" in metrics table for consistency Other rows in the table use lowercase type names (counter/gauge/histogram). Match that style. Reported by Copilot on #119. * docs: include size-check step in /health probe description The probe is write → size-check → read → verify → delete; the architecture note was missing the size-check step. Reported by Copilot on #119. * server: address andrew's review on #119 - Drop unused callerCtx parameter from healthCache.Check (Check is now parameter-less; the comment-only "accepted for symmetry" justification wasn't carrying its weight). - Emit "storage": {"status": "skipped"} on DB short-circuit instead of omitting the key, so monitors expecting a fixed key set keep working. - Reject negative storage_probe_interval at config validation time (previously parsed and silently behaved like "0"). - Extract HealthConfig.Validate to keep Config.Validate under the gocognit threshold and match the existing GradleBuildCacheConfig pattern. - README Health Check section: note that /health is intended as a readiness probe rather than a liveness probe (Check holds a mutex for up to the 10s probe timeout). - cmd/proxy/main.go godoc: column-align the new env var with the surrounding Gradle entries. Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
HealthProbeFailures.WithLabelValues(step).Inc()
recordFailure("proxy_health_probe_failures_total", map[string]string{"step": step}, detail)
}
// RecordStorageError increments storage error counter and keeps the detail
// for /ui/analytics.
func RecordStorageError(operation string, detail FailureDetail) {
2026-02-03 22:40:23 +00:00
StorageErrors.WithLabelValues(operation).Inc()
recordFailure("proxy_storage_errors_total", map[string]string{"operation": operation}, detail)
2026-02-03 22:40:23 +00:00
}
Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298) Runs fetched artifacts through pluggable external scanners after they're staged in storage but before they're committed to the cache DB, so a block verdict deletes the object instead of ever exposing it to a client. Scanners pull the staged bytes themselves via a short-lived HMAC-signed internal route rather than the proxy pushing bytes to them, keeping the mechanism storage-backend-agnostic and avoiding uploading potentially huge artifacts through the proxy's own egress. Hardening baked in from the start: the internal scan-fetch route is gated both at router-mount time and in the handler so it's inert whenever scanning is disabled or unsigned; the signing key is mandatory whenever scanning is enabled, enforced directly in scanner.NewGroup rather than relying on callers to invoke config validation; the scan call and the delete-on-block cleanup both run on a context detached from the client's, so a client disconnecting mid-scan can't be mistaken for a scanner failure, doesn't cause a legitimate artifact to be deleted, and doesn't leave a genuinely blocked artifact's bytes orphaned in storage; and scanner infrastructure errors (connection failures, internal hostnames) are never forwarded verbatim to anonymous clients, only a generic message. The scan-error metric also correctly distinguishes a scanner's own timeout from being cancelled because a sibling scanner already decided the verdict. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 05:06:44 -04:00
// RecordScanResult tracks a completed pre-cache scan call.
func RecordScanResult(ecosystem, scannerName string, allowed bool, duration time.Duration) {
ecosystem = purl.NormalizeEcosystem(ecosystem)
ScanDuration.WithLabelValues(ecosystem, scannerName).Observe(duration.Seconds())
if !allowed {
ScanBlocked.WithLabelValues(ecosystem, scannerName).Inc()
}
}
// RecordScanError increments the scan error counter.
// errorType is one of: "error" (scanner call failed), "timeout", "cancelled".
// The detail is kept for /ui/analytics.
func RecordScanError(ecosystem, scannerName, errorType string, detail FailureDetail) {
ecosystem = purl.NormalizeEcosystem(ecosystem)
ScanErrors.WithLabelValues(ecosystem, scannerName, errorType).Inc()
recordFailure("proxy_scan_errors_total",
map[string]string{"ecosystem": ecosystem, "scanner": scannerName, "error_type": errorType}, detail)
Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298) Runs fetched artifacts through pluggable external scanners after they're staged in storage but before they're committed to the cache DB, so a block verdict deletes the object instead of ever exposing it to a client. Scanners pull the staged bytes themselves via a short-lived HMAC-signed internal route rather than the proxy pushing bytes to them, keeping the mechanism storage-backend-agnostic and avoiding uploading potentially huge artifacts through the proxy's own egress. Hardening baked in from the start: the internal scan-fetch route is gated both at router-mount time and in the handler so it's inert whenever scanning is disabled or unsigned; the signing key is mandatory whenever scanning is enabled, enforced directly in scanner.NewGroup rather than relying on callers to invoke config validation; the scan call and the delete-on-block cleanup both run on a context detached from the client's, so a client disconnecting mid-scan can't be mistaken for a scanner failure, doesn't cause a legitimate artifact to be deleted, and doesn't leave a genuinely blocked artifact's bytes orphaned in storage; and scanner infrastructure errors (connection failures, internal hostnames) are never forwarded verbatim to anonymous clients, only a generic message. The scan-error metric also correctly distinguishes a scanner's own timeout from being cancelled because a sibling scanner already decided the verdict. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 05:06:44 -04:00
}
2026-02-03 22:40:23 +00:00
// UpdateCacheStats updates cache size and artifact count gauges.
func UpdateCacheStats(sizeBytes, artifactCount int64) {
CacheSize.Set(float64(sizeBytes))
CachedArtifacts.Set(float64(artifactCount))
}
// EcosystemStats is one ecosystem's row of the snapshot published as gauges.
type EcosystemStats struct {
Ecosystem string
Packages int64
Versions int64
Artifacts int64
CacheSize int64
Downloads int64
DownloadedBytes int64
}
// publishedEcosystems tracks which labels the per-ecosystem gauges currently
// carry, so a label that disappears can be deleted individually.
var (
publishedMu sync.Mutex
publishedEcosystems = map[string]bool{}
)
// UpdateEcosystemStats republishes the per-ecosystem gauges from a fresh snapshot.
//
// Rows are summed by label before anything is published, because normalizing
// collapses aliases: a database holding both "gem" and "rubygems" rows -- the
// proxy writes the former, git-pkgs the latter -- arrives as two rows belonging
// to one label, and publishing them one at a time would leave only the last.
//
// The vectors are not Reset() first. Reset followed by a repopulating loop
// leaves a window in which a scrape sees the families empty or half filled,
// which renders as a spurious gap on any panel built from them. Each series is
// Set instead, and only labels that have actually disappeared are deleted.
func UpdateEcosystemStats(stats []EcosystemStats) {
totals := make(map[string]EcosystemStats, len(stats))
for _, s := range stats {
ecosystem := purl.NormalizeEcosystem(s.Ecosystem)
t := totals[ecosystem]
t.Packages += s.Packages
t.Versions += s.Versions
t.Artifacts += s.Artifacts
t.CacheSize += s.CacheSize
t.Downloads += s.Downloads
t.DownloadedBytes += s.DownloadedBytes
totals[ecosystem] = t
}
for ecosystem, t := range totals {
EcosystemDownloadedBytes.WithLabelValues(ecosystem).Set(float64(t.DownloadedBytes))
EcosystemDownloads.WithLabelValues(ecosystem).Set(float64(t.Downloads))
EcosystemCacheSize.WithLabelValues(ecosystem).Set(float64(t.CacheSize))
EcosystemCachedArtifacts.WithLabelValues(ecosystem).Set(float64(t.Artifacts))
EcosystemPackages.WithLabelValues(ecosystem).Set(float64(t.Packages))
EcosystemVersions.WithLabelValues(ecosystem).Set(float64(t.Versions))
}
publishedMu.Lock()
defer publishedMu.Unlock()
for ecosystem := range publishedEcosystems {
if _, still := totals[ecosystem]; still {
continue
}
EcosystemDownloadedBytes.DeleteLabelValues(ecosystem)
EcosystemDownloads.DeleteLabelValues(ecosystem)
EcosystemCacheSize.DeleteLabelValues(ecosystem)
EcosystemCachedArtifacts.DeleteLabelValues(ecosystem)
EcosystemPackages.DeleteLabelValues(ecosystem)
EcosystemVersions.DeleteLabelValues(ecosystem)
}
publishedEcosystems = make(map[string]bool, len(totals))
for ecosystem := range totals {
publishedEcosystems[ecosystem] = true
}
}
2026-02-03 22:40:23 +00:00
// UpdateCircuitBreakerState updates circuit breaker state gauge.
// state: 0=closed, 1=half-open, 2=open
func UpdateCircuitBreakerState(registry string, state int) {
CircuitBreakerState.WithLabelValues(registry).Set(float64(state))
}
// RecordCircuitBreakerTrip increments circuit breaker trip counter.
func RecordCircuitBreakerTrip(registry string) {
CircuitBreakerTrips.WithLabelValues(registry).Inc()
}
// RecordUpstreamRateLimitHold counts a hold placed on host.
func RecordUpstreamRateLimitHold(host string) {
UpstreamRateLimitHolds.WithLabelValues(host).Inc()
}
2026-02-03 22:40:23 +00:00
// IncrementActiveRequests increments the active request counter.
func IncrementActiveRequests() {
ActiveRequests.Inc()
}
// DecrementActiveRequests decrements the active request counter.
func DecrementActiveRequests() {
ActiveRequests.Dec()
}