tale_ndjson/metrics/
collector.rs1use std::time::Duration;
5
6#[derive(Debug, Clone)]
7pub struct MetricsCollector {
8 metrics: ChunkMetrics,
9}
10
11impl Default for MetricsCollector {
12 fn default() -> Self {
13 Self::new()
14 }
15}
16
17impl MetricsCollector {
18 pub fn new() -> Self {
19 Self {
20 metrics: ChunkMetrics::new(),
21 }
22 }
23
24 pub fn chunks_seen(&self) -> usize {
25 self.metrics.chunks_seen
26 }
27
28 pub fn chunk_sizes_avg(&self) -> f64 {
29 self.metrics.chunk_sizes.average()
30 }
31
32 pub fn processing_speed_mbps(&self) -> f64 {
33 self.metrics.processing_speed_mbps()
34 }
35
36 pub fn record_chunk(&mut self, chunk_bytes: usize, elapsed_ms: Duration, line_count: usize) {
37 self.metrics
38 .record_chunk_processing(chunk_bytes, elapsed_ms, line_count)
39 }
40
41 pub fn ready_to_adapt(&self, interval: usize) -> bool {
42 self.metrics.chunks_seen > 0 && self.metrics.chunks_seen % interval == 0 && self.metrics.lines_moving.count >= 3
43 }
44
45 pub fn snapshot(&self) -> &ChunkMetrics {
46 &self.metrics
47 }
48}
49
50#[derive(Debug, Clone)]
54pub struct ChunkMetrics {
55 pub chunks_seen: usize,
57 pub parsed_per_ms: f64,
59 pub parsed_moving: MovingAverage<20>,
61 pub memory_bytes: usize,
63 pub memory_moving: MovingAverage<20>,
64 pub chunk_sizes: MovingAverage<20>,
66 pub lines_per_chunk: usize,
68 pub lines_moving: MovingAverage<20>,
70 total_bytes: usize,
72 total_duration: Duration,
74}
75
76impl Default for ChunkMetrics {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl ChunkMetrics {
83 pub fn new() -> Self {
84 Self {
85 chunks_seen: 0,
86 chunk_sizes: MovingAverage::new(),
87 parsed_per_ms: 0.0,
88 parsed_moving: MovingAverage::new(),
89 memory_bytes: 0,
90 memory_moving: MovingAverage::new(),
91 lines_per_chunk: 0,
92 lines_moving: MovingAverage::new(),
93 total_bytes: 0,
94 total_duration: Duration::new(0, 0),
95 }
96 }
97
98 pub fn record_chunk_processing(&mut self, chunk_size: usize, duration: Duration, lines: usize) {
99 self.chunks_seen += 1;
100 self.lines_per_chunk = lines;
101 self.lines_moving.push(lines as f64);
102
103 let mb_processed = chunk_size as f64 / (1024.0 * 1024.0);
105 let seconds = duration.as_secs_f64();
106 let mbps = if seconds > 0.0 { mb_processed / seconds } else { 0.0 };
107 self.parsed_per_ms = mbps;
108 self.parsed_moving.push(mbps);
109
110 if let Some(stats) = memory_stats::memory_stats() {
111 self.memory_bytes = stats.physical_mem;
112 self.memory_moving.push(stats.physical_mem as f64);
113 }
114
115 self.chunk_sizes.push(chunk_size as f64);
123 self.total_bytes += chunk_size;
124 self.total_duration += duration;
125 }
126
127 pub fn overall_throughput_mbps(&self) -> f64 {
128 let total_mb = self.total_bytes as f64 / (1024.0 * 1024.0);
129 let total_secs = self.total_duration.as_secs_f64();
130 if total_secs > 0.0 { total_mb / total_secs } else { 0.0 }
131 }
132
133 pub fn processing_speed_mbps(&self) -> f64 {
134 self.parsed_moving.average()
135 }
136
137 pub fn speed_moving(&self) -> &MovingAverage<20> {
138 &self.parsed_moving
139 }
140
141 pub fn memory_moving(&self) -> &MovingAverage<20> {
142 &self.memory_moving
143 }
144
145 pub fn chunk_sizes(&self) -> &MovingAverage<20> {
146 &self.chunk_sizes
147 }
148
149 pub fn should_adapt(&self, interval: usize) -> bool {
152 self.chunks_seen > 0 && self.chunks_seen % interval == 0 && self.lines_moving.count >= 3
154 }
155}
156
157#[derive(Debug, Clone)]
158pub struct MovingAverage<const N: usize> {
159 values: [f64; N],
160 index: usize,
161 count: usize,
162}
163
164impl<const N: usize> Default for MovingAverage<N> {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170impl<const N: usize> MovingAverage<N> {
171 pub fn new() -> Self {
172 Self {
173 values: [0.0; N],
174 index: 0,
175 count: 0,
176 }
177 }
178
179 pub fn push(&mut self, value: f64) {
181 self.values[self.index] = value;
182 self.index = (self.index + 1) % N; if self.count < N {
184 self.count += 1;
185 }
186 }
187
188 pub fn average(&self) -> f64 {
190 if self.count == 0 {
191 return 0.0;
192 }
193 let sum: f64 = if self.count < N {
194 self.values[0..self.count].iter().sum()
195 } else {
196 self.values.iter().sum()
197 };
198 sum / self.count as f64
199 }
200
201 pub fn trend(&self, stable: f64) -> Trend {
203 if self.count < 2 {
204 return Trend::Unknown;
206 }
207
208 let mid = self.count / 2;
209
210 let oldest_idx = if self.count < N {
212 0 } else {
214 self.index };
216
217 let mut ptr = oldest_idx;
218 let mut sum = 0.0;
219 for _i in 0..mid {
220 sum += self.values[ptr];
221 ptr = (ptr + 1) % N
222 }
223 let older = sum / mid as f64;
224
225 ptr = (oldest_idx + mid) % N;
226 let mut sum = 0.0;
227 for _i in 0..mid {
228 sum += self.values[ptr];
229 ptr = (ptr + 1) % N;
230 }
231 let newer = sum / mid as f64;
232
233 let percent_diff = if older != 0.0 {
238 ((newer - older) / older.abs()) * 100.0
239 } else {
240 0.0
241 };
242
243 if percent_diff.abs() < stable {
244 Trend::Stable
245 } else if percent_diff > 0.0 {
246 Trend::Improving
247 } else {
248 Trend::Degrading
249 }
250 }
251}
252
253#[derive(Debug, Clone, Default)]
254pub enum Trend {
255 Improving,
256 Stable,
257 Degrading,
258 #[default]
259 Unknown,
260}
261
262#[cfg(test)]
263mod test {
264 use super::*;
265
266 #[test]
267 fn metrics_collectors_work() {
268 let mut collector = MetricsCollector::new();
269
270 collector.record_chunk(8192, Duration::from_millis(10), 100);
272 collector.record_chunk(8192, Duration::from_millis(12), 95);
273 collector.record_chunk(8192, Duration::from_millis(8), 105);
274
275 assert!(!collector.ready_to_adapt(5));
277
278 collector.record_chunk(8192, Duration::from_millis(11), 98);
279 collector.record_chunk(8192, Duration::from_millis(9), 102);
280
281 assert!(collector.ready_to_adapt(5));
283
284 let snapshot = collector.snapshot();
286 assert!(snapshot.processing_speed_mbps() > 0.0);
287 }
288
289 #[test]
290 fn moving_average_can_average() {
291 let mut avg = MovingAverage::<5>::new();
292 avg.push(1.0);
293 avg.push(2.0);
294 avg.push(3.0);
295 assert_eq!(avg.average(), 2.0);
296
297 avg.push(4.0);
298 avg.push(5.0);
299 assert_eq!(avg.average(), 3.0);
300
301 avg.push(6.0); assert_eq!(avg.average(), 4.0); }
304
305 #[test]
306 fn can_keep_up_with_depeche_mode() {
307 let mut avg = MovingAverage::<4>::new();
308
309 avg.push(1.0);
311 avg.push(2.0);
312 avg.push(3.0);
313 avg.push(4.0);
314 assert!(matches!(avg.trend(0.5), Trend::Improving));
315
316 let mut avg = MovingAverage::<4>::new();
318 avg.push(5.0);
319 avg.push(5.1);
320 avg.push(4.9);
321 avg.push(5.0);
322 assert!(matches!(avg.trend(4.0), Trend::Stable));
323 }
324
325 #[test]
326 fn can_pass_thresholds() {
327 let mut avg = MovingAverage::<4>::new();
329 avg.push(50.0);
330 avg.push(51.0);
331 avg.push(49.0);
332 avg.push(50.0);
333 assert!(matches!(avg.trend(5.0), Trend::Stable));
334
335 let mut avg = MovingAverage::<4>::new();
337 avg.push(50.0);
338 avg.push(51.0);
339 avg.push(49.0);
340 avg.push(50.0);
341 assert!(matches!(avg.trend(0.5), Trend::Degrading));
342 }
343}