Skip to main content

git_cache_proxy/
metrics.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Prometheus metrics.
3//!
4//! Cardinality note: both `requests_total` and `upstream_ops_total` carry a
5//! `repo` label so operators can see per-repo traffic and totals
6//! (`sum without (repo) (...)`). To keep the label set bounded, the real repo
7//! name is emitted only for operations that *succeeded* (a served request, a
8//! completed clone/fetch); every failure - failed auth, malformed path,
9//! upstream error, a resolved-but-nonexistent repo - uses a `-` sentinel. The
10//! set of successfully served repos is bounded (the repos the fleet actually
11//! clones), so a flood of distinct but doomed repo paths cannot inflate the
12//! series count.
13
14use prometheus::{
15    Encoder, HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge, Opts, Registry,
16    TextEncoder,
17};
18
19/// Histogram buckets, in seconds, for git operation latency: from a fast cached
20/// advertisement (tens of ms) to a large clone over a slow WAN (minutes).
21const DURATION_BUCKETS: &[f64] = &[
22    0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0,
23];
24
25pub struct Metrics {
26    pub registry: Registry,
27    /// `requests_total{kind, result, repo}` - kind = info_refs | upload_pack |
28    /// auth | receive_pack; result = ok | error | upstream_error | unauthorized |
29    /// rejected; repo = the served repo path when result = ok, else `-`.
30    requests: IntCounterVec,
31    /// `upstream_ops_total{op, result, repo}` - op = clone | fetch; result = ok |
32    /// error; repo = the repo path when result = ok, else `-`.
33    upstream: IntCounterVec,
34    /// `cache_bytes` - total size of the on-disk mirror cache, maintained
35    /// incrementally as mirrors are added, refreshed, and evicted. Populated only
36    /// when a cap is configured (`--cache-max-mb`); with no cap it stays `0`.
37    cache_bytes: IntGauge,
38    /// `cache_mirrors` - number of cached mirrors (same caveat as `cache_bytes`).
39    cache_mirrors: IntGauge,
40    /// `evictions_total` - idle mirrors evicted to keep the cache under the cap.
41    evictions: IntCounter,
42    /// `upstream_duration_seconds{op, repo}` - clone/fetch wall-clock, observed
43    /// only on success (same bounded-`repo` discipline as the counters).
44    upstream_duration: HistogramVec,
45    /// `serve_duration_seconds{kind, repo}` - kind = info_refs (the buffered
46    /// advertisement) | upload_pack (the packfile stream, timed to EOF). Observed
47    /// only on success.
48    serve_duration: HistogramVec,
49}
50
51impl Metrics {
52    pub fn new() -> Self {
53        let registry = Registry::new();
54        let requests = IntCounterVec::new(
55            Opts::new("gitcacheproxy_requests_total", "Git requests served"),
56            &["kind", "result", "repo"],
57        )
58        .expect("valid metric");
59        let upstream = IntCounterVec::new(
60            Opts::new(
61                "gitcacheproxy_upstream_ops_total",
62                "Upstream clone/fetch operations",
63            ),
64            &["op", "result", "repo"],
65        )
66        .expect("valid metric");
67        let cache_bytes = IntGauge::new(
68            "gitcacheproxy_cache_bytes",
69            "Total size of the on-disk mirror cache in bytes",
70        )
71        .expect("valid metric");
72        let cache_mirrors = IntGauge::new(
73            "gitcacheproxy_cache_mirrors",
74            "Number of cached mirrors on disk",
75        )
76        .expect("valid metric");
77        let evictions = IntCounter::new(
78            "gitcacheproxy_evictions_total",
79            "Idle mirrors evicted to keep the cache under the configured cap",
80        )
81        .expect("valid metric");
82        let upstream_duration = HistogramVec::new(
83            HistogramOpts::new(
84                "gitcacheproxy_upstream_duration_seconds",
85                "Upstream clone/fetch duration in seconds",
86            )
87            .buckets(DURATION_BUCKETS.to_vec()),
88            &["op", "repo"],
89        )
90        .expect("valid metric");
91        let serve_duration = HistogramVec::new(
92            HistogramOpts::new(
93                "gitcacheproxy_serve_duration_seconds",
94                "Client serve duration in seconds (info/refs advertisement, upload-pack stream)",
95            )
96            .buckets(DURATION_BUCKETS.to_vec()),
97            &["kind", "repo"],
98        )
99        .expect("valid metric");
100        registry
101            .register(Box::new(requests.clone()))
102            .expect("register requests");
103        registry
104            .register(Box::new(upstream.clone()))
105            .expect("register upstream");
106        registry
107            .register(Box::new(cache_bytes.clone()))
108            .expect("register cache_bytes");
109        registry
110            .register(Box::new(cache_mirrors.clone()))
111            .expect("register cache_mirrors");
112        registry
113            .register(Box::new(evictions.clone()))
114            .expect("register evictions");
115        registry
116            .register(Box::new(upstream_duration.clone()))
117            .expect("register upstream_duration");
118        registry
119            .register(Box::new(serve_duration.clone()))
120            .expect("register serve_duration");
121        Self {
122            registry,
123            requests,
124            upstream,
125            cache_bytes,
126            cache_mirrors,
127            evictions,
128            upstream_duration,
129            serve_duration,
130        }
131    }
132
133    /// Record the outcome of a client request. `repo` is the served repo path on
134    /// success and `-` on any failure, so unbounded client-supplied paths cannot
135    /// inflate label cardinality. `kind` is `info_refs`, `upload_pack`, `auth` or
136    /// `receive_pack`; `result` is `ok`, `error`, `upstream_error`,
137    /// `unauthorized` or `rejected`.
138    pub fn record_request(&self, kind: &str, result: &str, repo: &str) {
139        self.requests.with_label_values(&[kind, result, repo]).inc();
140    }
141
142    /// Record an upstream clone/fetch. Errors are recorded too (`result =
143    /// "error"`); pass the repo path on success and `-` on error, matching
144    /// `record_request`.
145    pub fn record_upstream(&self, op: &str, result: &str, repo: &str) {
146        self.upstream.with_label_values(&[op, result, repo]).inc();
147    }
148
149    /// Refresh the cache-size gauges from the eviction index.
150    pub fn set_cache_size(&self, bytes: u64, mirrors: usize) {
151        self.cache_bytes.set(bytes as i64);
152        self.cache_mirrors.set(mirrors as i64);
153    }
154
155    /// Record one evicted mirror.
156    pub fn record_eviction(&self) {
157        self.evictions.inc();
158    }
159
160    /// Observe an upstream op's duration. Call only on success with the real repo,
161    /// matching the counters' bounded-`repo` cardinality discipline.
162    pub fn observe_upstream(&self, op: &str, repo: &str, seconds: f64) {
163        self.upstream_duration
164            .with_label_values(&[op, repo])
165            .observe(seconds);
166    }
167
168    /// Observe a client serve duration (`kind` = `info_refs` | `upload_pack`),
169    /// same cardinality discipline as `observe_upstream`.
170    pub fn observe_serve(&self, kind: &str, repo: &str, seconds: f64) {
171        self.serve_duration
172            .with_label_values(&[kind, repo])
173            .observe(seconds);
174    }
175
176    pub fn gather(&self) -> String {
177        let mut buf = Vec::new();
178        let enc = TextEncoder::new();
179        // encode never fails for the text format into a Vec.
180        let _ = enc.encode(&self.registry.gather(), &mut buf);
181        String::from_utf8_lossy(&buf).into_owned()
182    }
183}
184
185impl Default for Metrics {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn gather_renders_recorded_series() {
197        let m = Metrics::new();
198        m.record_request("info_refs", "ok", "group/foo.git");
199        m.record_request("upload_pack", "error", "group/bar.git");
200        m.record_upstream("fetch", "ok", "group/foo.git");
201        m.record_upstream("clone", "error", "group/bar.git");
202        m.set_cache_size(2048, 3);
203        m.record_eviction();
204        m.record_eviction();
205        m.observe_upstream("clone", "group/foo.git", 1.5);
206        m.observe_serve("upload_pack", "group/foo.git", 2.0);
207
208        let out = m.gather();
209        assert!(out.contains("gitcacheproxy_cache_bytes 2048"));
210        assert!(out.contains("gitcacheproxy_cache_mirrors 3"));
211        assert!(out.contains("gitcacheproxy_evictions_total 2"));
212        assert!(out.contains(
213            r#"gitcacheproxy_upstream_duration_seconds_count{op="clone",repo="group/foo.git"} 1"#
214        ));
215        assert!(out.contains(
216            r#"gitcacheproxy_serve_duration_seconds_count{kind="upload_pack",repo="group/foo.git"} 1"#
217        ));
218        assert!(out.contains(
219            r#"gitcacheproxy_requests_total{kind="info_refs",repo="group/foo.git",result="ok"} 1"#
220        ));
221        assert!(out.contains(
222            r#"gitcacheproxy_requests_total{kind="upload_pack",repo="group/bar.git",result="error"} 1"#
223        ));
224        assert!(out.contains(r#"op="fetch",repo="group/foo.git",result="ok"#));
225        assert!(out.contains(r#"op="clone",repo="group/bar.git",result="error"#));
226    }
227}