wlan_telemetry/processors/
scan.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use crate::util::cobalt_logger::log_cobalt_batch;
6use fidl_fuchsia_metrics::{MetricEvent, MetricEventPayload};
7use std::ops::BitOr;
8use windowed_stats::experimental::inspect::{InspectSender, InspectedTimeMatrix};
9use windowed_stats::experimental::series::interpolation::ConstantSample;
10use windowed_stats::experimental::series::metadata::BitSetMap;
11use windowed_stats::experimental::series::statistic::Union;
12use windowed_stats::experimental::series::{SamplingProfile, TimeMatrix};
13use {
14    fidl_fuchsia_power_battery as fidl_battery, fuchsia_async as fasync,
15    wlan_legacy_metrics_registry as metrics,
16};
17
18#[derive(Debug, PartialEq)]
19pub enum ScanResult {
20    Complete { num_results: usize },
21    Failed,
22    Cancelled,
23}
24
25pub struct ScanLogger {
26    cobalt_proxy: fidl_fuchsia_metrics::MetricEventLoggerProxy,
27    time_series_stats: ScanTimeSeries,
28    scan_started_at: Option<fasync::BootInstant>,
29    on_battery: bool,
30}
31
32impl ScanLogger {
33    pub fn new<S: InspectSender>(
34        cobalt_proxy: fidl_fuchsia_metrics::MetricEventLoggerProxy,
35        time_matrix_client: &S,
36    ) -> Self {
37        Self {
38            cobalt_proxy,
39            time_series_stats: ScanTimeSeries::new(time_matrix_client),
40            scan_started_at: None,
41            on_battery: false,
42        }
43    }
44
45    pub async fn handle_scan_start(&mut self) {
46        self.scan_started_at = Some(fasync::BootInstant::now());
47        self.time_series_stats.scan_events.fold_or_log_error(ScanEvents::START);
48        self.log_scan_start_cobalt().await;
49    }
50
51    pub async fn log_scan_start_cobalt(&mut self) {
52        let mut metric_events = vec![MetricEvent {
53            metric_id: metrics::SCAN_OCCURRENCE_METRIC_ID,
54            event_codes: vec![],
55            payload: MetricEventPayload::Count(1),
56        }];
57        if self.on_battery {
58            metric_events.push(MetricEvent {
59                metric_id: metrics::SCAN_OCCURRENCE_ON_BATTERY_METRIC_ID,
60                event_codes: vec![],
61                payload: MetricEventPayload::Count(1),
62            });
63        }
64        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_scan_start");
65    }
66
67    pub async fn handle_scan_result(&mut self, result: ScanResult) {
68        let mut metric_events = vec![];
69        let now = fasync::BootInstant::now();
70        // Only log scan result metrics if there was a scan
71        if let Some(scan_started_at) = self.scan_started_at.take() {
72            match result {
73                ScanResult::Complete { num_results } => {
74                    let scan_duration = now - scan_started_at;
75                    metric_events.push(MetricEvent {
76                        metric_id: metrics::SCAN_FULFILLMENT_TIME_METRIC_ID,
77                        event_codes: vec![],
78                        payload: MetricEventPayload::IntegerValue(scan_duration.into_millis()),
79                    });
80                    if num_results == 0 {
81                        metric_events.push(MetricEvent {
82                            metric_id: metrics::EMPTY_SCAN_RESULTS_METRIC_ID,
83                            event_codes: vec![],
84                            payload: MetricEventPayload::Count(1),
85                        });
86                    }
87                }
88                ScanResult::Failed => {
89                    metric_events.push(MetricEvent {
90                        metric_id: metrics::CLIENT_SCAN_FAILURE_METRIC_ID,
91                        event_codes: vec![],
92                        payload: MetricEventPayload::Count(1),
93                    });
94                }
95                ScanResult::Cancelled => {
96                    metric_events.push(MetricEvent {
97                        metric_id: metrics::ABORTED_SCAN_METRIC_ID,
98                        event_codes: vec![],
99                        payload: MetricEventPayload::Count(1),
100                    });
101                }
102            }
103        }
104
105        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_scan_result");
106    }
107
108    pub async fn handle_battery_charge_status(
109        &mut self,
110        charge_status: fidl_battery::ChargeStatus,
111    ) {
112        self.on_battery = matches!(charge_status, fidl_battery::ChargeStatus::Discharging);
113    }
114}
115
116#[derive(Default, Copy, Clone, Debug, PartialEq)]
117struct ScanEvents(u64);
118impl ScanEvents {
119    // Note: Keep these bits in sync with ScanEvents::bit_set_map
120    const START: Self = Self(1 << 0);
121}
122
123impl ScanEvents {
124    fn bit_set_map() -> BitSetMap {
125        BitSetMap::from_ordered(["start"])
126    }
127}
128
129impl BitOr for ScanEvents {
130    type Output = Self;
131
132    fn bitor(self, rhs: Self) -> Self::Output {
133        Self(self.0 | rhs.0)
134    }
135}
136
137impl From<ScanEvents> for u64 {
138    fn from(value: ScanEvents) -> u64 {
139        value.0
140    }
141}
142
143#[derive(Debug)]
144struct ScanTimeSeries {
145    scan_events: InspectedTimeMatrix<ScanEvents>,
146}
147
148impl ScanTimeSeries {
149    pub fn new<S: InspectSender>(client: &S) -> Self {
150        let scan_events = client.inspect_time_matrix_with_metadata(
151            "scan_events",
152            TimeMatrix::<Union<ScanEvents>, ConstantSample>::new(
153                SamplingProfile::highly_granular(),
154                ConstantSample::default(),
155            ),
156            ScanEvents::bit_set_map(),
157        );
158        Self { scan_events }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::testing::{TestHelper, setup_test};
166    use diagnostics_assertions::{AnyBytesProperty, assert_data_tree};
167    use futures::task::Poll;
168    use std::pin::pin;
169    use test_case::test_case;
170    use windowed_stats::experimental::clock::Timed;
171    use windowed_stats::experimental::inspect::TimeMatrixClient;
172    use windowed_stats::experimental::testing::TimeMatrixCall;
173
174    fn run_handle_scan_start(test_helper: &mut TestHelper, scan_logger: &mut ScanLogger) {
175        let mut test_fut = pin!(scan_logger.handle_scan_start());
176        assert_eq!(
177            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
178            Poll::Ready(())
179        );
180    }
181
182    fn run_handle_scan_result(
183        test_helper: &mut TestHelper,
184        scan_logger: &mut ScanLogger,
185        scan_result: ScanResult,
186    ) {
187        let mut test_fut = pin!(scan_logger.handle_scan_result(scan_result));
188        assert_eq!(
189            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
190            Poll::Ready(())
191        );
192    }
193
194    fn run_handle_battery_charge_status(
195        test_helper: &mut TestHelper,
196        scan_logger: &mut ScanLogger,
197        charge_status: fidl_battery::ChargeStatus,
198    ) {
199        let mut test_fut = pin!(scan_logger.handle_battery_charge_status(charge_status));
200        assert_eq!(
201            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
202            Poll::Ready(())
203        );
204    }
205
206    #[fuchsia::test]
207    fn test_handle_scan_start() {
208        let mut test_helper = setup_test();
209        let mut scan_logger =
210            ScanLogger::new(test_helper.cobalt_proxy.clone(), &test_helper.mock_time_matrix_client);
211
212        run_handle_scan_start(&mut test_helper, &mut scan_logger);
213
214        let metrics = test_helper.get_logged_metrics(metrics::SCAN_OCCURRENCE_METRIC_ID);
215        assert_eq!(metrics.len(), 1);
216        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
217
218        let metrics = test_helper.get_logged_metrics(metrics::SCAN_OCCURRENCE_ON_BATTERY_METRIC_ID);
219        assert!(metrics.is_empty());
220    }
221
222    #[fuchsia::test]
223    fn test_handle_scan_start_on_battery() {
224        let mut test_helper = setup_test();
225        let mut scan_logger =
226            ScanLogger::new(test_helper.cobalt_proxy.clone(), &test_helper.mock_time_matrix_client);
227
228        run_handle_battery_charge_status(
229            &mut test_helper,
230            &mut scan_logger,
231            fidl_battery::ChargeStatus::Discharging,
232        );
233        run_handle_scan_start(&mut test_helper, &mut scan_logger);
234
235        let metrics = test_helper.get_logged_metrics(metrics::SCAN_OCCURRENCE_METRIC_ID);
236        assert_eq!(metrics.len(), 1);
237        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
238
239        let metrics = test_helper.get_logged_metrics(metrics::SCAN_OCCURRENCE_ON_BATTERY_METRIC_ID);
240        assert_eq!(metrics.len(), 1);
241        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
242
243        // Set charge status to Charging and verify that scan_onccurrence_on_battery is not
244        // logged. This verifies that we do change back to off battery.
245        test_helper.clear_cobalt_events();
246        run_handle_battery_charge_status(
247            &mut test_helper,
248            &mut scan_logger,
249            fidl_battery::ChargeStatus::Charging,
250        );
251        run_handle_scan_start(&mut test_helper, &mut scan_logger);
252
253        let metrics = test_helper.get_logged_metrics(metrics::SCAN_OCCURRENCE_ON_BATTERY_METRIC_ID);
254        assert!(metrics.is_empty());
255    }
256
257    #[fuchsia::test]
258    fn test_handle_scan_result_complete() {
259        let mut test_helper = setup_test();
260        let mut scan_logger =
261            ScanLogger::new(test_helper.cobalt_proxy.clone(), &test_helper.mock_time_matrix_client);
262
263        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(20_000_000));
264        run_handle_scan_start(&mut test_helper, &mut scan_logger);
265
266        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(100_000_000));
267        let scan_result = ScanResult::Complete { num_results: 10 };
268        run_handle_scan_result(&mut test_helper, &mut scan_logger, scan_result);
269
270        let metrics = test_helper.get_logged_metrics(metrics::SCAN_FULFILLMENT_TIME_METRIC_ID);
271        assert_eq!(metrics.len(), 1);
272        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(80)); // 80ms
273        let metrics = test_helper.get_logged_metrics(metrics::EMPTY_SCAN_RESULTS_METRIC_ID);
274        assert!(metrics.is_empty());
275    }
276
277    #[fuchsia::test]
278    fn test_handle_scan_result_empty() {
279        let mut test_helper = setup_test();
280        let mut scan_logger =
281            ScanLogger::new(test_helper.cobalt_proxy.clone(), &test_helper.mock_time_matrix_client);
282
283        run_handle_scan_start(&mut test_helper, &mut scan_logger);
284
285        let scan_result = ScanResult::Complete { num_results: 0 };
286        run_handle_scan_result(&mut test_helper, &mut scan_logger, scan_result);
287
288        let metrics = test_helper.get_logged_metrics(metrics::SCAN_FULFILLMENT_TIME_METRIC_ID);
289        assert_eq!(metrics.len(), 1);
290        let metrics = test_helper.get_logged_metrics(metrics::EMPTY_SCAN_RESULTS_METRIC_ID);
291        assert_eq!(metrics.len(), 1);
292        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
293    }
294
295    #[fuchsia::test]
296    fn test_handle_scan_result_cancelled() {
297        let mut test_helper = setup_test();
298        let mut scan_logger =
299            ScanLogger::new(test_helper.cobalt_proxy.clone(), &test_helper.mock_time_matrix_client);
300
301        run_handle_scan_start(&mut test_helper, &mut scan_logger);
302
303        let scan_result = ScanResult::Cancelled;
304        run_handle_scan_result(&mut test_helper, &mut scan_logger, scan_result);
305
306        let metrics = test_helper.get_logged_metrics(metrics::ABORTED_SCAN_METRIC_ID);
307        assert_eq!(metrics.len(), 1);
308        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
309    }
310
311    #[fuchsia::test]
312    fn test_handle_scan_result_failure() {
313        let mut test_helper = setup_test();
314        let mut scan_logger =
315            ScanLogger::new(test_helper.cobalt_proxy.clone(), &test_helper.mock_time_matrix_client);
316
317        run_handle_scan_start(&mut test_helper, &mut scan_logger);
318
319        let scan_result = ScanResult::Failed;
320        run_handle_scan_result(&mut test_helper, &mut scan_logger, scan_result);
321
322        let metrics = test_helper.get_logged_metrics(metrics::CLIENT_SCAN_FAILURE_METRIC_ID);
323        assert_eq!(metrics.len(), 1);
324        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
325    }
326
327    #[test_case(
328        ScanResult::Complete { num_results: 10 },
329        metrics::SCAN_FULFILLMENT_TIME_METRIC_ID;
330        "scan complete"
331    )]
332    #[test_case(
333        ScanResult::Failed,
334        metrics::CLIENT_SCAN_FAILURE_METRIC_ID;
335        "scan failed"
336    )]
337    #[test_case(
338        ScanResult::Cancelled,
339        metrics::ABORTED_SCAN_METRIC_ID;
340        "scan cancelled"
341    )]
342    #[fuchsia::test(add_test_attr = false)]
343    fn test_handle_scan_result_no_logging_to_cobalt_if_scan_not_started(
344        scan_result: ScanResult,
345        metric_id: u32,
346    ) {
347        let mut test_helper = setup_test();
348        let mut scan_logger =
349            ScanLogger::new(test_helper.cobalt_proxy.clone(), &test_helper.mock_time_matrix_client);
350
351        run_handle_scan_result(&mut test_helper, &mut scan_logger, scan_result);
352
353        let metrics = test_helper.get_logged_metrics(metric_id);
354        assert!(metrics.is_empty());
355    }
356
357    #[fuchsia::test]
358    fn scan_logger_new_then_inspect_data_tree_contains_time_matrix_metadata() {
359        let mut test_helper = setup_test();
360        let client = TimeMatrixClient::new(test_helper.inspect_node.create_child("wlan_scan"));
361        let _scan_logger = ScanLogger::new(test_helper.cobalt_proxy.clone(), &client);
362
363        let tree = test_helper.get_inspect_data_tree();
364        assert_data_tree!(
365            @executor test_helper.exec,
366            tree,
367            root: contains {
368                test_stats: contains {
369                    wlan_scan: contains {
370                        scan_events: {
371                            "type": "bitset",
372                            "data": AnyBytesProperty,
373                            metadata: {
374                                index: {
375                                    "0": "start",
376                                }
377                            }
378                        }
379                    }
380                }
381            }
382        );
383    }
384
385    #[fuchsia::test]
386    fn log_scan_start_inspect() {
387        let mut test_helper = setup_test();
388        let mut scan_logger =
389            ScanLogger::new(test_helper.cobalt_proxy.clone(), &test_helper.mock_time_matrix_client);
390
391        run_handle_scan_start(&mut test_helper, &mut scan_logger);
392
393        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
394        assert_eq!(
395            &time_matrix_calls.drain::<ScanEvents>("scan_events")[..],
396            &[TimeMatrixCall::Fold(Timed::now(ScanEvents::START))]
397        );
398    }
399}