wlan_telemetry/processors/
connect_disconnect.rs

1// Copyright 2024 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 derivative::Derivative;
7use fidl_fuchsia_metrics::{MetricEvent, MetricEventPayload};
8use fuchsia_inspect::Node as InspectNode;
9use fuchsia_inspect_auto_persist::{self as auto_persist, AutoPersist};
10use fuchsia_inspect_contrib::id_enum::IdEnum;
11use fuchsia_inspect_contrib::nodes::{BoundedListNode, LruCacheNode};
12use fuchsia_inspect_contrib::{inspect_insert, inspect_log};
13use fuchsia_inspect_derive::Unit;
14use fuchsia_sync::Mutex;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::Arc;
17use strum_macros::{Display, EnumIter};
18use windowed_stats::experimental::clock::Timed;
19use windowed_stats::experimental::series::interpolation::{Constant, LastSample};
20use windowed_stats::experimental::series::metadata::{BitSetMap, BitSetNode};
21use windowed_stats::experimental::series::statistic::Union;
22use windowed_stats::experimental::series::{SamplingProfile, TimeMatrix};
23use windowed_stats::experimental::serve::{InspectSender, InspectedTimeMatrix};
24use wlan_common::bss::BssDescription;
25use wlan_common::channel::Channel;
26use {
27    fidl_fuchsia_wlan_ieee80211 as fidl_ieee80211, fidl_fuchsia_wlan_sme as fidl_sme,
28    fuchsia_async as fasync, wlan_legacy_metrics_registry as metrics, zx,
29};
30
31const INSPECT_CONNECT_EVENTS_LIMIT: usize = 10;
32const INSPECT_DISCONNECT_EVENTS_LIMIT: usize = 10;
33const INSPECT_CONNECTED_NETWORKS_ID_LIMIT: usize = 16;
34const INSPECT_DISCONNECT_SOURCES_ID_LIMIT: usize = 32;
35const SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_TIMEOUT: zx::BootDuration =
36    zx::BootDuration::from_minutes(2);
37
38#[derive(Debug, Display, EnumIter)]
39enum ConnectionState {
40    Idle(IdleState),
41    Connected(ConnectedState),
42    Disconnected(DisconnectedState),
43}
44
45impl IdEnum for ConnectionState {
46    type Id = u8;
47    fn to_id(&self) -> Self::Id {
48        match self {
49            Self::Idle(_) => 0,
50            Self::Disconnected(_) => 1,
51            Self::Connected(_) => 2,
52        }
53    }
54}
55
56#[derive(Debug, Default)]
57struct IdleState {}
58
59#[derive(Debug, Default)]
60struct ConnectedState {}
61
62#[derive(Debug, Default)]
63struct DisconnectedState {}
64
65#[derive(Derivative, Unit)]
66#[derivative(PartialEq, Eq, Hash)]
67struct InspectConnectedNetwork {
68    bssid: String,
69    ssid: String,
70    protection: String,
71    ht_cap: Option<Vec<u8>>,
72    vht_cap: Option<Vec<u8>>,
73    #[derivative(PartialEq = "ignore")]
74    #[derivative(Hash = "ignore")]
75    wsc: Option<InspectNetworkWsc>,
76    is_wmm_assoc: bool,
77    wmm_param: Option<Vec<u8>>,
78}
79
80impl From<&BssDescription> for InspectConnectedNetwork {
81    fn from(bss_description: &BssDescription) -> Self {
82        Self {
83            bssid: bss_description.bssid.to_string(),
84            ssid: bss_description.ssid.to_string(),
85            protection: format!("{:?}", bss_description.protection()),
86            ht_cap: bss_description.raw_ht_cap().map(|cap| cap.bytes.into()),
87            vht_cap: bss_description.raw_vht_cap().map(|cap| cap.bytes.into()),
88            wsc: bss_description.probe_resp_wsc().as_ref().map(InspectNetworkWsc::from),
89            is_wmm_assoc: bss_description.find_wmm_param().is_some(),
90            wmm_param: bss_description.find_wmm_param().map(|bytes| bytes.into()),
91        }
92    }
93}
94
95#[derive(PartialEq, Unit, Hash)]
96struct InspectNetworkWsc {
97    device_name: String,
98    manufacturer: String,
99    model_name: String,
100    model_number: String,
101}
102
103impl From<&wlan_common::ie::wsc::ProbeRespWsc> for InspectNetworkWsc {
104    fn from(wsc: &wlan_common::ie::wsc::ProbeRespWsc) -> Self {
105        Self {
106            device_name: String::from_utf8_lossy(&wsc.device_name[..]).to_string(),
107            manufacturer: String::from_utf8_lossy(&wsc.manufacturer[..]).to_string(),
108            model_name: String::from_utf8_lossy(&wsc.model_name[..]).to_string(),
109            model_number: String::from_utf8_lossy(&wsc.model_number[..]).to_string(),
110        }
111    }
112}
113
114#[derive(PartialEq, Eq, Unit, Hash)]
115struct InspectDisconnectSource {
116    source: String,
117    reason: String,
118    mlme_event_name: Option<String>,
119}
120
121impl From<&fidl_sme::DisconnectSource> for InspectDisconnectSource {
122    fn from(disconnect_source: &fidl_sme::DisconnectSource) -> Self {
123        match disconnect_source {
124            fidl_sme::DisconnectSource::User(reason) => Self {
125                source: "user".to_string(),
126                reason: format!("{reason:?}"),
127                mlme_event_name: None,
128            },
129            fidl_sme::DisconnectSource::Ap(cause) => Self {
130                source: "ap".to_string(),
131                reason: format!("{:?}", cause.reason_code),
132                mlme_event_name: Some(format!("{:?}", cause.mlme_event_name)),
133            },
134            fidl_sme::DisconnectSource::Mlme(cause) => Self {
135                source: "mlme".to_string(),
136                reason: format!("{:?}", cause.reason_code),
137                mlme_event_name: Some(format!("{:?}", cause.mlme_event_name)),
138            },
139        }
140    }
141}
142
143#[derive(Clone, Debug, PartialEq)]
144pub struct DisconnectInfo {
145    pub iface_id: u16,
146    pub connected_duration: zx::BootDuration,
147    pub is_sme_reconnecting: bool,
148    pub disconnect_source: fidl_sme::DisconnectSource,
149    pub original_bss_desc: Box<BssDescription>,
150    pub current_rssi_dbm: i8,
151    pub current_snr_db: i8,
152    pub current_channel: Channel,
153}
154
155pub struct ConnectDisconnectLogger {
156    connection_state: Arc<Mutex<ConnectionState>>,
157    cobalt_proxy: fidl_fuchsia_metrics::MetricEventLoggerProxy,
158    connect_events_node: Mutex<AutoPersist<BoundedListNode>>,
159    disconnect_events_node: Mutex<AutoPersist<BoundedListNode>>,
160    inspect_metadata_node: Mutex<InspectMetadataNode>,
161    time_series_stats: ConnectDisconnectTimeSeries,
162    successive_connect_attempt_failures: AtomicUsize,
163    last_connect_failure_at: Arc<Mutex<Option<fasync::BootInstant>>>,
164    last_disconnect_at: Arc<Mutex<Option<fasync::MonotonicInstant>>>,
165}
166
167impl ConnectDisconnectLogger {
168    pub fn new<S: InspectSender>(
169        cobalt_proxy: fidl_fuchsia_metrics::MetricEventLoggerProxy,
170        inspect_node: &InspectNode,
171        inspect_metadata_node: &InspectNode,
172        inspect_metadata_path: &str,
173        persistence_req_sender: auto_persist::PersistenceReqSender,
174        time_matrix_client: &S,
175    ) -> Self {
176        let connect_events = inspect_node.create_child("connect_events");
177        let disconnect_events = inspect_node.create_child("disconnect_events");
178        let this = Self {
179            cobalt_proxy,
180            connection_state: Arc::new(Mutex::new(ConnectionState::Idle(IdleState {}))),
181            connect_events_node: Mutex::new(AutoPersist::new(
182                BoundedListNode::new(connect_events, INSPECT_CONNECT_EVENTS_LIMIT),
183                "wlan-connect-events",
184                persistence_req_sender.clone(),
185            )),
186            disconnect_events_node: Mutex::new(AutoPersist::new(
187                BoundedListNode::new(disconnect_events, INSPECT_DISCONNECT_EVENTS_LIMIT),
188                "wlan-disconnect-events",
189                persistence_req_sender,
190            )),
191            inspect_metadata_node: Mutex::new(InspectMetadataNode::new(inspect_metadata_node)),
192            time_series_stats: ConnectDisconnectTimeSeries::new(
193                time_matrix_client,
194                inspect_metadata_path,
195            ),
196            successive_connect_attempt_failures: AtomicUsize::new(0),
197            last_connect_failure_at: Arc::new(Mutex::new(None)),
198            last_disconnect_at: Arc::new(Mutex::new(None)),
199        };
200        this.log_connection_state();
201        this
202    }
203
204    fn update_connection_state(&self, state: ConnectionState) {
205        *self.connection_state.lock() = state;
206        self.log_connection_state();
207    }
208
209    fn log_connection_state(&self) {
210        let wlan_connectivity_state_id = self.connection_state.lock().to_id() as u64;
211        self.time_series_stats.log_wlan_connectivity_state(1 << wlan_connectivity_state_id);
212    }
213
214    pub async fn handle_connect_attempt(
215        &self,
216        result: fidl_ieee80211::StatusCode,
217        bss: &BssDescription,
218    ) {
219        let mut flushed_successive_failures = None;
220        let mut downtime_duration = None;
221        if result == fidl_ieee80211::StatusCode::Success {
222            self.update_connection_state(ConnectionState::Connected(ConnectedState {}));
223            // TODO(https://fxbug.dev/412460463): Also flush and log
224            // successive_connect_attempt_failures metric on suspend
225            flushed_successive_failures =
226                Some(self.successive_connect_attempt_failures.swap(0, Ordering::SeqCst));
227            downtime_duration =
228                self.last_disconnect_at.lock().map(|t| fasync::MonotonicInstant::now() - t);
229        } else {
230            self.update_connection_state(ConnectionState::Idle(IdleState {}));
231            let _prev = self.successive_connect_attempt_failures.fetch_add(1, Ordering::SeqCst);
232            let _prev = self.last_connect_failure_at.lock().replace(fasync::BootInstant::now());
233        }
234
235        self.log_connect_attempt_inspect(result, bss);
236        self.log_connect_attempt_cobalt(result, flushed_successive_failures, downtime_duration)
237            .await;
238    }
239
240    pub fn log_connect_attempt_inspect(
241        &self,
242        result: fidl_ieee80211::StatusCode,
243        bss: &BssDescription,
244    ) {
245        if result == fidl_ieee80211::StatusCode::Success {
246            let mut inspect_metadata_node = self.inspect_metadata_node.lock();
247            let connected_network = InspectConnectedNetwork::from(bss);
248            let connected_network_id =
249                inspect_metadata_node.connected_networks.insert(connected_network) as u64;
250
251            self.time_series_stats.log_connected_networks(1 << connected_network_id);
252
253            inspect_log!(self.connect_events_node.lock().get_mut(), {
254                network_id: connected_network_id,
255            });
256        }
257    }
258
259    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
260    pub async fn log_connect_attempt_cobalt(
261        &self,
262        result: fidl_ieee80211::StatusCode,
263        flushed_successive_failures: Option<usize>,
264        downtime_duration: Option<zx::MonotonicDuration>,
265    ) {
266        let mut metric_events = vec![];
267        metric_events.push(MetricEvent {
268            metric_id: metrics::CONNECT_ATTEMPT_BREAKDOWN_BY_STATUS_CODE_METRIC_ID,
269            event_codes: vec![result as u32],
270            payload: MetricEventPayload::Count(1),
271        });
272
273        if let Some(failures) = flushed_successive_failures {
274            metric_events.push(MetricEvent {
275                metric_id: metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID,
276                event_codes: vec![],
277                payload: MetricEventPayload::IntegerValue(failures as i64),
278            });
279        }
280
281        if let Some(duration) = downtime_duration {
282            metric_events.push(MetricEvent {
283                metric_id: metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID,
284                event_codes: vec![],
285                payload: MetricEventPayload::IntegerValue(duration.into_millis()),
286            });
287        }
288
289        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "log_connect_attempt_cobalt_metrics");
290    }
291
292    pub async fn log_disconnect(&self, info: &DisconnectInfo) {
293        self.update_connection_state(ConnectionState::Disconnected(DisconnectedState {}));
294        let _prev = self.last_disconnect_at.lock().replace(fasync::MonotonicInstant::now());
295        self.log_disconnect_inspect(info);
296        self.log_disconnect_cobalt(info).await;
297    }
298
299    fn log_disconnect_inspect(&self, info: &DisconnectInfo) {
300        let mut inspect_metadata_node = self.inspect_metadata_node.lock();
301        let connected_network = InspectConnectedNetwork::from(&*info.original_bss_desc);
302        let connected_network_id =
303            inspect_metadata_node.connected_networks.insert(connected_network) as u64;
304        let disconnect_source = InspectDisconnectSource::from(&info.disconnect_source);
305        let disconnect_source_id =
306            inspect_metadata_node.disconnect_sources.insert(disconnect_source) as u64;
307        inspect_log!(self.disconnect_events_node.lock().get_mut(), {
308            connected_duration: info.connected_duration.into_nanos(),
309            disconnect_source_id: disconnect_source_id,
310            network_id: connected_network_id,
311            rssi_dbm: info.current_rssi_dbm,
312            snr_db: info.current_snr_db,
313            channel: format!("{}", info.current_channel),
314        });
315
316        self.time_series_stats.log_disconnected_networks(1 << connected_network_id);
317        self.time_series_stats.log_disconnect_sources(1 << disconnect_source_id);
318    }
319
320    async fn log_disconnect_cobalt(&self, info: &DisconnectInfo) {
321        let mut metric_events = vec![];
322        metric_events.push(MetricEvent {
323            metric_id: metrics::TOTAL_DISCONNECT_COUNT_METRIC_ID,
324            event_codes: vec![],
325            payload: MetricEventPayload::Count(1),
326        });
327
328        if should_log_disconnect_for_mobile_device(info) {
329            metric_events.push(MetricEvent {
330                metric_id: metrics::DISCONNECT_OCCURRENCE_FOR_MOBILE_DEVICE_METRIC_ID,
331                event_codes: vec![],
332                payload: MetricEventPayload::Count(1),
333            });
334        }
335
336        metric_events.push(MetricEvent {
337            metric_id: metrics::CONNECTED_DURATION_ON_DISCONNECT_METRIC_ID,
338            event_codes: vec![],
339            payload: MetricEventPayload::IntegerValue(info.connected_duration.into_millis()),
340        });
341
342        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "log_disconnect_cobalt_metrics");
343    }
344
345    pub async fn handle_periodic_telemetry(&self) {
346        let mut metric_events = vec![];
347        let now = fasync::BootInstant::now();
348        if let Some(failed_at) = *self.last_connect_failure_at.lock() {
349            if now - failed_at >= SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_TIMEOUT {
350                let failures = self.successive_connect_attempt_failures.swap(0, Ordering::SeqCst);
351                if failures > 0 {
352                    metric_events.push(MetricEvent {
353                        metric_id: metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID,
354                        event_codes: vec![],
355                        payload: MetricEventPayload::IntegerValue(failures as i64),
356                    });
357                }
358            }
359        }
360
361        if !metric_events.is_empty() {
362            log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_periodic_telemetry");
363        }
364    }
365}
366
367fn should_log_disconnect_for_mobile_device(info: &DisconnectInfo) -> bool {
368    match info.disconnect_source {
369        fidl_sme::DisconnectSource::Ap(_) => true,
370        fidl_sme::DisconnectSource::Mlme(cause)
371            if cause.reason_code != fidl_ieee80211::ReasonCode::MlmeLinkFailed =>
372        {
373            true
374        }
375        _ => false,
376    }
377}
378
379struct InspectMetadataNode {
380    connected_networks: LruCacheNode<InspectConnectedNetwork>,
381    disconnect_sources: LruCacheNode<InspectDisconnectSource>,
382}
383
384impl InspectMetadataNode {
385    const CONNECTED_NETWORKS: &'static str = "connected_networks";
386    const DISCONNECT_SOURCES: &'static str = "disconnect_sources";
387
388    fn new(inspect_node: &InspectNode) -> Self {
389        let connected_networks = inspect_node.create_child(Self::CONNECTED_NETWORKS);
390        let disconnect_sources = inspect_node.create_child(Self::DISCONNECT_SOURCES);
391        Self {
392            connected_networks: LruCacheNode::new(
393                connected_networks,
394                INSPECT_CONNECTED_NETWORKS_ID_LIMIT,
395            ),
396            disconnect_sources: LruCacheNode::new(
397                disconnect_sources,
398                INSPECT_DISCONNECT_SOURCES_ID_LIMIT,
399            ),
400        }
401    }
402}
403
404#[derive(Debug, Clone)]
405struct ConnectDisconnectTimeSeries {
406    wlan_connectivity_states: InspectedTimeMatrix<u64>,
407    connected_networks: InspectedTimeMatrix<u64>,
408    disconnected_networks: InspectedTimeMatrix<u64>,
409    disconnect_sources: InspectedTimeMatrix<u64>,
410}
411
412impl ConnectDisconnectTimeSeries {
413    pub fn new<S: InspectSender>(client: &S, inspect_metadata_path: &str) -> Self {
414        let wlan_connectivity_states = client.inspect_time_matrix_with_metadata(
415            "wlan_connectivity_states",
416            TimeMatrix::<Union<u64>, LastSample>::new(
417                SamplingProfile::highly_granular(),
418                LastSample::or(0),
419            ),
420            BitSetMap::from_ordered(["idle", "disconnected", "connected"]),
421        );
422        let connected_networks = client.inspect_time_matrix_with_metadata(
423            "connected_networks",
424            TimeMatrix::<Union<u64>, Constant>::new(
425                SamplingProfile::granular(),
426                Constant::default(),
427            ),
428            BitSetNode::from_path(format!(
429                "{}/{}",
430                inspect_metadata_path,
431                InspectMetadataNode::CONNECTED_NETWORKS
432            )),
433        );
434        let disconnected_networks = client.inspect_time_matrix_with_metadata(
435            "disconnected_networks",
436            TimeMatrix::<Union<u64>, Constant>::new(
437                SamplingProfile::granular(),
438                Constant::default(),
439            ),
440            // This time matrix shares its bit labels with `connected_networks`.
441            BitSetNode::from_path(format!(
442                "{}/{}",
443                inspect_metadata_path,
444                InspectMetadataNode::CONNECTED_NETWORKS
445            )),
446        );
447        let disconnect_sources = client.inspect_time_matrix_with_metadata(
448            "disconnect_sources",
449            TimeMatrix::<Union<u64>, Constant>::new(
450                SamplingProfile::granular(),
451                Constant::default(),
452            ),
453            BitSetNode::from_path(format!(
454                "{}/{}",
455                inspect_metadata_path,
456                InspectMetadataNode::DISCONNECT_SOURCES,
457            )),
458        );
459        Self {
460            wlan_connectivity_states,
461            connected_networks,
462            disconnected_networks,
463            disconnect_sources,
464        }
465    }
466
467    fn log_wlan_connectivity_state(&self, data: u64) {
468        self.wlan_connectivity_states.fold_or_log_error(Timed::now(data));
469    }
470    fn log_connected_networks(&self, data: u64) {
471        self.connected_networks.fold_or_log_error(Timed::now(data));
472    }
473    fn log_disconnected_networks(&self, data: u64) {
474        self.disconnected_networks.fold_or_log_error(Timed::now(data));
475    }
476    fn log_disconnect_sources(&self, data: u64) {
477        self.disconnect_sources.fold_or_log_error(Timed::now(data));
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::testing::*;
485    use diagnostics_assertions::{
486        assert_data_tree, AnyBoolProperty, AnyBytesProperty, AnyNumericProperty, AnyStringProperty,
487    };
488
489    use futures::task::Poll;
490    use ieee80211_testutils::{BSSID_REGEX, SSID_REGEX};
491    use rand::Rng;
492    use std::pin::pin;
493    use test_case::test_case;
494    use windowed_stats::experimental::serve;
495    use windowed_stats::experimental::testing::TimeMatrixCall;
496    use wlan_common::channel::{Cbw, Channel};
497    use wlan_common::{fake_bss_description, random_bss_description};
498
499    #[fuchsia::test]
500    fn log_connect_attempt_then_inspect_data_tree_contains_time_matrix_metadata() {
501        let mut harness = setup_test();
502
503        let (client, _server) = serve::serve_time_matrix_inspection(
504            harness.inspect_node.create_child("wlan_connect_disconnect"),
505        );
506        let logger = ConnectDisconnectLogger::new(
507            harness.cobalt_proxy.clone(),
508            &harness.inspect_node,
509            &harness.inspect_metadata_node,
510            &harness.inspect_metadata_path,
511            harness.persistence_sender.clone(),
512            &client,
513        );
514        let bss = random_bss_description!();
515        let mut log_connect_attempt =
516            pin!(logger.handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss));
517        assert!(
518            harness.run_until_stalled_drain_cobalt_events(&mut log_connect_attempt).is_ready(),
519            "`log_connect_attempt` did not complete",
520        );
521
522        let tree = harness.get_inspect_data_tree();
523        assert_data_tree!(
524            tree,
525            root: contains {
526                test_stats: contains {
527                    wlan_connect_disconnect: contains {
528                        wlan_connectivity_states: {
529                            "type": "bitset",
530                            "data": AnyBytesProperty,
531                            metadata: {
532                                index: {
533                                    "0": "idle",
534                                    "1": "disconnected",
535                                    "2": "connected",
536                                },
537                            },
538                        },
539                        connected_networks: {
540                            "type": "bitset",
541                            "data": AnyBytesProperty,
542                            metadata: {
543                                "index_node_path": "root/test_stats/metadata/connected_networks",
544                            },
545                        },
546                        disconnected_networks: {
547                            "type": "bitset",
548                            "data": AnyBytesProperty,
549                            metadata: {
550                                "index_node_path": "root/test_stats/metadata/connected_networks",
551                            },
552                        },
553                        disconnect_sources: {
554                            "type": "bitset",
555                            "data": AnyBytesProperty,
556                            metadata: {
557                                "index_node_path": "root/test_stats/metadata/disconnect_sources",
558                            },
559                        },
560                    },
561                },
562            }
563        );
564    }
565
566    #[fuchsia::test]
567    fn test_log_connect_attempt_inspect() {
568        let mut test_helper = setup_test();
569        let logger = ConnectDisconnectLogger::new(
570            test_helper.cobalt_proxy.clone(),
571            &test_helper.inspect_node,
572            &test_helper.inspect_metadata_node,
573            &test_helper.inspect_metadata_path,
574            test_helper.persistence_sender.clone(),
575            &test_helper.mock_time_matrix_client,
576        );
577
578        // Log the event
579        let bss_description = random_bss_description!();
580        let mut test_fut =
581            pin!(logger
582                .handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss_description));
583        assert_eq!(
584            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
585            Poll::Ready(())
586        );
587
588        // Validate Inspect data
589        let data = test_helper.get_inspect_data_tree();
590        assert_data_tree!(data, root: contains {
591            test_stats: contains {
592                metadata: contains {
593                    connected_networks: contains {
594                        "0": {
595                            "@time": AnyNumericProperty,
596                            "data": contains {
597                                bssid: &*BSSID_REGEX,
598                                ssid: &*SSID_REGEX,
599                            }
600                        }
601                    },
602                },
603                connect_events: {
604                    "0": {
605                        "@time": AnyNumericProperty,
606                        network_id: 0u64,
607                    }
608                }
609            }
610        });
611
612        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
613        assert_eq!(
614            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
615            &[TimeMatrixCall::Fold(Timed::now(1 << 0)), TimeMatrixCall::Fold(Timed::now(1 << 2)),]
616        );
617        assert_eq!(
618            &time_matrix_calls.drain::<u64>("connected_networks")[..],
619            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
620        );
621    }
622
623    #[fuchsia::test]
624    fn test_log_connect_attempt_cobalt() {
625        let mut test_helper = setup_test();
626        let logger = ConnectDisconnectLogger::new(
627            test_helper.cobalt_proxy.clone(),
628            &test_helper.inspect_node,
629            &test_helper.inspect_metadata_node,
630            &test_helper.inspect_metadata_path,
631            test_helper.persistence_sender.clone(),
632            &test_helper.mock_time_matrix_client,
633        );
634
635        // Generate BSS Description
636        let bss_description = random_bss_description!(Wpa2,
637            channel: Channel::new(157, Cbw::Cbw40),
638            bssid: [0x00, 0xf6, 0x20, 0x03, 0x04, 0x05],
639        );
640
641        // Log the event
642        let mut test_fut =
643            pin!(logger
644                .handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss_description));
645        assert_eq!(
646            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
647            Poll::Ready(())
648        );
649
650        // Validate Cobalt data
651        let breakdowns_by_status_code = test_helper
652            .get_logged_metrics(metrics::CONNECT_ATTEMPT_BREAKDOWN_BY_STATUS_CODE_METRIC_ID);
653        assert_eq!(breakdowns_by_status_code.len(), 1);
654        assert_eq!(
655            breakdowns_by_status_code[0].event_codes,
656            vec![fidl_ieee80211::StatusCode::Success as u32]
657        );
658        assert_eq!(breakdowns_by_status_code[0].payload, MetricEventPayload::Count(1));
659    }
660
661    #[fuchsia::test]
662    fn test_successive_connect_attempt_failures_cobalt_zero_failures() {
663        let mut test_helper = setup_test();
664        let logger = ConnectDisconnectLogger::new(
665            test_helper.cobalt_proxy.clone(),
666            &test_helper.inspect_node,
667            &test_helper.inspect_metadata_node,
668            &test_helper.inspect_metadata_path,
669            test_helper.persistence_sender.clone(),
670            &test_helper.mock_time_matrix_client,
671        );
672
673        let bss_description = random_bss_description!(Wpa2);
674        let mut test_fut =
675            pin!(logger
676                .handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss_description));
677        assert_eq!(
678            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
679            Poll::Ready(())
680        );
681
682        let metrics =
683            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
684        assert_eq!(metrics.len(), 1);
685        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(0));
686    }
687
688    #[test_case(1; "one_failure")]
689    #[test_case(2; "two_failures")]
690    #[fuchsia::test(add_test_attr = false)]
691    fn test_successive_connect_attempt_failures_cobalt_one_failure_then_success(n_failures: usize) {
692        let mut test_helper = setup_test();
693        let logger = ConnectDisconnectLogger::new(
694            test_helper.cobalt_proxy.clone(),
695            &test_helper.inspect_node,
696            &test_helper.inspect_metadata_node,
697            &test_helper.inspect_metadata_path,
698            test_helper.persistence_sender.clone(),
699            &test_helper.mock_time_matrix_client,
700        );
701
702        let bss_description = random_bss_description!(Wpa2);
703        for _i in 0..n_failures {
704            let mut test_fut = pin!(logger.handle_connect_attempt(
705                fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
706                &bss_description
707            ));
708            assert_eq!(
709                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
710                Poll::Ready(())
711            );
712        }
713
714        let metrics =
715            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
716        assert!(metrics.is_empty());
717
718        let mut test_fut =
719            pin!(logger
720                .handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss_description));
721        assert_eq!(
722            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
723            Poll::Ready(())
724        );
725
726        let metrics =
727            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
728        assert_eq!(metrics.len(), 1);
729        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(n_failures as i64));
730
731        // Verify subsequent successes would report 0 failures
732        test_helper.clear_cobalt_events();
733        let mut test_fut =
734            pin!(logger
735                .handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss_description));
736        assert_eq!(
737            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
738            Poll::Ready(())
739        );
740        let metrics =
741            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
742        assert_eq!(metrics.len(), 1);
743        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(0));
744    }
745
746    #[test_case(1; "one_failure")]
747    #[test_case(2; "two_failures")]
748    #[fuchsia::test(add_test_attr = false)]
749    fn test_successive_connect_attempt_failures_cobalt_one_failure_then_timeout(n_failures: usize) {
750        let mut test_helper = setup_test();
751        let logger = ConnectDisconnectLogger::new(
752            test_helper.cobalt_proxy.clone(),
753            &test_helper.inspect_node,
754            &test_helper.inspect_metadata_node,
755            &test_helper.inspect_metadata_path,
756            test_helper.persistence_sender.clone(),
757            &test_helper.mock_time_matrix_client,
758        );
759
760        let bss_description = random_bss_description!(Wpa2);
761        for _i in 0..n_failures {
762            let mut test_fut = pin!(logger.handle_connect_attempt(
763                fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
764                &bss_description
765            ));
766            assert_eq!(
767                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
768                Poll::Ready(())
769            );
770        }
771
772        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(60_000_000_000));
773        let mut test_fut = pin!(logger.handle_periodic_telemetry());
774        assert_eq!(
775            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
776            Poll::Ready(())
777        );
778
779        // Not enough time has passed, so successive_connect_attempt_failures is not flushed yet
780        let metrics =
781            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
782        assert!(metrics.is_empty());
783
784        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(120_000_000_000));
785        let mut test_fut = pin!(logger.handle_periodic_telemetry());
786        assert_eq!(
787            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
788            Poll::Ready(())
789        );
790
791        let metrics =
792            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
793        assert_eq!(metrics.len(), 1);
794        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(n_failures as i64));
795
796        // Verify timeout fires only once
797        test_helper.clear_cobalt_events();
798        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(240_000_000_000));
799        let mut test_fut = pin!(logger.handle_periodic_telemetry());
800        assert_eq!(
801            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
802            Poll::Ready(())
803        );
804        let metrics =
805            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
806        assert!(metrics.is_empty());
807    }
808
809    #[fuchsia::test]
810    fn test_log_disconnect_inspect() {
811        let mut test_helper = setup_test();
812        let logger = ConnectDisconnectLogger::new(
813            test_helper.cobalt_proxy.clone(),
814            &test_helper.inspect_node,
815            &test_helper.inspect_metadata_node,
816            &test_helper.inspect_metadata_path,
817            test_helper.persistence_sender.clone(),
818            &test_helper.mock_time_matrix_client,
819        );
820
821        // Log the event
822        let bss_description = fake_bss_description!(Open);
823        let channel = bss_description.channel;
824        let disconnect_info = DisconnectInfo {
825            iface_id: 32,
826            connected_duration: zx::BootDuration::from_seconds(30),
827            is_sme_reconnecting: false,
828            disconnect_source: fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
829                mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
830                reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
831            }),
832            original_bss_desc: Box::new(bss_description),
833            current_rssi_dbm: -30,
834            current_snr_db: 25,
835            current_channel: channel,
836        };
837        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
838        assert_eq!(
839            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
840            Poll::Ready(())
841        );
842
843        // Validate Inspect data
844        let data = test_helper.get_inspect_data_tree();
845        assert_data_tree!(data, root: contains {
846            test_stats: contains {
847                metadata: {
848                    connected_networks: {
849                        "0": {
850                            "@time": AnyNumericProperty,
851                            "data": {
852                                bssid: &*BSSID_REGEX,
853                                ssid: &*SSID_REGEX,
854                                ht_cap: AnyBytesProperty,
855                                vht_cap: AnyBytesProperty,
856                                protection: "Open",
857                                is_wmm_assoc: AnyBoolProperty,
858                                wmm_param: AnyBytesProperty,
859                            }
860                        }
861                    },
862                    disconnect_sources: {
863                        "0": {
864                            "@time": AnyNumericProperty,
865                            "data": {
866                                source: "ap",
867                                reason: "UnspecifiedReason",
868                                mlme_event_name: "DeauthenticateIndication",
869                            }
870                        }
871                    },
872                },
873                disconnect_events: {
874                    "0": {
875                        "@time": AnyNumericProperty,
876                        connected_duration: zx::BootDuration::from_seconds(30).into_nanos(),
877                        disconnect_source_id: 0u64,
878                        network_id: 0u64,
879                        rssi_dbm: -30i64,
880                        snr_db: 25i64,
881                        channel: AnyStringProperty,
882                    }
883                }
884            }
885        });
886
887        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
888        assert_eq!(
889            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
890            &[TimeMatrixCall::Fold(Timed::now(1 << 0)), TimeMatrixCall::Fold(Timed::now(1 << 1)),]
891        );
892        assert_eq!(
893            &time_matrix_calls.drain::<u64>("disconnected_networks")[..],
894            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
895        );
896        assert_eq!(
897            &time_matrix_calls.drain::<u64>("disconnect_sources")[..],
898            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
899        );
900    }
901
902    #[fuchsia::test]
903    fn test_log_disconnect_cobalt() {
904        let mut test_helper = setup_test();
905        let logger = ConnectDisconnectLogger::new(
906            test_helper.cobalt_proxy.clone(),
907            &test_helper.inspect_node,
908            &test_helper.inspect_metadata_node,
909            &test_helper.inspect_metadata_path,
910            test_helper.persistence_sender.clone(),
911            &test_helper.mock_time_matrix_client,
912        );
913
914        // Log the event
915        let disconnect_info = DisconnectInfo {
916            connected_duration: zx::BootDuration::from_millis(300_000),
917            ..fake_disconnect_info()
918        };
919        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
920        assert_eq!(
921            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
922            Poll::Ready(())
923        );
924
925        let disconnect_count_metrics =
926            test_helper.get_logged_metrics(metrics::TOTAL_DISCONNECT_COUNT_METRIC_ID);
927        assert_eq!(disconnect_count_metrics.len(), 1);
928        assert_eq!(disconnect_count_metrics[0].payload, MetricEventPayload::Count(1));
929
930        let connected_duration_metrics =
931            test_helper.get_logged_metrics(metrics::CONNECTED_DURATION_ON_DISCONNECT_METRIC_ID);
932        assert_eq!(connected_duration_metrics.len(), 1);
933        assert_eq!(
934            connected_duration_metrics[0].payload,
935            MetricEventPayload::IntegerValue(300_000)
936        );
937    }
938
939    #[test_case(
940        fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
941            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
942            reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
943        }),
944        true;
945        "ap_disconnect_source"
946    )]
947    #[test_case(
948        fidl_sme::DisconnectSource::Mlme(fidl_sme::DisconnectCause {
949            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
950            reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
951        }),
952        true;
953        "mlme_disconnect_source_not_link_failed"
954    )]
955    #[test_case(
956        fidl_sme::DisconnectSource::Mlme(fidl_sme::DisconnectCause {
957            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
958            reason_code: fidl_ieee80211::ReasonCode::MlmeLinkFailed,
959        }),
960        false;
961        "mlme_link_failed"
962    )]
963    #[test_case(
964        fidl_sme::DisconnectSource::User(fidl_sme::UserDisconnectReason::Unknown),
965        false;
966        "user_disconnect_source"
967    )]
968    #[fuchsia::test(add_test_attr = false)]
969    fn test_log_disconnect_for_mobile_device_cobalt(
970        disconnect_source: fidl_sme::DisconnectSource,
971        should_log: bool,
972    ) {
973        let mut test_helper = setup_test();
974        let logger = ConnectDisconnectLogger::new(
975            test_helper.cobalt_proxy.clone(),
976            &test_helper.inspect_node,
977            &test_helper.inspect_metadata_node,
978            &test_helper.inspect_metadata_path,
979            test_helper.persistence_sender.clone(),
980            &test_helper.mock_time_matrix_client,
981        );
982
983        // Log the event
984        let disconnect_info = DisconnectInfo { disconnect_source, ..fake_disconnect_info() };
985        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
986        assert_eq!(
987            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
988            Poll::Ready(())
989        );
990
991        let metrics = test_helper
992            .get_logged_metrics(metrics::DISCONNECT_OCCURRENCE_FOR_MOBILE_DEVICE_METRIC_ID);
993        if should_log {
994            assert_eq!(metrics.len(), 1);
995            assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
996        } else {
997            assert!(metrics.is_empty());
998        }
999    }
1000
1001    #[fuchsia::test]
1002    fn test_log_downtime_post_disconnect_on_reconnect() {
1003        let mut test_helper = setup_test();
1004        let logger = ConnectDisconnectLogger::new(
1005            test_helper.cobalt_proxy.clone(),
1006            &test_helper.inspect_node,
1007            &test_helper.inspect_metadata_node,
1008            &test_helper.inspect_metadata_path,
1009            test_helper.persistence_sender.clone(),
1010            &test_helper.mock_time_matrix_client,
1011        );
1012
1013        // Connect at 15th second
1014        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(15_000_000_000));
1015        let bss_description = random_bss_description!(Wpa2);
1016        let mut test_fut =
1017            pin!(logger
1018                .handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss_description));
1019        assert_eq!(
1020            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1021            Poll::Ready(())
1022        );
1023
1024        // Verify no downtime metric is logged on first successful connect
1025        let metrics = test_helper.get_logged_metrics(metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID);
1026        assert!(metrics.is_empty());
1027
1028        // Disconnect at 25th second
1029        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(25_000_000_000));
1030        let disconnect_info = fake_disconnect_info();
1031        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1032        assert_eq!(
1033            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1034            Poll::Ready(())
1035        );
1036
1037        // Reconnect at 60th second
1038        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(60_000_000_000));
1039        let mut test_fut =
1040            pin!(logger
1041                .handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss_description));
1042        assert_eq!(
1043            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1044            Poll::Ready(())
1045        );
1046
1047        // Verify that downtime metric is logged
1048        let metrics = test_helper.get_logged_metrics(metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID);
1049        assert_eq!(metrics.len(), 1);
1050        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(35_000));
1051    }
1052
1053    fn fake_disconnect_info() -> DisconnectInfo {
1054        let bss_description = random_bss_description!(Wpa2);
1055        let channel = bss_description.channel;
1056        DisconnectInfo {
1057            iface_id: 1,
1058            connected_duration: zx::BootDuration::from_hours(6),
1059            is_sme_reconnecting: false,
1060            disconnect_source: fidl_sme::DisconnectSource::User(
1061                fidl_sme::UserDisconnectReason::Unknown,
1062            ),
1063            original_bss_desc: bss_description.into(),
1064            current_rssi_dbm: -30,
1065            current_snr_db: 25,
1066            current_channel: channel,
1067        }
1068    }
1069}