Skip to main content

netcfg/telemetry/processors/
network_properties.rs

1// Copyright 2026 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::telemetry::NetworkEventMetadata;
6use fidl_fuchsia_net_policy_socketproxy as fnp_socketproxy;
7use fuchsia_inspect::Node as InspectNode;
8use fuchsia_inspect_contrib::nodes::LruCacheNode;
9use fuchsia_inspect_derive::Unit;
10use windowed_stats::experimental::inspect::{InspectSender, InspectedTimeMatrix};
11use windowed_stats::experimental::series::interpolation::LastSample;
12use windowed_stats::experimental::series::metadata::{BitSetMap, BitSetNode};
13use windowed_stats::experimental::series::statistic::Union;
14use windowed_stats::experimental::series::{SamplingProfile, TimeMatrix};
15
16pub struct NetworkPropertiesProcessor<S: InspectSender> {
17    default_network_detailed_matrix: InspectedTimeMatrix<u64>,
18    default_network_type_matrix: InspectedTimeMatrix<u64>,
19    inspect_metadata_node: InspectMetadataNode,
20    connectivity_matrices: Vec<Option<NetworkConnectivityTimeSeries<S>>>,
21    inspect_metadata_path: String,
22}
23
24struct NetworkConnectivityTimeSeries<S> {
25    network_id: u64,
26    _client: S,
27    matrix: InspectedTimeMatrix<u64>,
28}
29
30const METADATA_NODE_NAME: &str = "metadata";
31
32impl<S: InspectSender> NetworkPropertiesProcessor<S> {
33    pub fn new(parent: &InspectNode, parent_path: &str, client: &S) -> Self {
34        let inspect_metadata_node = parent.create_child(METADATA_NODE_NAME);
35        let inspect_metadata_path = format!("{}/{}", parent_path, METADATA_NODE_NAME);
36        let detailed_time_matrix = TimeMatrix::<Union<u64>, LastSample>::new(
37            SamplingProfile::granular(),
38            LastSample::or(0),
39        );
40        let default_network_detailed_matrix = client.inspect_time_matrix_with_metadata(
41            "default_network_detailed",
42            detailed_time_matrix,
43            BitSetNode::from_path(format!(
44                "{}/{}",
45                inspect_metadata_path,
46                InspectMetadataNode::NETWORK_REGISTRY
47            )),
48        );
49
50        let types_time_matrix = TimeMatrix::<Union<u64>, LastSample>::new(
51            SamplingProfile::granular(),
52            LastSample::or(0),
53        );
54        let default_network_type_matrix = client.inspect_time_matrix_with_metadata(
55            "default_network_type",
56            types_time_matrix,
57            BitSetNode::from_path(format!(
58                "{}/{}",
59                inspect_metadata_path,
60                InspectMetadataNode::NETWORK_TYPES
61            )),
62        );
63
64        let mut connectivity_matrices = Vec::with_capacity(NETWORKS_METADATA_CACHE_SIZE);
65        for _ in 0..NETWORKS_METADATA_CACHE_SIZE {
66            connectivity_matrices.push(None);
67        }
68
69        Self {
70            default_network_detailed_matrix,
71            default_network_type_matrix,
72            inspect_metadata_node: InspectMetadataNode::new(inspect_metadata_node),
73            connectivity_matrices,
74            inspect_metadata_path,
75        }
76    }
77
78    pub fn log_default_network_lost(&mut self) {
79        self.default_network_detailed_matrix.fold_or_log_error(0);
80        self.default_network_type_matrix.fold_or_log_error(0);
81    }
82
83    pub fn log_default_network_changed(&mut self, metadata: NetworkEventMetadata) {
84        let data = NetworkData::from(metadata);
85        let types_mapped_id =
86            self.inspect_metadata_node.network_types.insert(data.transport.clone());
87        self.default_network_type_matrix.fold_or_log_error(1u64 << types_mapped_id);
88
89        let detailed_mapped_id = self.inspect_metadata_node.network_registry.insert(data);
90        self.default_network_detailed_matrix.fold_or_log_error(1u64 << detailed_mapped_id);
91    }
92
93    pub fn log_network_changed(
94        &mut self,
95        metadata: crate::telemetry::NetworkEventMetadata,
96        client: &S,
97    ) {
98        let network_id = metadata.id;
99        let connectivity_state = metadata.connectivity_state;
100
101        let data = NetworkData::from(metadata);
102        let detailed_mapped_id = self.inspect_metadata_node.network_registry.insert(data);
103
104        let connectivity_state = match connectivity_state {
105            Some(state) => state,
106            None => return,
107        };
108
109        let bit_index = match connectivity_state_to_bit_index(connectivity_state) {
110            Some(index) => index,
111            None => return,
112        };
113
114        // If the slot holds a different network_id, overwrite it.
115        let needs_new_matrix = match self.connectivity_matrices.get(detailed_mapped_id) {
116            Some(Some(time_series)) => time_series.network_id != network_id,
117            Some(None) | None => true,
118        };
119
120        if needs_new_matrix {
121            // Overwriting with None drops the old node and evicts the time series.
122            self.connectivity_matrices[detailed_mapped_id] = None;
123
124            let node_name = format!("network_{}", network_id);
125            let scoped_client = client.clone_with_child(&node_name);
126
127            let time_matrix = TimeMatrix::<Union<u64>, LastSample>::new(
128                SamplingProfile::highly_granular(),
129                LastSample::or(0),
130            );
131
132            let matrix = scoped_client.inspect_time_matrix_with_metadata(
133                "connectivity",
134                time_matrix,
135                BitSetNode::from_path(format!(
136                    "{}/connectivity_states",
137                    self.inspect_metadata_path
138                )),
139            );
140
141            self.connectivity_matrices[detailed_mapped_id] =
142                Some(NetworkConnectivityTimeSeries { network_id, _client: scoped_client, matrix });
143        }
144
145        if let Some(ts) = &self.connectivity_matrices[detailed_mapped_id] {
146            ts.matrix.fold_or_log_error(1u64 << bit_index);
147        }
148    }
149}
150
151#[derive(Unit, PartialEq, Eq, Hash)]
152struct NetworkData {
153    pub id: u64,
154    pub name: String,
155    pub transport: String,
156    pub is_fuchsia_provisioned: bool,
157}
158
159impl From<NetworkEventMetadata> for NetworkData {
160    fn from(metadata: NetworkEventMetadata) -> Self {
161        let NetworkEventMetadata { id, name, transport, is_fuchsia_provisioned, .. } = metadata;
162        Self {
163            id: id,
164            name: name.unwrap_or_else(|| "unknown".to_string()),
165            transport: format!("{:?}", transport),
166            is_fuchsia_provisioned,
167        }
168    }
169}
170
171fn get_ordered_connectivity_states() -> [&'static str; 4] {
172    ["NoConnectivity", "LocalConnectivity", "PartialConnectivity", "FullConnectivity"]
173}
174
175fn connectivity_state_to_bit_index(state: fnp_socketproxy::ConnectivityState) -> Option<u8> {
176    match state {
177        fnp_socketproxy::ConnectivityState::NoConnectivity => Some(0),
178        fnp_socketproxy::ConnectivityState::LocalConnectivity => Some(1),
179        fnp_socketproxy::ConnectivityState::PartialConnectivity => Some(2),
180        fnp_socketproxy::ConnectivityState::FullConnectivity => Some(3),
181        _ => None,
182    }
183}
184
185const NETWORKS_METADATA_CACHE_SIZE: usize = 16;
186const NETWORK_TYPES_CACHE_SIZE: usize = 8;
187
188// Holds the inspect node children for the metadata that correlates to
189// bits in the default network bitsets.
190struct InspectMetadataNode {
191    _node: InspectNode,
192    network_registry: LruCacheNode<NetworkData>,
193    network_types: LruCacheNode<String>,
194    _connectivity_states: InspectNode,
195}
196
197impl InspectMetadataNode {
198    const NETWORK_REGISTRY: &'static str = "network_registry";
199    const NETWORK_TYPES: &'static str = "network_types";
200
201    fn new(inspect_node: InspectNode) -> Self {
202        // Record the network registry, which is dynamically updated as networks
203        // are added.
204        let network_registry = LruCacheNode::new(
205            inspect_node.create_child(Self::NETWORK_REGISTRY),
206            NETWORKS_METADATA_CACHE_SIZE,
207        );
208
209        // Record the observed network types for the default network type time matrix.
210        let network_types = LruCacheNode::new(
211            inspect_node.create_child(Self::NETWORK_TYPES),
212            NETWORK_TYPES_CACHE_SIZE,
213        );
214
215        let connectivity_states = inspect_node.create_child("connectivity_states");
216        let connectivity_metadata = BitSetMap::from_ordered(get_ordered_connectivity_states());
217        connectivity_metadata.record(&connectivity_states);
218
219        Self {
220            _node: inspect_node,
221            network_registry,
222            network_types,
223            _connectivity_states: connectivity_states,
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use diagnostics_assertions::{AnyBytesProperty, assert_data_tree};
232    use fidl_fuchsia_net_policy_socketproxy as fnp_socketproxy;
233    use fuchsia_inspect::Inspector;
234    use fuchsia_inspect::reader::DiagnosticsHierarchy;
235    use futures::task::Poll;
236    use std::pin::pin;
237    use windowed_stats::experimental::clock::Timed;
238    use windowed_stats::experimental::inspect::TimeMatrixClient;
239    use windowed_stats::experimental::testing::{MockTimeMatrixClient, TimeMatrixCall};
240
241    pub struct TestHelper {
242        pub inspector: Inspector,
243        pub inspect_node: InspectNode,
244        pub parent_path: String,
245        pub mock_time_matrix_client: MockTimeMatrixClient,
246
247        // Note: keep the executor field last in the struct so it gets dropped last.
248        pub exec: fuchsia_async::TestExecutor,
249    }
250
251    impl TestHelper {
252        pub fn get_inspect_data_tree(&mut self) -> DiagnosticsHierarchy {
253            let read_fut = fuchsia_inspect::reader::read(&self.inspector);
254            let mut read_fut = pin!(read_fut);
255            match self.exec.run_until_stalled(&mut read_fut) {
256                Poll::Pending => {
257                    panic!("Unexpected pending state");
258                }
259                Poll::Ready(result) => result.expect("failed to get hierarchy"),
260            }
261        }
262    }
263
264    pub fn setup_test() -> TestHelper {
265        let exec = fuchsia_async::TestExecutor::new_with_fake_time();
266        exec.set_fake_time(fuchsia_async::MonotonicInstant::from_nanos(0));
267
268        let inspector = Inspector::default();
269        let inspect_node = inspector.root().create_child("test_stats");
270        let parent_path = "root/test_stats".to_string();
271
272        TestHelper {
273            inspector,
274            inspect_node,
275            parent_path,
276            mock_time_matrix_client: MockTimeMatrixClient::new(),
277            exec,
278        }
279    }
280
281    fn log_network_events<S: InspectSender>(processor: &mut NetworkPropertiesProcessor<S>) {
282        let eth_metadata = NetworkEventMetadata {
283            id: 0,
284            name: Some("eth0".to_string()),
285            transport: fnp_socketproxy::NetworkType::Ethernet,
286            is_fuchsia_provisioned: true,
287            connectivity_state: None,
288        };
289
290        let wlan_metadata = NetworkEventMetadata {
291            id: 1,
292            name: Some("wlan0".to_string()),
293            transport: fnp_socketproxy::NetworkType::Wifi,
294            is_fuchsia_provisioned: false,
295            connectivity_state: None,
296        };
297
298        processor.log_default_network_changed(eth_metadata);
299        processor.log_default_network_lost();
300        processor.log_default_network_changed(wlan_metadata);
301    }
302
303    #[fuchsia::test]
304    fn log_default_network_time_series_calls() {
305        let harness = setup_test();
306        let mut processor = NetworkPropertiesProcessor::new(
307            &harness.inspect_node,
308            &harness.parent_path,
309            &harness.mock_time_matrix_client,
310        );
311        log_network_events(&mut processor);
312
313        let mut time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
314        assert_eq!(
315            &time_matrix_calls.drain::<u64>("default_network_detailed")[..],
316            &[
317                TimeMatrixCall::Fold(Timed::now(1 << 0)),
318                TimeMatrixCall::Fold(Timed::now(0)),
319                TimeMatrixCall::Fold(Timed::now(1 << 1)),
320            ]
321        );
322        assert_eq!(
323            &time_matrix_calls.drain::<u64>("default_network_type")[..],
324            &[
325                TimeMatrixCall::Fold(Timed::now(1 << 0)),
326                TimeMatrixCall::Fold(Timed::now(0)),
327                TimeMatrixCall::Fold(Timed::now(1 << 1)),
328            ]
329        );
330    }
331
332    #[fuchsia::test]
333    fn log_network_connectivity_time_series_calls() {
334        let harness = setup_test();
335        let mut processor = NetworkPropertiesProcessor::new(
336            &harness.inspect_node,
337            &harness.parent_path,
338            &harness.mock_time_matrix_client,
339        );
340
341        let eth_metadata = NetworkEventMetadata {
342            id: 0,
343            name: Some("eth0".to_string()),
344            transport: fnp_socketproxy::NetworkType::Ethernet,
345            is_fuchsia_provisioned: true,
346            connectivity_state: Some(fnp_socketproxy::ConnectivityState::FullConnectivity),
347        };
348
349        let wlan_metadata = NetworkEventMetadata {
350            id: 1,
351            name: Some("wlan0".to_string()),
352            transport: fnp_socketproxy::NetworkType::Wifi,
353            is_fuchsia_provisioned: false,
354            connectivity_state: Some(fnp_socketproxy::ConnectivityState::LocalConnectivity),
355        };
356
357        processor.log_network_changed(eth_metadata, &harness.mock_time_matrix_client);
358        processor.log_network_changed(wlan_metadata, &harness.mock_time_matrix_client);
359
360        let mut time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
361
362        // FullConnectivity maps to bit 3 -> value 2^3 = 8
363        assert_eq!(
364            &time_matrix_calls.drain::<u64>("network_0/connectivity")[..],
365            &[TimeMatrixCall::Fold(Timed::now(1 << 3))]
366        );
367
368        // LocalConnectivity maps to bit 1 -> value 2^1 = 2
369        assert_eq!(
370            &time_matrix_calls.drain::<u64>("network_1/connectivity")[..],
371            &[TimeMatrixCall::Fold(Timed::now(1 << 1))]
372        );
373    }
374
375    #[fuchsia::test]
376    fn log_network_connectivity_inspect_tree() {
377        let mut harness = setup_test();
378        let time_matrix_client =
379            TimeMatrixClient::new(harness.inspect_node.create_child("time_series"));
380        let mut processor = NetworkPropertiesProcessor::new(
381            &harness.inspect_node,
382            &harness.parent_path,
383            &time_matrix_client,
384        );
385
386        let eth_metadata = NetworkEventMetadata {
387            id: 0,
388            name: Some("eth0".to_string()),
389            transport: fnp_socketproxy::NetworkType::Ethernet,
390            is_fuchsia_provisioned: true,
391            connectivity_state: Some(fnp_socketproxy::ConnectivityState::FullConnectivity),
392        };
393
394        processor.log_network_changed(eth_metadata, &time_matrix_client);
395
396        let hierarchy = harness.get_inspect_data_tree();
397
398        assert_data_tree!(
399            @executor harness.exec,
400            hierarchy,
401            root: contains {
402                test_stats: contains {
403                    metadata: contains {
404                        connectivity_states: contains {
405                            index: contains {
406                                "0": "NoConnectivity",
407                                "1": "LocalConnectivity",
408                                "2": "PartialConnectivity",
409                                "3": "FullConnectivity",
410                            }
411                        }
412                    },
413                    time_series: contains {
414                        network_0: contains {
415                            connectivity: contains {
416                                "type": "bitset",
417                                "data": AnyBytesProperty,
418                                metadata: {
419                                    index_node_path: "root/test_stats/metadata/connectivity_states",
420                                }
421                            }
422                        }
423                    }
424                }
425            }
426        );
427    }
428
429    #[fuchsia::test]
430    fn log_default_network_inspect_tree() {
431        let mut harness = setup_test();
432        let time_matrix_client =
433            TimeMatrixClient::new(harness.inspect_node.create_child("time_series"));
434        let mut processor = NetworkPropertiesProcessor::new(
435            &harness.inspect_node,
436            &harness.parent_path,
437            &time_matrix_client,
438        );
439        log_network_events(&mut processor);
440
441        let hierarchy = harness.get_inspect_data_tree();
442
443        assert_data_tree!(
444            @executor harness.exec,
445            hierarchy,
446            root: contains {
447                test_stats: contains {
448                    metadata: contains {
449                        network_registry: contains {
450                            "0": contains {
451                                data: {
452                                    id: 0u64,
453                                    name: "eth0",
454                                    transport: "Ethernet",
455                                    is_fuchsia_provisioned: true,
456                                }
457                            },
458                            "1": contains {
459                                data: {
460                                    id: 1u64,
461                                    name: "wlan0",
462                                    transport: "Wifi",
463                                    is_fuchsia_provisioned: false,
464                                }
465                            }
466                        },
467                        network_types: contains {
468                            "0": contains {
469                                data: "Ethernet",
470                            },
471                            "1": contains {
472                                data: "Wifi",
473                            }
474                        }
475                    },
476                    time_series: contains {
477                        default_network_detailed: {
478                            "type": "bitset",
479                            "data": AnyBytesProperty,
480                            metadata: {
481                                index_node_path: "root/test_stats/metadata/network_registry",
482                            }
483                        },
484                        default_network_type: {
485                            "type": "bitset",
486                            "data": AnyBytesProperty,
487                            metadata: {
488                                index_node_path: "root/test_stats/metadata/network_types",
489                            }
490                        },
491                    }
492                }
493            }
494        );
495    }
496
497    #[fuchsia::test]
498    fn log_network_connectivity_eviction() {
499        let mut harness = setup_test();
500        let time_matrix_client =
501            TimeMatrixClient::new(harness.inspect_node.create_child("time_series"));
502        let mut processor = NetworkPropertiesProcessor::new(
503            &harness.inspect_node,
504            &harness.parent_path,
505            &time_matrix_client,
506        );
507
508        // Log 16 unique networks to fill the cache.
509        for i in 0..16 {
510            let metadata = NetworkEventMetadata {
511                id: i as u64,
512                name: Some(format!("eth{}", i)),
513                transport: fnp_socketproxy::NetworkType::Ethernet,
514                is_fuchsia_provisioned: true,
515                connectivity_state: Some(fnp_socketproxy::ConnectivityState::FullConnectivity),
516            };
517            processor.log_network_changed(metadata, &time_matrix_client);
518        }
519
520        // network_0 should exist before eviction.
521        let hierarchy_before = harness.get_inspect_data_tree();
522        assert_data_tree!(
523            @executor harness.exec,
524            hierarchy_before,
525            root: contains {
526                test_stats: contains {
527                    time_series: contains {
528                        network_0: contains {}
529                    }
530                }
531            }
532        );
533
534        // Log a 17th network. This should trigger eviction of slot 0 (network 0).
535        let metadata = NetworkEventMetadata {
536            id: 16,
537            name: Some("eth16".to_string()),
538            transport: fnp_socketproxy::NetworkType::Ethernet,
539            is_fuchsia_provisioned: true,
540            connectivity_state: Some(fnp_socketproxy::ConnectivityState::LocalConnectivity),
541        };
542        processor.log_network_changed(metadata, &time_matrix_client);
543
544        let hierarchy = harness.get_inspect_data_tree();
545
546        // Verify that network_16 is present.
547        assert_data_tree!(
548            @executor harness.exec,
549            hierarchy,
550            root: contains {
551                test_stats: contains {
552                    time_series: contains {
553                        network_16: contains {}
554                    }
555                }
556            }
557        );
558
559        // Verify that network_0 is not in the tree. There is no assert_data_tree! macro for
560        // negative assertions.
561        let test_stats = hierarchy.children.iter().find(|c| c.name == "test_stats").unwrap();
562        let time_series = test_stats.children.iter().find(|c| c.name == "time_series").unwrap();
563        assert!(
564            !time_series.children.iter().any(|c| c.name == "network_0"),
565            "network_0 was not evicted from Inspect!"
566        );
567    }
568}