[wlan][policy] Add telemetry for connection uptime

Keep track of:
- connected_duration
- downtime_duration
- downtime_no_saved_neighbor_duration

for the last one day and seven days, separately, and log them into
Inspect.

These counters will be used to compute the uptime ratio.

Bug: 78170
Bug: 78318
Test:

Added unit tests. Performed the following manual test.

1. Connect to network, verify that connected_duration counter goes up
   periodically.
2. Disconnect via StopClientConnection, verify that no counter goes up.
3. Reconnect.
4. Disconnect via wlan-dev API, verify that downtime_duration counter
   goes up, while downtime_no_saved_neighbor_duration doesn't.
5. Policy would reconnect automatically. Now, disconnect by turning off
   AP. Verify that both downtime_duration and
   downtime_no_saved_neighbor_duration counters go up periodically.

Change-Id: I549770c97d19994c5b8624e9421a1001a7bd11d3
Reviewed-on: https://fuchsia-review.googlesource.com/c/fuchsia/+/543627
Commit-Queue: Kiet Tran <kiettran@google.com>
Reviewed-by: Marc Khouri <mnck@google.com>
Reviewed-by: Charles Celerier <chcl@google.com>
diff --git a/src/connectivity/wlan/wlancfg/BUILD.gn b/src/connectivity/wlan/wlancfg/BUILD.gn
index 748272e..8f43ed3 100644
--- a/src/connectivity/wlan/wlancfg/BUILD.gn
+++ b/src/connectivity/wlan/wlancfg/BUILD.gn
@@ -70,6 +70,7 @@
     "//third_party/rust_crates:rand",
     "//third_party/rust_crates:serde",
     "//third_party/rust_crates:serde_json",
+    "//third_party/rust_crates:static_assertions",
     "//third_party/rust_crates:tempfile",
     "//third_party/rust_crates:test-case",
     "//third_party/rust_crates:thiserror",
diff --git a/src/connectivity/wlan/wlancfg/README.md b/src/connectivity/wlan/wlancfg/README.md
index 364a134..31599de 100644
--- a/src/connectivity/wlan/wlancfg/README.md
+++ b/src/connectivity/wlan/wlancfg/README.md
@@ -90,6 +90,18 @@
 - Performs scans via the Interface Manager.
 - Distributes scan results to the requester as well as the Network Selection Manager and Emergency Location provider.
 
+### Telemetry
+
+Implemented in: [`telemetry/mod.rs`](./src/telemetry/mod.rs)
+
+Responsibilities:
+
+- Receives TelemetryEvent on network selection and network state, and keep track of
+  stats about connection uptime and downtime.
+- Maintains stats for the last one day and seven days separately, discarding stale
+  data when enough time has passed.
+- Exposes stats via Inspect.
+
 ## Examples of data flow in common situations
 
 The situations below illustrate how the modules cooperate to handle common scenarios. Similar to above, these situations aren't intended to capture every nuance of behavior.
diff --git a/src/connectivity/wlan/wlancfg/src/client/mod.rs b/src/connectivity/wlan/wlancfg/src/client/mod.rs
index c8fcfde..71eb6cf 100644
--- a/src/connectivity/wlan/wlancfg/src/client/mod.rs
+++ b/src/connectivity/wlan/wlancfg/src/client/mod.rs
@@ -484,6 +484,7 @@
         crate::{
             access_point::state_machine as ap_fsm,
             config_management::{Credential, NetworkConfig, SecurityType, WPA_PSK_BYTE_LEN},
+            telemetry::{TelemetryEvent, TelemetrySender},
             util::testing::{create_mock_cobalt_sender, fakes::FakeSavedNetworksManager},
         },
         async_trait::async_trait,
@@ -696,6 +697,7 @@
         update_sender: mpsc::UnboundedSender<listener::ClientListenerMessage>,
         listener_updates: mpsc::UnboundedReceiver<listener::ClientListenerMessage>,
         client_provider_lock: Arc<Mutex<()>>,
+        _telemetry_receiver: mpsc::Receiver<TelemetryEvent>,
     }
 
     // setup channels and proxies needed for the tests to use use the Client Provider and
@@ -715,10 +717,12 @@
         ];
         let saved_networks =
             Arc::new(FakeSavedNetworksManager::new_with_saved_configs(presaved_default_configs));
+        let (telemetry_sender, telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
         let (provider, requests) = create_proxy::<fidl_policy::ClientProviderMarker>()
             .expect("failed to create ClientProvider proxy");
@@ -744,6 +748,7 @@
             update_sender,
             listener_updates,
             client_provider_lock: Arc::new(Mutex::new(())),
+            _telemetry_receiver: telemetry_receiver,
         }
     }
 
@@ -1265,10 +1270,12 @@
     fn save_network() {
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let saved_networks = Arc::new(FakeSavedNetworksManager::new());
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         let (provider, requests) = create_proxy::<fidl_policy::ClientProviderMarker>()
@@ -1325,10 +1332,12 @@
     fn save_network_with_disconnected_iface() {
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let saved_networks = Arc::new(FakeSavedNetworksManager::new());
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         let (provider, requests) = create_proxy::<fidl_policy::ClientProviderMarker>()
@@ -1401,10 +1410,12 @@
     fn save_network_overwrite_disconnects() {
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let saved_networks = Arc::new(FakeSavedNetworksManager::new());
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
         let (provider, requests) = create_proxy::<fidl_policy::ClientProviderMarker>()
             .expect("failed to create ClientProvider proxy");
@@ -1472,10 +1483,12 @@
         let mut saved_networks = FakeSavedNetworksManager::new();
         saved_networks.fail_all_stores = true;
         let saved_networks = Arc::new(saved_networks);
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         let (provider, requests) = create_proxy::<fidl_policy::ClientProviderMarker>()
@@ -1535,10 +1548,12 @@
     fn test_remove_a_network() {
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let saved_networks = Arc::new(FakeSavedNetworksManager::new());
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
         let (provider, requests) = create_proxy::<fidl_policy::ClientProviderMarker>()
             .expect("failed to create ClientProvider proxy");
@@ -1675,10 +1690,12 @@
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let saved_networks =
             Arc::new(FakeSavedNetworksManager::new_with_saved_configs(saved_configs));
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
         let (provider, requests) = create_proxy::<fidl_policy::ClientProviderMarker>()
             .expect("failed to create ClientProvider proxy");
@@ -2007,10 +2024,12 @@
     fn no_client_interface() {
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let saved_networks = Arc::new(FakeSavedNetworksManager::new());
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
         let iface_manager = Arc::new(Mutex::new(FakeIfaceManagerNoIfaces {}));
 
diff --git a/src/connectivity/wlan/wlancfg/src/client/network_selection.rs b/src/connectivity/wlan/wlancfg/src/client/network_selection.rs
index 5ca343c..bfa8d39 100644
--- a/src/connectivity/wlan/wlancfg/src/client/network_selection.rs
+++ b/src/connectivity/wlan/wlancfg/src/client/network_selection.rs
@@ -12,6 +12,7 @@
             self, ConnectFailure, Credential, Disconnect, FailureReason, SavedNetworksManagerApi,
         },
         mode_management::iface_manager_api::IfaceManagerApi,
+        telemetry::{self, TelemetryEvent, TelemetrySender},
     },
     async_trait::async_trait,
     fidl_fuchsia_wlan_internal as fidl_internal, fidl_fuchsia_wlan_policy as fidl_policy,
@@ -73,6 +74,7 @@
     hasher: WlanHasher,
     _inspect_node_root: Arc<Mutex<InspectNode>>,
     inspect_node_for_network_selections: Arc<Mutex<InspectBoundedListNode>>,
+    telemetry_sender: TelemetrySender,
 }
 
 struct ScanResultCache {
@@ -216,6 +218,7 @@
         saved_network_manager: Arc<dyn SavedNetworksManagerApi>,
         cobalt_api: CobaltSender,
         inspect_node: InspectNode,
+        telemetry_sender: TelemetrySender,
     ) -> Self {
         let inspect_node_for_network_selection = InspectBoundedListNode::new(
             inspect_node.create_child("network_selection"),
@@ -233,6 +236,7 @@
             inspect_node_for_network_selections: Arc::new(Mutex::new(
                 inspect_node_for_network_selection,
             )),
+            telemetry_sender,
         }
     }
 
@@ -334,6 +338,10 @@
         iface_manager: Arc<Mutex<dyn IfaceManagerApi + Send>>,
         ignore_list: &Vec<types::NetworkIdentifier>,
     ) -> Option<types::ConnectionCandidate> {
+        self.telemetry_sender.send(TelemetryEvent::StartNetworkSelection {
+            network_selection_type: telemetry::NetworkSelectionType::Undirected,
+        });
+
         self.perform_scan(iface_manager.clone()).await;
         let scan_result_guard = self.scan_result_cache.lock().await;
         let networks = merge_saved_networks_and_scan_data(
@@ -342,14 +350,23 @@
             &self.hasher,
         )
         .await;
+        // TODO(fxbug.dev/78170): When there's a scan error, this should be an `Err`, not `Ok(0)`.
+        let num_candidates = Ok(networks.len());
 
         let mut inspect_node = self.inspect_node_for_network_selections.lock().await;
-        match select_best_connection_candidate(networks, ignore_list, &mut inspect_node) {
-            Some((selected, channel, bssid)) => {
-                Some(augment_bss_with_active_scan(selected, channel, bssid, iface_manager).await)
-            }
-            None => None,
-        }
+        let result =
+            match select_best_connection_candidate(networks, ignore_list, &mut inspect_node) {
+                Some((selected, channel, bssid)) => Some(
+                    augment_bss_with_active_scan(selected, channel, bssid, iface_manager).await,
+                ),
+                None => None,
+            };
+
+        self.telemetry_sender.send(TelemetryEvent::NetworkSelectionDecision {
+            num_candidates,
+            selected_any: result.is_some(),
+        });
+        result
     }
 
     /// Find a suitable BSS for the given network.
@@ -358,12 +375,16 @@
         sme_proxy: fidl_sme::ClientSmeProxy,
         network: types::NetworkIdentifier,
     ) -> Option<types::ConnectionCandidate> {
+        self.telemetry_sender.send(TelemetryEvent::StartNetworkSelection {
+            network_selection_type: telemetry::NetworkSelectionType::Directed,
+        });
+
         // TODO: check if we have recent enough scan results that we can pull from instead?
         let scan_results =
             scan::perform_directed_active_scan(&sme_proxy, &network.ssid, None).await;
 
-        match scan_results {
-            Err(_) => None,
+        let (result, num_candidates) = match scan_results {
+            Err(_) => (None, Err(())),
             Ok(scan_results) => {
                 let networks = merge_saved_networks_and_scan_data(
                     &self.saved_network_manager,
@@ -371,18 +392,29 @@
                     &self.hasher,
                 )
                 .await;
+                let num_candidates = Ok(networks.len());
                 let ignore_list = vec![];
                 let mut inspect_node = self.inspect_node_for_network_selections.lock().await;
-                select_best_connection_candidate(networks, &ignore_list, &mut inspect_node).map(
-                    |(candidate, _, _)| {
-                        // Strip out the information about passive vs active scan, because we can't know
-                        // if this network would have been observed in a passive scan (since we never
-                        // performed a passive scan).
-                        types::ConnectionCandidate { observed_in_passive_scan: None, ..candidate }
-                    },
-                )
+                let result =
+                    select_best_connection_candidate(networks, &ignore_list, &mut inspect_node)
+                        .map(|(candidate, _, _)| {
+                            // Strip out the information about passive vs active scan, because we can't know
+                            // if this network would have been observed in a passive scan (since we never
+                            // performed a passive scan).
+                            types::ConnectionCandidate {
+                                observed_in_passive_scan: None,
+                                ..candidate
+                            }
+                        });
+                (result, num_candidates)
             }
-        }
+        };
+
+        self.telemetry_sender.send(TelemetryEvent::NetworkSelectionDecision {
+            num_candidates,
+            selected_any: result.is_some(),
+        });
+        result
     }
 }
 
@@ -679,6 +711,7 @@
         iface_manager: Arc<Mutex<FakeIfaceManager>>,
         sme_stream: fidl_sme::ClientSmeRequestStream,
         inspector: inspect::Inspector,
+        telemetry_receiver: mpsc::Receiver<TelemetryEvent>,
     }
 
     async fn test_setup() -> TestValues {
@@ -687,9 +720,14 @@
         let saved_network_manager = Arc::new(SavedNetworksManager::new_for_test().await.unwrap());
         let inspector = inspect::Inspector::new();
         let inspect_node = inspector.root().create_child("net_select_test");
+        let (telemetry_sender, telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
 
-        let network_selector =
-            Arc::new(NetworkSelector::new(saved_network_manager.clone(), cobalt_api, inspect_node));
+        let network_selector = Arc::new(NetworkSelector::new(
+            saved_network_manager.clone(),
+            cobalt_api,
+            inspect_node,
+            TelemetrySender::new(telemetry_sender),
+        ));
         let (client_sme, remote) =
             create_proxy::<fidl_sme::ClientSmeMarker>().expect("error creating proxy");
         let iface_manager = Arc::new(Mutex::new(FakeIfaceManager::new(client_sme)));
@@ -701,6 +739,7 @@
             iface_manager,
             sme_stream: remote.into_stream().expect("failed to create stream"),
             inspector,
+            telemetry_receiver,
         }
     }
 
@@ -1988,6 +2027,7 @@
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let mut test_values = exec.run_singlethreaded(test_setup());
         let network_selector = test_values.network_selector;
+        let mut telemetry_receiver = test_values.telemetry_receiver;
 
         // create some identifiers
         let test_id_1 = types::NetworkIdentifier {
@@ -2046,6 +2086,13 @@
         pin_mut!(network_selection_fut);
         assert_variant!(exec.run_until_stalled(&mut network_selection_fut), Poll::Pending);
 
+        // Verify that StartNetworkSelection telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::StartNetworkSelection {
+                network_selection_type: telemetry::NetworkSelectionType::Undirected,
+            });
+        });
+
         // Check that a scan request was sent to the sme and send back results
         let expected_scan_request = fidl_sme::ScanRequest::Passive(fidl_sme::PassiveScanRequest {});
         let mock_scan_results = vec![
@@ -2236,6 +2283,14 @@
                 }
             },
         });
+
+        // Verify that NetworkSelectionDecision telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::NetworkSelectionDecision {
+                num_candidates: Ok(2),
+                selected_any: true,
+            });
+        });
     }
 
     #[fuchsia::test]
@@ -2342,6 +2397,7 @@
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let mut test_values = exec.run_singlethreaded(test_setup());
         let network_selector = test_values.network_selector;
+        let mut telemetry_receiver = test_values.telemetry_receiver;
 
         // create identifiers
         let test_id_1 = types::NetworkIdentifier {
@@ -2373,6 +2429,13 @@
         pin_mut!(network_selection_fut);
         assert_variant!(exec.run_until_stalled(&mut network_selection_fut), Poll::Pending);
 
+        // Verify that StartNetworkSelection telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::StartNetworkSelection {
+                network_selection_type: telemetry::NetworkSelectionType::Directed,
+            });
+        });
+
         // Check that a scan request was sent to the sme and send back results
         let expected_scan_request = fidl_sme::ScanRequest::Active(fidl_sme::ActiveScanRequest {
             ssids: vec![test_id_1.ssid.clone()],
@@ -2430,6 +2493,14 @@
                 multiple_bss_candidates: Some(false),
             })
         );
+
+        // Verify that NetworkSelectionDecision telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::NetworkSelectionDecision {
+                num_candidates: Ok(1),
+                selected_any: true,
+            });
+        });
     }
 
     #[fuchsia::test]
@@ -2437,6 +2508,7 @@
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let mut test_values = exec.run_singlethreaded(test_setup());
         let network_selector = test_values.network_selector;
+        let mut telemetry_receiver = test_values.telemetry_receiver;
 
         // create identifiers
         let test_id_1 = types::NetworkIdentifier {
@@ -2456,6 +2528,13 @@
         pin_mut!(network_selection_fut);
         assert_variant!(exec.run_until_stalled(&mut network_selection_fut), Poll::Pending);
 
+        // Verify that StartNetworkSelection telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::StartNetworkSelection {
+                network_selection_type: telemetry::NetworkSelectionType::Directed,
+            });
+        });
+
         // Return an error on the scan
         assert_variant!(
             exec.run_until_stalled(&mut test_values.sme_stream.next()),
@@ -2475,6 +2554,14 @@
         // Check that nothing is returned
         let results = exec.run_singlethreaded(&mut network_selection_fut);
         assert_eq!(results, None);
+
+        // Verify that NetworkSelectionDecision telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::NetworkSelectionDecision {
+                num_candidates: Err(()),
+                selected_any: false,
+            });
+        });
     }
 
     fn generate_random_bss() -> types::Bss {
diff --git a/src/connectivity/wlan/wlancfg/src/client/state_machine.rs b/src/connectivity/wlan/wlancfg/src/client/state_machine.rs
index f3b5f49..1b14274 100644
--- a/src/connectivity/wlan/wlancfg/src/client/state_machine.rs
+++ b/src/connectivity/wlan/wlancfg/src/client/state_machine.rs
@@ -6,6 +6,7 @@
     crate::{
         client::{network_selection, sme_credential_from_policy, types},
         config_management::SavedNetworksManagerApi,
+        telemetry::{TelemetryEvent, TelemetrySender},
         util::{
             listener::{
                 ClientListenerMessageSender, ClientNetworkState, ClientStateUpdate,
@@ -121,6 +122,7 @@
     connect_request: Option<(types::ConnectRequest, oneshot::Sender<()>)>,
     network_selector: Arc<network_selection::NetworkSelector>,
     cobalt_api: CobaltSender,
+    telemetry_sender: TelemetrySender,
 ) {
     let next_network = match connect_request {
         Some((req, sender)) => Some(ConnectingOptions {
@@ -143,6 +145,7 @@
         saved_networks_manager: saved_networks_manager,
         network_selector,
         cobalt_api,
+        telemetry_sender,
     };
     let state_machine =
         disconnecting_state(common_options, disconnect_options).into_state_machine();
@@ -171,6 +174,7 @@
     saved_networks_manager: Arc<dyn SavedNetworksManagerApi>,
     network_selector: Arc<network_selection::NetworkSelector>,
     cobalt_api: CobaltSender,
+    telemetry_sender: TelemetrySender,
 }
 
 fn handle_none_request() -> Result<State, ExitReason> {
@@ -490,6 +494,7 @@
                                     status: None
                                 },
                             );
+                            common_options.telemetry_sender.send(TelemetryEvent::Connected);
                             return Ok(
                                 connected_state(common_options, ConnectedOptions{ currently_fulfilled_request: options.connect_request, bssid: *bssid }).into_state()
                             );
@@ -665,6 +670,7 @@
                                 reason: types::DisconnectReason::DisconnectDetectedFromSme
                             };
                             info!("Detected disconnection from network, will attempt reconnection");
+                            common_options.telemetry_sender.send(TelemetryEvent::Disconnected { track_subsequent_downtime: true });
                             return Ok(disconnecting_state(common_options, options).into_state());
                         }
                     }
@@ -685,6 +691,7 @@
                             next_network: None,
                             reason
                         };
+                        common_options.telemetry_sender.send(TelemetryEvent::Disconnected { track_subsequent_downtime: false });
                         return Ok(disconnecting_state(common_options, options).into_state());
                     }
                     Some(ManualRequest::Connect((new_connect_request, new_responder))) => {
@@ -712,6 +719,7 @@
                                 }
                             };
                             info!("Connection to new network requested, disconnecting from current network");
+                            common_options.telemetry_sender.send(TelemetryEvent::Disconnected { track_subsequent_downtime: false });
                             return Ok(disconnecting_state(common_options, options).into_state())
                         }
                     }
@@ -731,6 +739,7 @@
                 network_config::{self, Credential, FailureReason},
                 SavedNetworksManager,
             },
+            telemetry::{TelemetryEvent, TelemetrySender},
             util::{
                 listener,
                 testing::{
@@ -762,6 +771,7 @@
         client_req_sender: mpsc::Sender<ManualRequest>,
         update_receiver: mpsc::UnboundedReceiver<listener::ClientListenerMessage>,
         cobalt_events: mpsc::Receiver<CobaltEvent>,
+        telemetry_receiver: mpsc::Receiver<TelemetryEvent>,
     }
 
     async fn test_setup() -> TestValues {
@@ -776,10 +786,13 @@
                 .expect("Failed to create saved networks manager"),
         );
         let (cobalt_api, cobalt_events) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            telemetry_sender.clone(),
         ));
 
         TestValues {
@@ -790,11 +803,13 @@
                 saved_networks_manager: saved_networks_manager,
                 network_selector,
                 cobalt_api,
+                telemetry_sender,
             },
             sme_req_stream,
             client_req_sender,
             update_receiver,
             cobalt_events,
+            telemetry_receiver,
         }
     }
 
@@ -844,10 +859,13 @@
             exec.run_singlethreaded(SavedNetworksManager::new_and_stash_server(path, tmp_path));
         let saved_networks_manager = Arc::new(saved_networks);
         let (cobalt_api, mut cobalt_events) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            telemetry_sender.clone(),
         ));
         let next_network_ssid = "bar";
         let bss_desc = generate_random_bss_desc();
@@ -895,6 +913,7 @@
             saved_networks_manager: saved_networks_manager.clone(),
             network_selector,
             cobalt_api: cobalt_api,
+            telemetry_sender,
         };
         let initial_state = connecting_state(common_options, connecting_options);
         let fut = run_state_machine(initial_state);
@@ -1008,10 +1027,13 @@
             exec.run_singlethreaded(SavedNetworksManager::new_and_stash_server(path, tmp_path));
         let saved_networks_manager = Arc::new(saved_networks);
         let (cobalt_api, mut cobalt_events) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, mut telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            telemetry_sender.clone(),
         ));
         let next_network_ssid = "bar";
         let bss_desc = generate_random_bss_desc();
@@ -1059,6 +1081,7 @@
             saved_networks_manager: saved_networks_manager.clone(),
             network_selector,
             cobalt_api: cobalt_api,
+            telemetry_sender,
         };
         let initial_state = connecting_state(common_options, connecting_options);
         let fut = run_state_machine(initial_state);
@@ -1067,6 +1090,11 @@
         // Run the state machine
         assert_variant!(exec.run_until_stalled(&mut fut), Poll::Pending);
 
+        // Check that StartNetworkSelection telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_variant!(event, TelemetryEvent::StartNetworkSelection { .. });
+        });
+
         // Ensure a scan request is sent to the SME and send back a result
         let expected_scan_request = fidl_sme::ScanRequest::Active(fidl_sme::ActiveScanRequest {
             ssids: vec![next_network_ssid.as_bytes().to_vec()],
@@ -1127,6 +1155,11 @@
             assert_eq!(updates, client_state_update);
         });
 
+        // Check that NetworkSelectionDecision telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_variant!(event, TelemetryEvent::NetworkSelectionDecision { .. });
+        });
+
         // Progress the state machine
         assert_variant!(exec.run_until_stalled(&mut fut), Poll::Pending);
         process_stash_write(&mut exec, &mut stash_server);
@@ -1160,6 +1193,9 @@
         assert_eq!(true, saved_networks[0].has_ever_connected);
         assert_eq!(network_config::PROB_HIDDEN_DEFAULT, saved_networks[0].hidden_probability);
 
+        // Check that connected telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(TelemetryEvent::Connected)));
+
         // Progress the state machine
         assert_variant!(exec.run_until_stalled(&mut fut), Poll::Pending);
 
@@ -1193,10 +1229,13 @@
             exec.run_singlethreaded(SavedNetworksManager::new_and_stash_server(path, tmp_path));
         let saved_networks_manager = Arc::new(saved_networks);
         let (cobalt_api, _cobalt_events) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            telemetry_sender.clone(),
         ));
         let next_network_ssid = "bar";
         let bss_desc = generate_random_bss_desc();
@@ -1244,6 +1283,7 @@
             saved_networks_manager: saved_networks_manager.clone(),
             network_selector,
             cobalt_api: cobalt_api,
+            telemetry_sender,
         };
         let initial_state = connecting_state(common_options, connecting_options);
         let fut = run_state_machine(initial_state);
@@ -1454,10 +1494,13 @@
             exec.run_singlethreaded(SavedNetworksManager::new_and_stash_server(path, tmp_path));
         let saved_networks_manager = Arc::new(saved_networks);
         let (cobalt_api, mut cobalt_events) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            telemetry_sender.clone(),
         ));
         let next_network_ssid = "bar";
         let bss_desc = generate_random_bss_desc();
@@ -1498,6 +1541,7 @@
             saved_networks_manager: saved_networks_manager.clone(),
             network_selector,
             cobalt_api: cobalt_api,
+            telemetry_sender,
         };
         let initial_state = connecting_state(common_options, connecting_options);
         let fut = run_state_machine(initial_state);
@@ -1653,10 +1697,13 @@
         );
         let (_client_req_sender, client_req_stream) = mpsc::channel(1);
         let (cobalt_api, mut cobalt_events) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            telemetry_sender.clone(),
         ));
         let common_options = CommonStateOptions {
             proxy: sme_proxy,
@@ -1665,6 +1712,7 @@
             saved_networks_manager: saved_networks_manager.clone(),
             network_selector,
             cobalt_api: cobalt_api,
+            telemetry_sender,
         };
 
         let next_network_ssid = "bar";
@@ -1792,10 +1840,13 @@
         );
         let (_client_req_sender, client_req_stream) = mpsc::channel(1);
         let (cobalt_api, mut cobalt_events) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            telemetry_sender.clone(),
         ));
 
         let common_options = CommonStateOptions {
@@ -1805,6 +1856,7 @@
             saved_networks_manager: saved_networks_manager.clone(),
             network_selector,
             cobalt_api: cobalt_api,
+            telemetry_sender,
         };
 
         let next_network_ssid = "bar";
@@ -2407,6 +2459,7 @@
     fn connected_state_gets_disconnect_request() {
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let mut test_values = exec.run_singlethreaded(test_setup());
+        let mut telemetry_receiver = test_values.telemetry_receiver;
 
         let network_ssid = "test";
         let bss_desc = generate_random_bss_desc();
@@ -2506,6 +2559,11 @@
             DISCONNECTION_METRIC_ID,
             types::DisconnectReason::FidlStopClientConnectionsRequest
         );
+
+        // Disconnect telemetry event sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::Disconnected { track_subsequent_downtime: false });
+        });
     }
 
     #[fuchsia::test]
@@ -2519,12 +2577,15 @@
             exec.run_singlethreaded(SavedNetworksManager::new_and_stash_server(path, tmp_path));
         let saved_networks_manager = Arc::new(saved_networks);
         test_values.common_options.saved_networks_manager = saved_networks_manager.clone();
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
         test_values.common_options.network_selector = network_selector;
+        let mut telemetry_receiver = test_values.telemetry_receiver;
 
         let network_ssid = "flaky-network".as_bytes().to_vec();
         let security = types::SecurityType::Wpa2;
@@ -2592,6 +2653,11 @@
         assert_variant!(disconnects.as_slice(), [disconnect] => {
             assert_eq!(disconnect.bssid, bss_desc.bssid);
         });
+
+        // Disconnect telemetry event sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::Disconnected { track_subsequent_downtime: true });
+        });
     }
 
     #[fuchsia::test]
@@ -2605,10 +2671,12 @@
             exec.run_singlethreaded(SavedNetworksManager::new_and_stash_server(path, tmp_path));
         let saved_networks_manager = Arc::new(saved_networks);
         test_values.common_options.saved_networks_manager = saved_networks_manager.clone();
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
         test_values.common_options.network_selector = network_selector;
 
@@ -2733,6 +2801,7 @@
     fn connected_state_gets_duplicate_connect_request() {
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let mut test_values = exec.run_singlethreaded(test_setup());
+        let mut telemetry_receiver = test_values.telemetry_receiver;
 
         let network_ssid = "test";
         let bss_desc = generate_random_bss_desc();
@@ -2802,12 +2871,16 @@
 
         // No cobalt metrics logged
         validate_no_cobalt_events!(test_values.cobalt_events);
+
+        // No telemetry event is sent
+        assert_variant!(telemetry_receiver.try_next(), Err(_));
     }
 
     #[fuchsia::test]
     fn connected_state_gets_different_connect_request() {
         let mut exec = fasync::TestExecutor::new().expect("failed to create an executor");
         let mut test_values = exec.run_singlethreaded(test_setup());
+        let mut telemetry_receiver = test_values.telemetry_receiver;
 
         let first_network_ssid = "foo";
         let second_network_ssid = "bar";
@@ -2929,6 +3002,11 @@
             assert_eq!(updates, client_state_update);
         });
 
+        // Disconnect telemetry event sent
+        assert_variant!(telemetry_receiver.try_next(), Ok(Some(event)) => {
+            assert_eq!(event, TelemetryEvent::Disconnected { track_subsequent_downtime: false });
+        });
+
         // Check the responder was acknowledged
         assert_variant!(exec.run_until_stalled(&mut connect_receiver2), Poll::Ready(Ok(())));
 
@@ -3005,10 +3083,13 @@
             exec.run_singlethreaded(SavedNetworksManager::new_and_stash_server(path, tmp_path));
         let saved_networks_manager = Arc::new(saved_networks);
         let (cobalt_api, mut cobalt_events) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(network_selection::NetworkSelector::new(
             saved_networks_manager.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            telemetry_sender.clone(),
         ));
         let network_ssid = "foo";
         let bss_desc = generate_random_bss_desc();
@@ -3043,6 +3124,7 @@
             saved_networks_manager: saved_networks_manager.clone(),
             network_selector,
             cobalt_api: cobalt_api,
+            telemetry_sender,
         };
         let options = ConnectedOptions {
             currently_fulfilled_request: connect_request.clone(),
@@ -3493,6 +3575,7 @@
             Some((connect_req, sender)),
             test_values.common_options.network_selector,
             test_values.common_options.cobalt_api,
+            test_values.common_options.telemetry_sender,
         );
         pin_mut!(fut);
 
@@ -3560,6 +3643,7 @@
             Some((connect_req, sender)),
             test_values.common_options.network_selector,
             test_values.common_options.cobalt_api,
+            test_values.common_options.telemetry_sender,
         );
         pin_mut!(fut);
 
@@ -3618,6 +3702,7 @@
             Some((connect_req, sender)),
             test_values.common_options.network_selector,
             test_values.common_options.cobalt_api,
+            test_values.common_options.telemetry_sender,
         );
         pin_mut!(fut);
 
@@ -3744,6 +3829,7 @@
             Some((connect_req, sender)),
             test_values.common_options.network_selector,
             test_values.common_options.cobalt_api,
+            test_values.common_options.telemetry_sender,
         );
         pin_mut!(fut);
 
diff --git a/src/connectivity/wlan/wlancfg/src/main.rs b/src/connectivity/wlan/wlancfg/src/main.rs
index bc1bf75..515b47e 100644
--- a/src/connectivity/wlan/wlancfg/src/main.rs
+++ b/src/connectivity/wlan/wlancfg/src/main.rs
@@ -22,6 +22,7 @@
             create_iface_manager, iface_manager_api::IfaceManagerApi, phy_manager::PhyManager,
         },
         regulatory_manager::RegulatoryManager,
+        telemetry::serve_telemetry,
     },
     anyhow::{format_err, Context as _, Error},
     fidl::{endpoints::RequestStream, handle::AsyncChannel},
@@ -251,11 +252,15 @@
     let (cobalt_api, cobalt_fut) =
         CobaltConnector::default().serve(ConnectionType::project_id(metrics::PROJECT_ID));
 
+    let (telemetry_sender, telemetry_fut) =
+        serve_telemetry(component::inspector().root().create_child("client_stats"));
+
     let saved_networks = Arc::new(SavedNetworksManager::new(cobalt_api.clone()).await?);
     let network_selector = Arc::new(NetworkSelector::new(
         saved_networks.clone(),
         cobalt_api.clone(),
         component::inspector().root().create_child("network_selector"),
+        telemetry_sender.clone(),
     ));
 
     let phy_manager = Arc::new(Mutex::new(PhyManager::new(
@@ -279,6 +284,7 @@
         saved_networks.clone(),
         network_selector.clone(),
         cobalt_api.clone(),
+        telemetry_sender.clone(),
     );
 
     let legacy_client = IfaceRef::new();
@@ -326,6 +332,7 @@
         metrics_fut,
         regulatory_fut,
         lifecycle_fut,
+        telemetry_fut.map(Ok),
     )?;
     Ok(())
 }
diff --git a/src/connectivity/wlan/wlancfg/src/mode_management/iface_manager.rs b/src/connectivity/wlan/wlancfg/src/mode_management/iface_manager.rs
index 349d978..eb0914c 100644
--- a/src/connectivity/wlan/wlancfg/src/mode_management/iface_manager.rs
+++ b/src/connectivity/wlan/wlancfg/src/mode_management/iface_manager.rs
@@ -14,6 +14,7 @@
             iface_manager_types::*,
             phy_manager::{CreateClientIfacesReason, PhyManagerApi},
         },
+        telemetry::TelemetrySender,
         util::{future_with_metadata, listener},
     },
     anyhow::{format_err, Error},
@@ -74,6 +75,7 @@
     network_selector: Arc<NetworkSelector>,
     connect_req: Option<(client_types::ConnectRequest, oneshot::Sender<()>)>,
     cobalt_api: CobaltSender,
+    telemetry_sender: TelemetrySender,
 ) -> Result<
     (
         Box<dyn client_fsm::ClientApi + Send>,
@@ -103,6 +105,7 @@
         connect_req,
         network_selector,
         cobalt_api,
+        telemetry_sender,
     );
 
     let metadata =
@@ -127,6 +130,7 @@
         FuturesUnordered<future_with_metadata::FutureWithMetadata<(), StateMachineMetadata>>,
     cobalt_api: CobaltSender,
     clients_enabled_time: Option<zx::Time>,
+    telemetry_sender: TelemetrySender,
 }
 
 impl IfaceManagerService {
@@ -138,6 +142,7 @@
         saved_networks: Arc<dyn SavedNetworksManagerApi>,
         network_selector: Arc<NetworkSelector>,
         cobalt_api: CobaltSender,
+        telemetry_sender: TelemetrySender,
     ) -> Self {
         IfaceManagerService {
             phy_manager: phy_manager.clone(),
@@ -151,6 +156,7 @@
             fsm_futures: FuturesUnordered::new(),
             cobalt_api,
             clients_enabled_time: None,
+            telemetry_sender,
         }
     }
 
@@ -424,6 +430,7 @@
                     self.network_selector.clone(),
                     Some((connect_req, sender)),
                     self.cobalt_api.clone(),
+                    self.telemetry_sender.clone(),
                 )
                 .await?;
                 client_iface.client_state_machine = Some(new_client);
@@ -510,6 +517,7 @@
                     self.network_selector.clone(),
                     Some((connect_req.clone(), sender)),
                     self.cobalt_api.clone(),
+                    self.telemetry_sender.clone(),
                 )
                 .await?;
 
@@ -553,6 +561,7 @@
                     self.network_selector.clone(),
                     None,
                     self.cobalt_api.clone(),
+                    self.telemetry_sender.clone(),
                 )
                 .await?;
 
@@ -1209,6 +1218,7 @@
             },
             mode_management::phy_manager::{self, PhyManagerError},
             regulatory_manager::REGION_CODE_LEN,
+            telemetry::{TelemetryEvent, TelemetrySender},
             util::testing::{
                 create_mock_cobalt_sender, create_mock_cobalt_sender_and_receiver,
                 generate_random_bss_desc, poll_sme_req,
@@ -1292,6 +1302,8 @@
         pub node: inspect::Node,
         pub cobalt_api: CobaltSender,
         pub cobalt_receiver: mpsc::Receiver<CobaltEvent>,
+        pub telemetry_sender: TelemetrySender,
+        pub telemetry_receiver: mpsc::Receiver<TelemetryEvent>,
     }
 
     fn rand_string() -> String {
@@ -1322,10 +1334,13 @@
         let inspector = inspect::Inspector::new();
         let node = inspector.root().create_child("phy_manager");
         let (cobalt_api, cobalt_receiver) = create_mock_cobalt_sender_and_receiver();
+        let (telemetry_sender, telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
+        let telemetry_sender = TelemetrySender::new(telemetry_sender);
         let network_selector = Arc::new(NetworkSelector::new(
             saved_networks.clone(),
             cobalt_api.clone(),
             inspector.root().create_child("network_selection"),
+            telemetry_sender.clone(),
         ));
 
         TestValues {
@@ -1342,6 +1357,8 @@
             network_selector,
             cobalt_api,
             cobalt_receiver,
+            telemetry_sender,
+            telemetry_receiver,
         }
     }
 
@@ -1573,6 +1590,7 @@
             test_values.saved_networks.clone(),
             test_values.network_selector.clone(),
             test_values.cobalt_api.clone(),
+            test_values.telemetry_sender.clone(),
         );
 
         if configured {
@@ -1627,6 +1645,7 @@
             test_values.saved_networks.clone(),
             test_values.network_selector.clone(),
             test_values.cobalt_api.clone(),
+            test_values.telemetry_sender.clone(),
         );
 
         iface_manager.aps.push(ap_container);
@@ -1702,6 +1721,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
         let scan_fut = iface_manager.scan(fidl_fuchsia_wlan_sme::ScanRequest::Passive(
             fidl_fuchsia_wlan_sme::PassiveScanRequest {},
@@ -2192,6 +2212,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Call connect on the IfaceManager
@@ -2326,6 +2347,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Call disconnect on the IfaceManager
@@ -2474,6 +2496,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
         iface_manager.clients_enabled_time = Some(zx::Time::get_monotonic());
 
@@ -2606,6 +2629,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Call stop_client_connections.
@@ -2737,6 +2761,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         {
@@ -2845,6 +2870,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Call start_ap.
@@ -2973,6 +2999,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
         let fut =
             iface_manager.stop_ap(TEST_SSID.as_bytes().to_vec(), TEST_PASSWORD.as_bytes().to_vec());
@@ -3103,6 +3130,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         let fut = iface_manager.stop_all_aps();
@@ -3385,6 +3413,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         {
@@ -3488,6 +3517,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         {
@@ -3558,6 +3588,7 @@
             test_values.saved_networks,
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         {
@@ -3894,10 +3925,12 @@
 
         // Create other components to run the service.
         let iface_manager_client = Arc::new(Mutex::new(FakeIfaceManagerRequester::new()));
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(NetworkSelector::new(
             test_values.saved_networks,
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         // Create mpsc channel to handle requests.
@@ -3945,10 +3978,12 @@
 
         // Create other components to run the service.
         let iface_manager_client = Arc::new(Mutex::new(FakeIfaceManagerRequester::new()));
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(NetworkSelector::new(
             test_values.saved_networks,
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         // Create mpsc channel to handle requests.
@@ -3985,10 +4020,12 @@
 
         // Create other components to run the service.
         let iface_manager_client = Arc::new(Mutex::new(FakeIfaceManagerRequester::new()));
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(NetworkSelector::new(
             test_values.saved_networks,
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         // Create mpsc channel to handle requests.
@@ -4129,14 +4166,17 @@
             test_values.saved_networks.clone(),
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Create other components to run the service.
         let iface_manager_client = Arc::new(Mutex::new(FakeIfaceManagerRequester::new()));
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let network_selector = Arc::new(NetworkSelector::new(
             test_values.saved_networks,
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         // Create mpsc channel to handle requests.
@@ -4252,6 +4292,7 @@
             test_values.saved_networks.clone(),
             test_values.network_selector.clone(),
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Report a new interface.
@@ -4325,6 +4366,7 @@
                     test_values.saved_networks.clone(),
                     test_values.network_selector.clone(),
                     test_values.cobalt_api,
+                    test_values.telemetry_sender,
                 );
                 (iface_manager, None)
             }
@@ -4403,6 +4445,7 @@
             test_values.saved_networks.clone(),
             test_values.network_selector.clone(),
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Make start client connections request
@@ -4473,6 +4516,7 @@
             test_values.saved_networks.clone(),
             test_values.network_selector.clone(),
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Make stop client connections request
@@ -4695,6 +4739,7 @@
             test_values.saved_networks.clone(),
             test_values.network_selector,
             test_values.cobalt_api,
+            test_values.telemetry_sender,
         );
 
         // Update the saved networks with knowledge of the test SSID and credentials.
@@ -4822,10 +4867,12 @@
         >::new();
 
         // Create a network selector to be used by the network selection request.
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let selector = Arc::new(NetworkSelector::new(
             test_values.saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         // Setup the test to prevent a network selection from happening for whatever reason was specified.
@@ -5073,10 +5120,12 @@
         }
 
         // Create a network selector.
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let selector = Arc::new(NetworkSelector::new(
             test_values.saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         // Create an interface manager with an unconfigured client interface.
@@ -5121,10 +5170,12 @@
     fn test_terminated_ap() {
         let mut exec = fuchsia_async::TestExecutor::new().expect("failed to create an executor");
         let test_values = test_setup(&mut exec);
+        let (telemetry_sender, _telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
         let selector = Arc::new(NetworkSelector::new(
             test_values.saved_networks.clone(),
             create_mock_cobalt_sender(),
             inspect::Inspector::new().root().create_child("network_selector"),
+            TelemetrySender::new(telemetry_sender),
         ));
 
         // Create an interface manager with an unconfigured client interface.
@@ -5270,6 +5321,7 @@
             test_values.saved_networks.clone(),
             test_values.network_selector.clone(),
             test_values.cobalt_api.clone(),
+            test_values.telemetry_sender.clone(),
         );
 
         // If the test calls for it, create an AP interface to test that the IfaceManager preserves
diff --git a/src/connectivity/wlan/wlancfg/src/mode_management/mod.rs b/src/connectivity/wlan/wlancfg/src/mode_management/mod.rs
index 5bd5565..a070a1e 100644
--- a/src/connectivity/wlan/wlancfg/src/mode_management/mod.rs
+++ b/src/connectivity/wlan/wlancfg/src/mode_management/mod.rs
@@ -5,7 +5,7 @@
 use {
     crate::{
         client::network_selection::NetworkSelector, config_management::SavedNetworksManagerApi,
-        util::listener,
+        telemetry::TelemetrySender, util::listener,
     },
     anyhow::Error,
     fuchsia_cobalt::CobaltSender,
@@ -27,6 +27,7 @@
     saved_networks: Arc<dyn SavedNetworksManagerApi>,
     network_selector: Arc<NetworkSelector>,
     cobalt_api: CobaltSender,
+    telemetry_sender: TelemetrySender,
 ) -> (Arc<Mutex<iface_manager_api::IfaceManager>>, impl Future<Output = Result<Void, Error>>) {
     let (sender, receiver) = mpsc::channel(0);
     let iface_manager_sender = Arc::new(Mutex::new(iface_manager_api::IfaceManager { sender }));
@@ -38,6 +39,7 @@
         saved_networks,
         network_selector.clone(),
         cobalt_api,
+        telemetry_sender,
     );
     let iface_manager_service = iface_manager::serve_iface_manager_requests(
         iface_manager,
diff --git a/src/connectivity/wlan/wlancfg/src/telemetry/mod.rs b/src/connectivity/wlan/wlancfg/src/telemetry/mod.rs
index d1def3d..b033180 100644
--- a/src/connectivity/wlan/wlancfg/src/telemetry/mod.rs
+++ b/src/connectivity/wlan/wlancfg/src/telemetry/mod.rs
@@ -3,3 +3,793 @@
 // found in the LICENSE file.
 
 mod windowed_stats;
+
+use {
+    crate::telemetry::windowed_stats::WindowedStats,
+    fuchsia_async as fasync,
+    fuchsia_inspect::{Inspector, Node as InspectNode},
+    fuchsia_inspect_contrib::inspect_insert,
+    fuchsia_zircon as zx,
+    futures::{channel::mpsc, select, Future, FutureExt, StreamExt},
+    log::{info, warn},
+    num_traits::SaturatingAdd,
+    parking_lot::Mutex,
+    static_assertions::const_assert_eq,
+    std::{
+        ops::Add,
+        sync::{
+            atomic::{AtomicBool, Ordering},
+            Arc,
+        },
+    },
+};
+
+#[derive(Clone, Debug)]
+pub struct TelemetrySender {
+    sender: Arc<Mutex<mpsc::Sender<TelemetryEvent>>>,
+    sender_is_blocked: Arc<AtomicBool>,
+}
+
+impl TelemetrySender {
+    pub fn new(sender: mpsc::Sender<TelemetryEvent>) -> Self {
+        Self {
+            sender: Arc::new(Mutex::new(sender)),
+            sender_is_blocked: Arc::new(AtomicBool::new(false)),
+        }
+    }
+
+    // Send telemetry event. Log an error if it fails
+    pub fn send(&self, event: TelemetryEvent) {
+        match self.sender.lock().try_send(event) {
+            Ok(_) => {
+                // If sender has been blocked before, set bool to false and log message
+                if let Ok(_) = self.sender_is_blocked.compare_exchange(
+                    true,
+                    false,
+                    Ordering::SeqCst,
+                    Ordering::SeqCst,
+                ) {
+                    info!("TelemetrySender recovered and resumed sending");
+                }
+            }
+            Err(_) => {
+                // If sender has not been blocked before, set bool to true and log error message
+                if let Ok(_) = self.sender_is_blocked.compare_exchange(
+                    false,
+                    true,
+                    Ordering::SeqCst,
+                    Ordering::SeqCst,
+                ) {
+                    warn!("TelemetrySender dropped a msg: either buffer is full or no receiver is waiting");
+                }
+            }
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum TelemetryEvent {
+    /// Notify the telemetry event loop that network selection has started.
+    StartNetworkSelection {
+        /// Type of network selection. This field is currently unused.
+        network_selection_type: NetworkSelectionType,
+    },
+    /// Notify the telemetry event loop that network selection is complete.
+    NetworkSelectionDecision {
+        /// When there's a scan error, `num_candidates` should be Err.
+        /// When `num_candidates` is `Ok(0)` and the telemetry event loop is tracking downtime,
+        /// the event loop will use the period of network selection to increment the
+        /// `downtime_no_saved_neighbor_duration` counter. This would later be used to
+        /// adjust the raw downtime.
+        num_candidates: Result<usize, ()>,
+        /// Whether a network has been selected. This field is currently unused.
+        selected_any: bool,
+    },
+    /// Notify the telemetry event loop that the client has connected.
+    /// Subsequently, the telemetry event loop will increment the `connected_duration` counter
+    /// periodically.
+    Connected,
+    /// Notify the telemetry event loop that the client has disconnected.
+    /// Subsequently, the telemetry event loop will increment the downtime counters periodically
+    /// if TelemetrySender has requested downtime to be tracked via `track_subsequent_downtime`
+    /// flag.
+    Disconnected {
+        /// Indicates whether subsequent period should be used to increment the downtime counters.
+        track_subsequent_downtime: bool,
+    },
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum NetworkSelectionType {
+    /// Looking for the best BSS from any saved networks
+    Undirected,
+    /// Looking for the best BSS for a particular network
+    Directed,
+}
+
+/// Capacity of "first come, first serve" slots available to clients of
+/// the mpsc::Sender<TelemetryEvent>.
+const TELEMETRY_EVENT_BUFFER_SIZE: usize = 100;
+/// How often to request RSSI stats and dispatcher packet counts from MLME.
+const TELEMETRY_QUERY_INTERVAL: zx::Duration = zx::Duration::from_seconds(15);
+
+/// Create a struct for sending TelemetryEvent, and a future representing the telemetry loop.
+///
+/// Every 15 seconds, the telemetry loop will query for MLME/PHY stats and update various
+/// time-interval stats. The telemetry loop also handles incoming TelemetryEvent to update
+/// the appropriate stats.
+pub fn serve_telemetry(inspect_node: InspectNode) -> (TelemetrySender, impl Future<Output = ()>) {
+    let (sender, mut receiver) = mpsc::channel::<TelemetryEvent>(TELEMETRY_EVENT_BUFFER_SIZE);
+    let fut = async move {
+        let mut report_interval_stream = fasync::Interval::new(TELEMETRY_QUERY_INTERVAL);
+        const ONE_HOUR: zx::Duration = zx::Duration::from_hours(1);
+        const_assert_eq!(ONE_HOUR.into_nanos() % TELEMETRY_QUERY_INTERVAL.into_nanos(), 0);
+        const INTERVAL_TICKS_PER_HR: u64 =
+            (ONE_HOUR.into_nanos() / TELEMETRY_QUERY_INTERVAL.into_nanos()) as u64;
+        let mut interval_tick = 0;
+        let mut telemetry = Telemetry::new(inspect_node);
+        loop {
+            select! {
+                event = receiver.next() => {
+                    if let Some(event) = event {
+                        telemetry.handle_telemetry_event(event);
+                    }
+                }
+                _ = report_interval_stream.next() => {
+                    telemetry.handle_periodic_telemetry();
+                    // This ensures that `signal_hr_passed` is always called after
+                    // `handle_periodic_telemetry` at the hour mark. This is mainly for ease
+                    // of testing.
+                    interval_tick = (interval_tick + 1) % INTERVAL_TICKS_PER_HR;
+                    if interval_tick == 0 {
+                        telemetry.signal_hr_passed();
+                    }
+                }
+            }
+        }
+    };
+    (TelemetrySender::new(sender), fut)
+}
+
+#[derive(Debug, Clone, PartialEq)]
+enum ConnectionState {
+    // Like disconnected, but no downtime is tracked.
+    Idle,
+    Connected,
+    Disconnected,
+}
+
+fn record_inspect_counters(
+    inspect_node: &InspectNode,
+    child_name: &str,
+    counters: Arc<Mutex<WindowedStats<StatCounters>>>,
+) {
+    inspect_node.record_lazy_child(child_name, move || {
+        let counters = Arc::clone(&counters);
+        async move {
+            let inspector = Inspector::new();
+            {
+                let counters_mutex_guard = counters.lock();
+                let counters = counters_mutex_guard.windowed_stat();
+                inspect_insert!(inspector.root(), {
+                    connected_duration: counters.connected_duration.into_nanos(),
+                    downtime_duration: counters.downtime_duration.into_nanos(),
+                    downtime_no_saved_neighbor_duration: counters.downtime_no_saved_neighbor_duration.into_nanos(),
+                });
+            }
+            Ok(inspector)
+        }
+        .boxed()
+    });
+}
+
+pub struct Telemetry {
+    connection_state: ConnectionState,
+    last_checked_connection_state: fasync::Time,
+    network_selection_start_time: Option<fasync::Time>,
+    stats_logger: StatsLogger,
+    _inspect_node: InspectNode,
+}
+
+impl Telemetry {
+    pub fn new(inspect_node: InspectNode) -> Self {
+        let stats_logger = StatsLogger::new();
+        record_inspect_counters(
+            &inspect_node,
+            "1d_counters",
+            Arc::clone(&stats_logger.last_1d_stats),
+        );
+        record_inspect_counters(
+            &inspect_node,
+            "7d_counters",
+            Arc::clone(&stats_logger.last_7d_stats),
+        );
+        Self {
+            connection_state: ConnectionState::Idle,
+            last_checked_connection_state: fasync::Time::now(),
+            network_selection_start_time: None,
+            stats_logger,
+            _inspect_node: inspect_node,
+        }
+    }
+
+    pub fn handle_periodic_telemetry(&mut self) {
+        let now = fasync::Time::now();
+        let duration = now - self.last_checked_connection_state;
+        match &self.connection_state {
+            ConnectionState::Idle => (),
+            ConnectionState::Connected => {
+                self.stats_logger.log_stat(StatOp::AddConnectedDuration(duration));
+            }
+            ConnectionState::Disconnected => {
+                self.stats_logger.log_stat(StatOp::AddDowntimeDuration(duration));
+            }
+        }
+        self.last_checked_connection_state = now;
+    }
+
+    pub fn handle_telemetry_event(&mut self, event: TelemetryEvent) {
+        let now = fasync::Time::now();
+        match event {
+            TelemetryEvent::StartNetworkSelection { .. } => {
+                let _prev = self.network_selection_start_time.replace(now);
+            }
+            TelemetryEvent::NetworkSelectionDecision { num_candidates, .. } => {
+                if let Some(start_time) = self.network_selection_start_time.take() {
+                    match self.connection_state {
+                        ConnectionState::Disconnected => match num_candidates {
+                            Ok(0) => {
+                                // TODO(fxbug.dev/80699): Track a `no_saved_neighbor` flag and add
+                                //                        all subsequent downtime to this counter.
+                                self.stats_logger.log_stat(
+                                    StatOp::AddDowntimeNoSavedNeighborDuration(now - start_time),
+                                );
+                            }
+                            _ => (),
+                        },
+                        _ => (),
+                    }
+                }
+            }
+            TelemetryEvent::Connected => {
+                let duration = now - self.last_checked_connection_state;
+                if let ConnectionState::Disconnected = self.connection_state {
+                    self.stats_logger.log_stat(StatOp::AddDowntimeDuration(duration));
+                }
+                self.connection_state = ConnectionState::Connected;
+                self.last_checked_connection_state = now;
+            }
+            TelemetryEvent::Disconnected { track_subsequent_downtime } => {
+                let duration = now - self.last_checked_connection_state;
+                if let ConnectionState::Connected = self.connection_state {
+                    self.stats_logger.log_stat(StatOp::AddConnectedDuration(duration));
+                }
+                self.connection_state = if track_subsequent_downtime {
+                    ConnectionState::Disconnected
+                } else {
+                    ConnectionState::Idle
+                };
+                self.last_checked_connection_state = now;
+            }
+        }
+    }
+
+    pub fn signal_hr_passed(&mut self) {
+        self.stats_logger.handle_hr_passed();
+    }
+}
+
+struct StatsLogger {
+    last_1d_stats: Arc<Mutex<WindowedStats<StatCounters>>>,
+    last_7d_stats: Arc<Mutex<WindowedStats<StatCounters>>>,
+    hr_tick: u32,
+}
+
+impl StatsLogger {
+    pub fn new() -> Self {
+        Self {
+            last_1d_stats: Arc::new(Mutex::new(WindowedStats::new(24))),
+            last_7d_stats: Arc::new(Mutex::new(WindowedStats::new(7))),
+            hr_tick: 0,
+        }
+    }
+
+    fn log_stat(&mut self, stat_op: StatOp) {
+        let zero = StatCounters::default();
+        let addition = match stat_op {
+            StatOp::AddConnectedDuration(duration) => {
+                StatCounters { connected_duration: duration, ..zero }
+            }
+            StatOp::AddDowntimeDuration(duration) => {
+                StatCounters { downtime_duration: duration, ..zero }
+            }
+            StatOp::AddDowntimeNoSavedNeighborDuration(duration) => {
+                StatCounters { downtime_no_saved_neighbor_duration: duration, ..zero }
+            }
+        };
+        self.last_1d_stats.lock().saturating_add(&addition);
+        self.last_7d_stats.lock().saturating_add(&addition);
+    }
+
+    fn handle_hr_passed(&mut self) {
+        self.hr_tick = (self.hr_tick + 1) % 24;
+        self.last_1d_stats.lock().slide_window();
+        if self.hr_tick == 0 {
+            self.last_7d_stats.lock().slide_window();
+        }
+    }
+}
+
+enum StatOp {
+    AddConnectedDuration(zx::Duration),
+    AddDowntimeDuration(zx::Duration),
+    // Downtime with no saved network in vicinity
+    AddDowntimeNoSavedNeighborDuration(zx::Duration),
+}
+
+#[derive(Clone, Default)]
+struct StatCounters {
+    connected_duration: zx::Duration,
+    downtime_duration: zx::Duration,
+    downtime_no_saved_neighbor_duration: zx::Duration,
+}
+
+// `Add` implementation is required to implement `SaturatingAdd` down below.
+impl Add for StatCounters {
+    type Output = Self;
+
+    fn add(self, other: Self) -> Self {
+        Self {
+            connected_duration: self.connected_duration + other.connected_duration,
+            downtime_duration: self.downtime_duration + other.downtime_duration,
+            downtime_no_saved_neighbor_duration: self.downtime_no_saved_neighbor_duration
+                + other.downtime_no_saved_neighbor_duration,
+        }
+    }
+}
+
+impl SaturatingAdd for StatCounters {
+    fn saturating_add(&self, v: &Self) -> Self {
+        Self {
+            connected_duration: zx::Duration::from_nanos(
+                self.connected_duration
+                    .into_nanos()
+                    .saturating_add(v.connected_duration.into_nanos()),
+            ),
+            downtime_duration: zx::Duration::from_nanos(
+                self.downtime_duration
+                    .into_nanos()
+                    .saturating_add(v.downtime_duration.into_nanos()),
+            ),
+            downtime_no_saved_neighbor_duration: zx::Duration::from_nanos(
+                self.downtime_no_saved_neighbor_duration
+                    .into_nanos()
+                    .saturating_add(v.downtime_no_saved_neighbor_duration.into_nanos()),
+            ),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use {
+        super::*,
+        fuchsia_inspect::{assert_data_tree, Inspector},
+        fuchsia_zircon::DurationNum,
+        futures::task::Poll,
+        std::pin::Pin,
+    };
+
+    const STEP_INCREMENT: zx::Duration = zx::Duration::from_seconds(1);
+
+    #[fuchsia::test]
+    fn test_stat_cycles() {
+        let (mut test_helper, mut test_fut) = setup_test();
+        test_helper.telemetry_sender.send(TelemetryEvent::Connected);
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        test_helper.advance_by(24.hours() - TELEMETRY_QUERY_INTERVAL, test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: (24.hours() - TELEMETRY_QUERY_INTERVAL).into_nanos(),
+                },
+                "7d_counters": contains {
+                    connected_duration: (24.hours() - TELEMETRY_QUERY_INTERVAL).into_nanos(),
+                },
+            }
+        });
+
+        test_helper.advance_to_next_telemetry_checkpoint(test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    // The first hour window is now discarded, so it only shows 23 hours
+                    // of connected duration.
+                    connected_duration: 23.hours().into_nanos(),
+                },
+                "7d_counters": contains {
+                    connected_duration: 24.hours().into_nanos(),
+                },
+            }
+        });
+
+        test_helper.advance_by(2.hours(), test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 23.hours().into_nanos(),
+                },
+                "7d_counters": contains {
+                    connected_duration: 26.hours().into_nanos(),
+                },
+            }
+        });
+
+        // Disconnect now
+        test_helper
+            .telemetry_sender
+            .send(TelemetryEvent::Disconnected { track_subsequent_downtime: false });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        // Now the 1d counter should decrease
+        test_helper.advance_by(8.hours(), test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 15.hours().into_nanos(),
+                },
+                "7d_counters": contains {
+                    connected_duration: 26.hours().into_nanos(),
+                },
+            }
+        });
+
+        // The 7d counter does not decrease before the 7th day
+        test_helper.advance_by(14.hours(), test_fut.as_mut());
+        test_helper.advance_by((5 * 24).hours() - TELEMETRY_QUERY_INTERVAL, test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 26.hours().into_nanos(),
+                },
+            }
+        });
+
+        // On the 7th day, the first 24 hours of connected duration is deducted
+        test_helper.advance_to_next_telemetry_checkpoint(test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 2.hours().into_nanos(),
+                },
+            }
+        });
+    }
+
+    #[fuchsia::test]
+    fn test_counters_when_idle() {
+        let (mut test_helper, mut test_fut) = setup_test();
+
+        test_helper.advance_by(30.minutes(), test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+            }
+        });
+
+        test_helper.advance_by(30.minutes(), test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+            }
+        });
+    }
+
+    #[fuchsia::test]
+    fn test_connected_counters_increase_when_connected() {
+        let (mut test_helper, mut test_fut) = setup_test();
+        test_helper.telemetry_sender.send(TelemetryEvent::Connected);
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        test_helper.advance_by(30.minutes(), test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 30.minutes().into_nanos(),
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 30.minutes().into_nanos(),
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+            }
+        });
+
+        test_helper.advance_by(30.minutes(), test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 1.hour().into_nanos(),
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 1.hour().into_nanos(),
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+            }
+        });
+    }
+
+    #[fuchsia::test]
+    fn test_downtime_counter() {
+        let (mut test_helper, mut test_fut) = setup_test();
+
+        // Disconnect but not track downtime. Downtime counter should not increase.
+        test_helper
+            .telemetry_sender
+            .send(TelemetryEvent::Disconnected { track_subsequent_downtime: false });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        test_helper.advance_by(10.minutes(), test_fut.as_mut());
+
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+            }
+        });
+
+        // Disconnect and track downtime. Downtime counter should now increase
+        test_helper
+            .telemetry_sender
+            .send(TelemetryEvent::Disconnected { track_subsequent_downtime: true });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        test_helper.advance_by(15.minutes(), test_fut.as_mut());
+
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: 15.minutes().into_nanos(),
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: 15.minutes().into_nanos(),
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+            }
+        });
+    }
+
+    #[fuchsia::test]
+    fn test_counters_connect_then_disconnect() {
+        let (mut test_helper, mut test_fut) = setup_test();
+        test_helper.telemetry_sender.send(TelemetryEvent::Connected);
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        test_helper.advance_by(5.seconds(), test_fut.as_mut());
+
+        // Disconnect but not track downtime. Downtime counter should not increase.
+        test_helper
+            .telemetry_sender
+            .send(TelemetryEvent::Disconnected { track_subsequent_downtime: true });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        // The 5 seconds connected duration is accounted for right away
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 5.seconds().into_nanos(),
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 5.seconds().into_nanos(),
+                    downtime_duration: 0i64,
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+            }
+        });
+
+        // At next telemetry checkpoint, `test_fut` updates the downtime duration
+        let downtime_start = fasync::Time::now();
+        test_helper.advance_to_next_telemetry_checkpoint(test_fut.as_mut());
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 5.seconds().into_nanos(),
+                    downtime_duration: (fasync::Time::now() - downtime_start).into_nanos(),
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+                "7d_counters": contains {
+                    connected_duration: 5.seconds().into_nanos(),
+                    downtime_duration: (fasync::Time::now() - downtime_start).into_nanos(),
+                    downtime_no_saved_neighbor_duration: 0i64,
+                },
+            }
+        });
+    }
+
+    #[fuchsia::test]
+    fn test_downtime_no_saved_neighbor_duration_counter() {
+        let (mut test_helper, mut test_fut) = setup_test();
+        test_helper.telemetry_sender.send(TelemetryEvent::Connected);
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        // Disconnect and track downtime.
+        test_helper
+            .telemetry_sender
+            .send(TelemetryEvent::Disconnected { track_subsequent_downtime: true });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        test_helper.advance_by(5.seconds(), test_fut.as_mut());
+        test_helper.telemetry_sender.send(TelemetryEvent::StartNetworkSelection {
+            network_selection_type: NetworkSelectionType::Undirected,
+        });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        test_helper.advance_by(2.seconds(), test_fut.as_mut());
+        test_helper.telemetry_sender.send(TelemetryEvent::NetworkSelectionDecision {
+            num_candidates: Ok(0),
+            selected_any: false,
+        });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        let prev = fasync::Time::now();
+        test_helper.advance_to_next_telemetry_checkpoint(test_fut.as_mut());
+        let downtime_duration = (7.seconds() + (fasync::Time::now() - prev)).into_nanos();
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: downtime_duration,
+                    downtime_no_saved_neighbor_duration: 2.seconds().into_nanos(),
+                },
+                "7d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: downtime_duration,
+                    downtime_no_saved_neighbor_duration: 2.seconds().into_nanos(),
+                },
+            }
+        });
+
+        // Disconnect but don't track downtime
+        test_helper
+            .telemetry_sender
+            .send(TelemetryEvent::Disconnected { track_subsequent_downtime: false });
+
+        // Go through the same sequence of network selection as before
+        test_helper.advance_by(5.seconds(), test_fut.as_mut());
+        test_helper.telemetry_sender.send(TelemetryEvent::StartNetworkSelection {
+            network_selection_type: NetworkSelectionType::Undirected,
+        });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        test_helper.advance_by(2.seconds(), test_fut.as_mut());
+        test_helper.telemetry_sender.send(TelemetryEvent::NetworkSelectionDecision {
+            num_candidates: Ok(0),
+            selected_any: false,
+        });
+        assert_eq!(test_helper.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+        test_helper.advance_to_next_telemetry_checkpoint(test_fut.as_mut());
+
+        // However, this time neither of the downtime counters should be incremented
+        assert_data_tree!(test_helper.inspector, root: {
+            stats: contains {
+                "1d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: downtime_duration,
+                    downtime_no_saved_neighbor_duration: 2.seconds().into_nanos(),
+                },
+                "7d_counters": contains {
+                    connected_duration: 0i64,
+                    downtime_duration: downtime_duration,
+                    downtime_no_saved_neighbor_duration: 2.seconds().into_nanos(),
+                },
+            }
+        });
+    }
+
+    struct TestHelper {
+        exec: fasync::TestExecutor,
+        telemetry_sender: TelemetrySender,
+        inspector: Inspector,
+    }
+
+    impl TestHelper {
+        // Advance executor by `duration`.
+        // This function repeatedly advances the executor by 1 second, triggering any expired timers
+        // and running the test_fut, until `duration` is reached.
+        fn advance_by(
+            &mut self,
+            duration: zx::Duration,
+            mut test_fut: Pin<&mut impl Future<Output = ()>>,
+        ) {
+            assert_eq!(
+                duration.into_nanos() % STEP_INCREMENT.into_nanos(),
+                0,
+                "duration {:?} is not divisible by STEP_INCREMENT",
+                duration,
+            );
+            const_assert_eq!(
+                TELEMETRY_QUERY_INTERVAL.into_nanos() % STEP_INCREMENT.into_nanos(),
+                0
+            );
+
+            for _i in 0..(duration.into_nanos() / STEP_INCREMENT.into_nanos()) {
+                self.exec.set_fake_time(fasync::Time::after(STEP_INCREMENT));
+                let _ = self.exec.wake_expired_timers();
+                assert_eq!(self.exec.run_until_stalled(&mut test_fut), Poll::Pending);
+            }
+        }
+
+        // Advance executor by some duration until the next time `test_fut` handles periodic
+        // telemetry. This uses `self.advance_by` underneath.
+        //
+        // This function assumes that executor starts test_fut at time 0 (which should be true
+        // if TestHelper is created from `setup_test()`)
+        fn advance_to_next_telemetry_checkpoint(
+            &mut self,
+            test_fut: Pin<&mut impl Future<Output = ()>>,
+        ) {
+            let now = fasync::Time::now();
+            let remaining_interval = TELEMETRY_QUERY_INTERVAL.into_nanos()
+                - (now.into_nanos() % TELEMETRY_QUERY_INTERVAL.into_nanos());
+            self.advance_by(zx::Duration::from_nanos(remaining_interval), test_fut)
+        }
+    }
+
+    fn setup_test() -> (TestHelper, Pin<Box<impl Future<Output = ()>>>) {
+        let mut exec = fasync::TestExecutor::new_with_fake_time().expect("executor should build");
+        exec.set_fake_time(fasync::Time::from_nanos(0));
+
+        let inspector = Inspector::new();
+        let inspect_node = inspector.root().create_child("stats");
+        let (telemetry_sender, test_fut) = serve_telemetry(inspect_node);
+        let mut test_fut = Box::pin(test_fut);
+
+        assert_eq!(exec.run_until_stalled(&mut test_fut), Poll::Pending);
+
+        let test_helper = TestHelper { exec, telemetry_sender, inspector };
+        (test_helper, test_fut)
+    }
+}
diff --git a/src/connectivity/wlan/wlancfg/src/telemetry/windowed_stats.rs b/src/connectivity/wlan/wlancfg/src/telemetry/windowed_stats.rs
index a6e0749..6b87c75 100644
--- a/src/connectivity/wlan/wlancfg/src/telemetry/windowed_stats.rs
+++ b/src/connectivity/wlan/wlancfg/src/telemetry/windowed_stats.rs
@@ -2,8 +2,6 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-#![allow(unused)]
-
 use num_traits::SaturatingAdd;
 use std::collections::VecDeque;
 use std::default::Default;