1use std::collections::BTreeMap;
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct Sample {
14 pub name: String,
15 pub labels: BTreeMap<String, String>,
16 pub value: f64,
17}
18
19pub fn parse_samples(text: &str) -> Vec<Sample> {
23 text.lines().filter_map(parse_line).collect()
24}
25
26fn parse_line(line: &str) -> Option<Sample> {
27 let line = line.trim();
28 if line.is_empty() || line.starts_with('#') {
29 return None;
30 }
31 let (head, value_str) = match line.find('}') {
35 Some(close) => {
36 let (h, rest) = line.split_at(close + 1);
37 (h, rest.trim())
38 }
39 None => {
40 let mut parts = line.split_whitespace();
41 let h = parts.next()?;
42 (h, line[h.len()..].trim())
43 }
44 };
45 let value: f64 = value_str.split_whitespace().next()?.parse().ok()?;
46
47 let (name, labels) = match head.find('{') {
48 None => (head.trim().to_string(), BTreeMap::new()),
49 Some(open) => {
50 let name = head[..open].trim().to_string();
51 let body = head[open + 1..head.len() - 1].trim_end_matches(',');
52 (name, parse_labels(body)?)
53 }
54 };
55 if name.is_empty() {
56 return None;
57 }
58 Some(Sample {
59 name,
60 labels,
61 value,
62 })
63}
64
65fn parse_labels(body: &str) -> Option<BTreeMap<String, String>> {
68 let mut labels = BTreeMap::new();
69 let mut chars = body.chars().peekable();
70 loop {
71 while matches!(chars.peek(), Some(',') | Some(' ')) {
73 chars.next();
74 }
75 if chars.peek().is_none() {
76 return Some(labels);
77 }
78 let mut key = String::new();
79 for c in chars.by_ref() {
80 if c == '=' {
81 break;
82 }
83 key.push(c);
84 }
85 if chars.next()? != '"' {
86 return None;
87 }
88 let mut value = String::new();
89 loop {
90 match chars.next()? {
91 '\\' => match chars.next()? {
92 'n' => value.push('\n'),
93 '\\' => value.push('\\'),
94 '"' => value.push('"'),
95 other => {
96 value.push('\\');
97 value.push(other);
98 }
99 },
100 '"' => break,
101 c => value.push(c),
102 }
103 }
104 labels.insert(key.trim().to_string(), value);
105 }
106}
107
108#[derive(Debug, Clone, Default, PartialEq)]
110pub struct RowStats {
111 pub source: String,
113 pub sink: String,
115 pub records_in: u64,
116 pub records_out: u64,
117 pub rate: f64,
119 pub source_errors: u64,
120 pub sink_errors: u64,
121 pub dlq_records: u64,
122 pub last_bookmark_unix: f64,
124 pub finished: Option<bool>,
126 pub in_flight: bool,
127}
128
129#[derive(Debug, Clone, Default, PartialEq)]
131pub struct TuiModel {
132 pub rows: BTreeMap<String, RowStats>,
134 pub total_in: u64,
135 pub total_out: u64,
136 pub total_rate: f64,
137}
138
139#[derive(Debug, Default)]
142pub struct Sampler {
143 pipeline: String,
144 prev_out: BTreeMap<String, (u64, std::time::Duration)>,
145}
146
147impl Sampler {
148 pub fn new(pipeline: impl Into<String>) -> Self {
149 Self {
150 pipeline: pipeline.into(),
151 prev_out: BTreeMap::new(),
152 }
153 }
154
155 pub fn observe(&mut self, text: &str, elapsed: std::time::Duration) -> TuiModel {
158 let mut model = TuiModel::default();
159 for s in parse_samples(text) {
160 if s.labels.get("pipeline").map(String::as_str) != Some(self.pipeline.as_str()) {
161 continue;
162 }
163 let row_id = s.labels.get("row").cloned().unwrap_or_default();
164 let row = model.rows.entry(row_id).or_default();
165 let connector = s.labels.get("connector").cloned().unwrap_or_default();
166 match s.name.as_str() {
167 "faucet_source_records_total" => {
168 row.records_in += s.value as u64;
169 if !connector.is_empty() {
170 row.source = connector;
171 }
172 }
173 "faucet_sink_records_total" => {
174 row.records_out += s.value as u64;
175 if !connector.is_empty() {
176 row.sink = connector;
177 }
178 }
179 "faucet_source_errors_total" => row.source_errors += s.value as u64,
180 "faucet_sink_errors_total" => row.sink_errors += s.value as u64,
181 "faucet_sink_dlq_records_total" => row.dlq_records += s.value as u64,
182 "faucet_pipeline_last_bookmark_unix_seconds" => {
183 row.last_bookmark_unix = s.value;
184 }
185 "faucet_pipeline_in_flight" => {
186 if s.value > 0.0 {
187 row.in_flight = true;
188 }
189 }
190 "faucet_pipeline_runs_total" => {
191 if row.source.is_empty()
193 && let Some(src) = s.labels.get("source")
194 {
195 row.source = src.clone();
196 }
197 if row.sink.is_empty()
198 && let Some(dst) = s.labels.get("sink")
199 {
200 row.sink = dst.clone();
201 }
202 if s.value > 0.0 {
203 match s.labels.get("status").map(String::as_str) {
204 Some("ok") => row.finished = Some(row.finished.unwrap_or(true)),
205 Some("err") => row.finished = Some(false),
206 _ => {}
207 }
208 }
209 }
210 _ => {}
211 }
212 }
213 let mut prev = std::mem::take(&mut self.prev_out);
215 for (row_id, row) in &mut model.rows {
216 if let Some((prev_count, prev_at)) = prev.remove(row_id) {
217 let dt = elapsed.saturating_sub(prev_at).as_secs_f64();
218 if dt > 0.0 && row.records_out >= prev_count {
220 row.rate = (row.records_out - prev_count) as f64 / dt;
221 }
222 }
223 self.prev_out
224 .insert(row_id.clone(), (row.records_out, elapsed));
225 model.total_in += row.records_in;
226 model.total_out += row.records_out;
227 model.total_rate += row.rate;
228 }
229 model
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::time::Duration;
237
238 #[test]
239 fn parses_bare_and_labelled_samples() {
240 let text = "\
241# HELP faucet_x helper
242# TYPE faucet_x counter
243faucet_up 1
244faucet_source_records_total{pipeline=\"p\",row=\"r1\",connector=\"csv\"} 42
245";
246 let samples = parse_samples(text);
247 assert_eq!(samples.len(), 2);
248 assert_eq!(samples[0].name, "faucet_up");
249 assert_eq!(samples[0].value, 1.0);
250 assert!(samples[0].labels.is_empty());
251 assert_eq!(samples[1].labels["connector"], "csv");
252 assert_eq!(samples[1].value, 42.0);
253 }
254
255 #[test]
256 fn parses_escaped_label_values() {
257 let text = r#"m{a="quo\"te",b="back\\slash",c="new\nline"} 7"#;
258 let s = &parse_samples(text)[0];
259 assert_eq!(s.labels["a"], "quo\"te");
260 assert_eq!(s.labels["b"], "back\\slash");
261 assert_eq!(s.labels["c"], "new\nline");
262 }
263
264 #[test]
265 fn garbage_lines_are_skipped_not_fatal() {
266 let text = "not a metric\nname_only\n{}} 3\nok_metric 5\n";
267 let samples = parse_samples(text);
268 assert_eq!(samples.len(), 1);
269 assert_eq!(samples[0].name, "ok_metric");
270 }
271
272 fn render(pipeline: &str, row: &str, out: u64) -> String {
273 format!(
274 "faucet_sink_records_total{{pipeline=\"{pipeline}\",row=\"{row}\",connector=\"jsonl\"}} {out}\n"
275 )
276 }
277
278 #[test]
279 fn sampler_filters_by_pipeline_and_computes_rates() {
280 let mut s = Sampler::new("mine");
281 let t0 = Duration::from_secs(1);
282 let m = s.observe(&(render("mine", "a", 100) + &render("other", "a", 999)), t0);
283 assert_eq!(m.rows.len(), 1);
284 assert_eq!(m.rows["a"].records_out, 100);
285 assert_eq!(m.rows["a"].rate, 0.0, "no window yet");
286
287 let m = s.observe(&render("mine", "a", 300), Duration::from_secs(3));
288 assert!((m.rows["a"].rate - 100.0).abs() < 1e-9, "200 records / 2s");
289 assert_eq!(m.total_out, 300);
290 }
291
292 #[test]
293 fn sampler_counter_reset_yields_zero_rate() {
294 let mut s = Sampler::new("p");
295 s.observe(&render("p", "a", 500), Duration::from_secs(1));
296 let m = s.observe(&render("p", "a", 10), Duration::from_secs(2));
297 assert_eq!(m.rows["a"].rate, 0.0);
298 }
299
300 #[test]
301 fn sampler_aggregates_row_fields() {
302 let text = "\
303faucet_source_records_total{pipeline=\"p\",row=\"r\",connector=\"spanner\"} 10
304faucet_sink_records_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\"} 8
305faucet_source_errors_total{pipeline=\"p\",row=\"r\",connector=\"spanner\",kind=\"http\"} 1
306faucet_sink_errors_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\",kind=\"io\"} 2
307faucet_sink_dlq_records_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\"} 3
308faucet_pipeline_last_bookmark_unix_seconds{pipeline=\"p\",row=\"r\"} 1700000000
309faucet_pipeline_in_flight{pipeline=\"p\",row=\"r\"} 1
310";
311 let mut s = Sampler::new("p");
312 let m = s.observe(text, Duration::from_secs(1));
313 let row = &m.rows["r"];
314 assert_eq!(row.source, "spanner");
315 assert_eq!(row.sink, "jsonl");
316 assert_eq!(row.records_in, 10);
317 assert_eq!(row.records_out, 8);
318 assert_eq!(row.source_errors, 1);
319 assert_eq!(row.sink_errors, 2);
320 assert_eq!(row.dlq_records, 3);
321 assert_eq!(row.last_bookmark_unix, 1_700_000_000.0);
322 assert!(row.in_flight);
323 assert_eq!(row.finished, None);
324 }
325
326 #[test]
327 fn run_status_ok_and_err_map_to_finished() {
328 let ok = "faucet_pipeline_runs_total{pipeline=\"p\",row=\"r\",source=\"csv\",sink=\"stdout\",status=\"ok\"} 1\n";
329 let err = "faucet_pipeline_runs_total{pipeline=\"p\",row=\"r\",source=\"csv\",sink=\"stdout\",status=\"err\",kind=\"sink\"} 1\n";
330 let mut s = Sampler::new("p");
331 let m = s.observe(ok, Duration::from_secs(1));
332 assert_eq!(m.rows["r"].finished, Some(true));
333 assert_eq!(m.rows["r"].source, "csv");
334 assert_eq!(m.rows["r"].sink, "stdout");
335 let m = s.observe(&(ok.to_string() + err), Duration::from_secs(2));
336 assert_eq!(m.rows["r"].finished, Some(false));
338 }
339}