Skip to main content

heldar_kernel/services/
metrics.rs

1//! Prometheus exposition rendering for the `/metrics` endpoint (system + per-camera gauges).
2
3use std::fmt::Write;
4
5use chrono::Utc;
6use sqlx::SqlitePool;
7
8use crate::config::Config;
9use crate::models::CameraStatus;
10use crate::services::storage;
11
12/// Escape a Prometheus label value.
13fn esc(s: &str) -> String {
14    s.replace('\\', "\\\\")
15        .replace('"', "\\\"")
16        .replace('\n', " ")
17}
18
19fn metric(out: &mut String, name: &str, help: &str, kind: &str, value: f64) {
20    let _ = writeln!(out, "# HELP {name} {help}");
21    let _ = writeln!(out, "# TYPE {name} {kind}");
22    let _ = writeln!(out, "{name} {value}");
23}
24
25/// Render the full metrics page in Prometheus text exposition format.
26pub async fn render(pool: &SqlitePool, cfg: &Config) -> sqlx::Result<String> {
27    let mut out = String::new();
28
29    let _ = writeln!(out, "# HELP heldar_build_info Build information");
30    let _ = writeln!(out, "# TYPE heldar_build_info gauge");
31    let _ = writeln!(
32        out,
33        "heldar_build_info{{version=\"{}\"}} 1",
34        env!("CARGO_PKG_VERSION")
35    );
36
37    let cameras_total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM cameras")
38        .fetch_one(pool)
39        .await?;
40    let recording: i64 =
41        sqlx::query_scalar("SELECT COUNT(*) FROM camera_status WHERE state = 'recording'")
42            .fetch_one(pool)
43            .await?;
44    let segments_total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM segments")
45        .fetch_one(pool)
46        .await?;
47    let recordings_bytes: i64 =
48        sqlx::query_scalar("SELECT COALESCE(SUM(size_bytes), 0) FROM segments")
49            .fetch_one(pool)
50            .await?;
51
52    metric(
53        &mut out,
54        "heldar_cameras_total",
55        "Registered cameras",
56        "gauge",
57        cameras_total as f64,
58    );
59    metric(
60        &mut out,
61        "heldar_cameras_recording",
62        "Cameras currently recording",
63        "gauge",
64        recording as f64,
65    );
66    metric(
67        &mut out,
68        "heldar_segments_total",
69        "Indexed recording segments",
70        "gauge",
71        segments_total as f64,
72    );
73    metric(
74        &mut out,
75        "heldar_recordings_bytes",
76        "Total bytes of recorded segments",
77        "gauge",
78        recordings_bytes as f64,
79    );
80
81    let ai_tasks_enabled: i64 =
82        sqlx::query_scalar("SELECT COUNT(*) FROM ai_tasks WHERE enabled = 1")
83            .fetch_one(pool)
84            .await?;
85    let detections_total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM detections")
86        .fetch_one(pool)
87        .await?;
88    metric(
89        &mut out,
90        "heldar_ai_tasks_enabled",
91        "Enabled AI tasks",
92        "gauge",
93        ai_tasks_enabled as f64,
94    );
95    // gauge (not counter): the stored count can decrease as the retention sweeper prunes old rows.
96    metric(
97        &mut out,
98        "heldar_detections_stored",
99        "AI detections currently stored",
100        "gauge",
101        detections_total as f64,
102    );
103
104    if let Some(d) = storage::disk_stats_async(cfg.recordings_dir.clone()).await {
105        metric(
106            &mut out,
107            "heldar_disk_total_bytes",
108            "Total bytes on recordings filesystem",
109            "gauge",
110            d.total_bytes as f64,
111        );
112        metric(
113            &mut out,
114            "heldar_disk_free_bytes",
115            "Free bytes on recordings filesystem",
116            "gauge",
117            d.free_bytes as f64,
118        );
119        metric(
120            &mut out,
121            "heldar_disk_used_percent",
122            "Used percent of recordings filesystem",
123            "gauge",
124            d.used_percent,
125        );
126    }
127
128    let rows = sqlx::query_as::<_, CameraStatus>("SELECT * FROM camera_status")
129        .fetch_all(pool)
130        .await?;
131    let now = Utc::now();
132
133    let _ = writeln!(
134        out,
135        "# HELP heldar_camera_up Camera recording state (1 = recording)"
136    );
137    let _ = writeln!(out, "# TYPE heldar_camera_up gauge");
138    for r in &rows {
139        let up = i32::from(r.state == "recording");
140        let _ = writeln!(
141            out,
142            "heldar_camera_up{{camera=\"{}\",state=\"{}\"}} {up}",
143            esc(&r.camera_id),
144            esc(&r.state)
145        );
146    }
147
148    let _ = writeln!(
149        out,
150        "# HELP heldar_camera_reconnects_total Recorder reconnect count"
151    );
152    let _ = writeln!(out, "# TYPE heldar_camera_reconnects_total counter");
153    for r in &rows {
154        let _ = writeln!(
155            out,
156            "heldar_camera_reconnects_total{{camera=\"{}\"}} {}",
157            esc(&r.camera_id),
158            r.reconnect_count
159        );
160    }
161
162    let _ = writeln!(
163        out,
164        "# HELP heldar_camera_segments_written_total Segments written by the recorder"
165    );
166    let _ = writeln!(out, "# TYPE heldar_camera_segments_written_total counter");
167    for r in &rows {
168        let _ = writeln!(
169            out,
170            "heldar_camera_segments_written_total{{camera=\"{}\"}} {}",
171            esc(&r.camera_id),
172            r.segments_written
173        );
174    }
175
176    let _ = writeln!(
177        out,
178        "# HELP heldar_camera_bitrate_kbps Observed stream bitrate (kbps)"
179    );
180    let _ = writeln!(out, "# TYPE heldar_camera_bitrate_kbps gauge");
181    for r in &rows {
182        if let Some(b) = r.bitrate_kbps {
183            let _ = writeln!(
184                out,
185                "heldar_camera_bitrate_kbps{{camera=\"{}\"}} {b}",
186                esc(&r.camera_id)
187            );
188        }
189    }
190
191    let _ = writeln!(
192        out,
193        "# HELP heldar_camera_last_segment_age_seconds Seconds since the last indexed segment"
194    );
195    let _ = writeln!(out, "# TYPE heldar_camera_last_segment_age_seconds gauge");
196    for r in &rows {
197        if let Some(t) = r.last_segment_at {
198            let age = (now - t).num_seconds().max(0);
199            let _ = writeln!(
200                out,
201                "heldar_camera_last_segment_age_seconds{{camera=\"{}\"}} {age}",
202                esc(&r.camera_id)
203            );
204        }
205    }
206
207    Ok(out)
208}