Skip to main content

reachability_core/telemetry/processors/
link_properties_state.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// TODO(https://fxbug.dev/432298933): Localize the unused tag once reachability
6// starts reporting LinkProperties and LinkState time series data.
7#![allow(unused)]
8
9use super::{
10    InterfaceIdentifier, InterfaceTimeSeriesGrouping, InterfaceType, identifiers_from_port_class,
11};
12
13use fidl_fuchsia_net_interfaces_ext::PortClass;
14use fuchsia_inspect::Node as InspectNode;
15use fuchsia_inspect_contrib::id_enum::IdEnum;
16use fuchsia_sync::Mutex;
17use std::collections::HashMap;
18use std::sync::Arc;
19use windowed_stats::experimental::inspect::{InspectSender, InspectedTimeMatrix};
20use windowed_stats::experimental::series::interpolation::LastSample;
21use windowed_stats::experimental::series::metadata::{BitSetMap, BitSetNode};
22use windowed_stats::experimental::series::statistic::Union;
23use windowed_stats::experimental::series::{SamplingProfile, TimeMatrix};
24
25use crate::{IpVersions, LinkState};
26
27#[derive(Debug)]
28enum TimeSeriesType {
29    // LinkProperties time series report `LinkProperties` structs, with each
30    // boolean field mapped to a bit in a bitset.
31    LinkProperties,
32    // LinkState time series report `LinkState` enum variants, with each
33    // variant mapped to a bit in a bitset.
34    LinkState,
35}
36
37impl std::fmt::Display for TimeSeriesType {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        let name = match *self {
40            Self::LinkProperties => "link_properties",
41            Self::LinkState => "link_state",
42        };
43        write!(f, "{}", name)
44    }
45}
46
47// A representation of an interface's provisioning status.
48#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
49pub struct LinkProperties {
50    // For IPv4, indicates the acquisition of an IPv4 address. For IPv6,
51    // indicates the acquisition of a non-link local IPv6 address.
52    pub has_address: bool,
53    pub has_default_route: bool,
54    // TODO(https://fxbug.dev/42175016): Perform DNS lookups across the
55    // specified network.
56    //
57    // This is currently a system-wide check. For example, if one interface
58    // can perform DNS resolution, then all of them will report `has_dns` true.
59    pub has_dns: bool,
60    // TODO(https://fxbug.dev/432303907): Separate the HTTP check from DNS.
61    // HTTP can succeed without requiring DNS to be enabled and functional.
62    //
63    // Whether a device can successfully use the HTTP protocol.
64    pub has_http_reachability: bool,
65}
66
67impl LinkProperties {
68    // Each field in the LinkProperties struct represents a single bit. The
69    // first field, `has_address` is the least significant bit. This function
70    // must maintain ordered alignment with `link_properties_metadata`
71    // in `InspectMetadataNode`.
72    fn to_bitset(&self) -> u64 {
73        let mut result = 0u64;
74        if self.has_address {
75            result |= 1;
76        }
77
78        if self.has_default_route {
79            result |= 1 << 1;
80        }
81
82        if self.has_dns {
83            result |= 1 << 2;
84        }
85
86        if self.has_http_reachability {
87            result |= 1 << 3;
88        }
89        result
90    }
91}
92
93#[cfg(test)]
94impl From<u64> for LinkProperties {
95    fn from(data: u64) -> Self {
96        Self {
97            has_address: (data & 1) == 1,
98            has_default_route: (data & 1 << 1) >= 1,
99            has_dns: (data & 1 << 2) >= 1,
100            has_http_reachability: (data & 1 << 3) >= 1,
101        }
102    }
103}
104
105impl IdEnum for LinkState {
106    type Id = u8;
107    // This function must maintain ordered alignment with `link_state_metadata`
108    // in `InspectMetadataNode`.
109    fn to_id(&self) -> Self::Id {
110        match self {
111            LinkState::None => 0,
112            LinkState::Removed => 1,
113            LinkState::Down => 2,
114            LinkState::Up => 3,
115            LinkState::Local => 4,
116            LinkState::Gateway => 5,
117            LinkState::Internet => 6,
118        }
119    }
120}
121
122#[cfg(test)]
123impl From<u64> for LinkState {
124    fn from(data: u64) -> Self {
125        match data {
126            0 => Self::None,
127            1 => Self::Removed,
128            2 => Self::Down,
129            3 => Self::Up,
130            4 => Self::Local,
131            5 => Self::Gateway,
132            6 => Self::Internet,
133            unknown => {
134                log::info!("invalid ordinal provided: {data:?}");
135                Self::None
136            }
137        }
138    }
139}
140
141// Create a time series for IPv4 and IPv6 stored in `IpVersions`.
142fn ip_versions_time_series<S: InspectSender>(
143    client: &S,
144    inspect_metadata_path: &str,
145    series_type: TimeSeriesType,
146    identifier: InterfaceIdentifier,
147) -> IpVersions<InspectedTimeMatrix<u64>> {
148    // Time series of the same type report the same metadata and can use the
149    // same metadata node.
150    let metadata_node = match series_type {
151        TimeSeriesType::LinkProperties => InspectMetadataNode::LINK_PROPERTIES,
152        TimeSeriesType::LinkState => InspectMetadataNode::LINK_STATE,
153    };
154    let bitset_node = BitSetNode::from_path(format!("{}/{}", inspect_metadata_path, metadata_node));
155    // A separate time matrix is created for IPv4 and IPv6.
156    IpVersions {
157        ipv4: single_time_matrix(
158            client,
159            format!("{}_v4_{}", series_type, identifier),
160            bitset_node.clone(),
161        ),
162        ipv6: single_time_matrix(client, format!("{}_v6_{}", series_type, identifier), bitset_node),
163    }
164}
165
166// Create a single u64 time matrix with a highly granular sampling profile.
167fn single_time_matrix<S: InspectSender>(
168    client: &S,
169    time_series_name: String,
170    bitset_node: BitSetNode,
171) -> InspectedTimeMatrix<u64> {
172    client.inspect_time_matrix_with_metadata(
173        time_series_name,
174        TimeMatrix::<Union<u64>, LastSample>::new(
175            SamplingProfile::highly_granular(),
176            LastSample::or(0),
177        ),
178        bitset_node,
179    )
180}
181
182impl IpVersions<InspectedTimeMatrix<u64>> {
183    // Helper functions to make logging based on protocol cleaner.
184    fn log_v4(&self, data: u64) {
185        self.ipv4.fold_or_log_error(data);
186    }
187
188    fn log_v6(&self, data: u64) {
189        self.ipv6.fold_or_log_error(data);
190    }
191}
192
193// State for tracking link properties and link state across an interface,
194// backed by time series.
195struct PerInterfaceTimeSeries {
196    // The most recent link properties for the interface.
197    link_properties: Arc<Mutex<IpVersions<LinkProperties>>>,
198    // Time matrix for tracking changes in `link_properties`.
199    link_properties_time_matrix: IpVersions<InspectedTimeMatrix<u64>>,
200    // The most recent link state for the interface.
201    link_state: Arc<Mutex<IpVersions<LinkState>>>,
202    // Time matrix for tracking changes in `link_state`.
203    link_state_time_matrix: IpVersions<InspectedTimeMatrix<u64>>,
204}
205
206impl PerInterfaceTimeSeries {
207    pub fn new<S: InspectSender>(
208        client: &S,
209        inspect_metadata_path: &str,
210        identifier: InterfaceIdentifier,
211    ) -> Self {
212        Self {
213            link_properties: Arc::new(Mutex::new(IpVersions::default())),
214            link_properties_time_matrix: ip_versions_time_series(
215                client,
216                inspect_metadata_path,
217                TimeSeriesType::LinkProperties,
218                identifier.clone(),
219            ),
220            link_state: Arc::new(Mutex::new(IpVersions::default())),
221            link_state_time_matrix: ip_versions_time_series(
222                client,
223                inspect_metadata_path,
224                TimeSeriesType::LinkState,
225                identifier,
226            ),
227        }
228    }
229
230    fn log_link_properties_v4(&self, link_properties: LinkProperties) {
231        self.link_properties_time_matrix.log_v4(link_properties.to_bitset());
232    }
233
234    fn log_link_properties_v6(&self, link_properties: LinkProperties) {
235        self.link_properties_time_matrix.log_v6(link_properties.to_bitset());
236    }
237
238    fn maybe_log_link_properties(&self, new: &IpVersions<LinkProperties>) {
239        let mut curr = self.link_properties.lock();
240
241        // TODO(https://fxbug.dev/432304519): `InterfaceType` groupings need to
242        // be specially handled. If multiple interfaces of the same type
243        // co-exist, then the one that is last updated will take precedence.
244        // It is desired that the 'highest' rating link properties of the
245        // provided type will be reported.
246        if new.ipv4 != curr.ipv4 {
247            curr.ipv4 = new.ipv4;
248            self.log_link_properties_v4(curr.ipv4);
249        }
250
251        if new.ipv6 != curr.ipv6 {
252            curr.ipv6 = new.ipv6;
253            self.log_link_properties_v6(curr.ipv6);
254        }
255    }
256
257    fn log_link_state_v4(&self, link_state: LinkState) {
258        self.link_state_time_matrix.log_v4(1 << (link_state.to_id() as u64));
259    }
260
261    fn log_link_state_v6(&self, link_state: LinkState) {
262        self.link_state_time_matrix.log_v6(1 << (link_state.to_id() as u64));
263    }
264
265    fn maybe_log_link_state(&self, new: &IpVersions<LinkState>) {
266        let mut curr = self.link_state.lock();
267
268        // TODO(https://fxbug.dev/432304519): See `maybe_log_link_properties`.
269        if new.ipv4 != curr.ipv4 {
270            curr.ipv4 = new.ipv4;
271            self.log_link_state_v4(curr.ipv4);
272        }
273
274        if new.ipv6 != curr.ipv6 {
275            curr.ipv6 = new.ipv6;
276            self.log_link_state_v6(curr.ipv6);
277        }
278    }
279}
280
281// The wrapper for the time series reporting for LinkProperties and LinkState.
282pub struct LinkPropertiesStateLogger {
283    // Tracks the provided `InterfaceIdentifier`s against the time series for
284    // that identifier. Entries are only created during initialization.
285    time_series_stats: HashMap<InterfaceIdentifier, PerInterfaceTimeSeries>,
286    inspect_metadata_node: InspectMetadataNode,
287}
288
289impl LinkPropertiesStateLogger {
290    pub fn new<S: InspectSender>(
291        inspect_metadata_node: &InspectNode,
292        inspect_metadata_path: &str,
293        interface_grouping: InterfaceTimeSeriesGrouping,
294        time_matrix_client: &S,
295    ) -> Self {
296        Self {
297            // Create a time series per interface type provided.
298            time_series_stats: match interface_grouping {
299                InterfaceTimeSeriesGrouping::Type(tys) => tys.into_iter().map(|ty| {
300                    let identifier = InterfaceIdentifier::Type(ty);
301                    (
302                        identifier.clone(),
303                        PerInterfaceTimeSeries::new(
304                            time_matrix_client,
305                            inspect_metadata_path,
306                            identifier,
307                        ),
308                    )
309                }),
310            }
311            .collect(),
312            inspect_metadata_node: InspectMetadataNode::new(inspect_metadata_node),
313        }
314    }
315
316    // Update an interface's `LinkProperties`. `interface_identifiers`
317    // represent the various ways that an interface can be identified. When an
318    // identifier matches an attribute that is being tracked in
319    // `time_series_stats`, attempt to log that `LinkProperties` update.
320    pub fn update_link_properties(
321        &self,
322        interface_identifiers: Vec<InterfaceIdentifier>,
323        link_properties: &IpVersions<LinkProperties>,
324    ) {
325        interface_identifiers.iter().for_each(|identifier| {
326            if let Some(time_series) = self.time_series_stats.get(identifier) {
327                time_series.maybe_log_link_properties(&link_properties);
328            }
329        });
330    }
331
332    // Update an interface's `LinkState`. `interface_identifiers` represent the
333    // various ways that an interface can be identified. When an identifier
334    // matches an attribute that is being tracked in `time_series_stats`,
335    // attempt to log that `LinkState` update.
336    pub fn update_link_state(
337        &self,
338        interface_identifiers: Vec<InterfaceIdentifier>,
339        link_state: &IpVersions<LinkState>,
340    ) {
341        interface_identifiers.iter().for_each(|identifier| {
342            if let Some(time_series) = self.time_series_stats.get(identifier) {
343                time_series.maybe_log_link_state(&link_state);
344            }
345        });
346    }
347}
348
349// Holds the inspect node children for the static metadata that correlates to
350// bits in each of the corresponding structs / enums.
351struct InspectMetadataNode {
352    link_properties: InspectNode,
353    link_state: InspectNode,
354}
355
356impl InspectMetadataNode {
357    const LINK_PROPERTIES: &'static str = "link_properties";
358    const LINK_STATE: &'static str = "link_state";
359
360    fn new(inspect_node: &InspectNode) -> Self {
361        let link_properties = inspect_node.create_child(Self::LINK_PROPERTIES);
362        let link_state = inspect_node.create_child(Self::LINK_STATE);
363
364        let link_properties_metadata = BitSetMap::from_ordered([
365            "has_address",
366            "has_default_route",
367            "has_dns",
368            "has_http_reachability",
369        ]);
370        let link_state_metadata = BitSetMap::from_ordered([
371            "None", "Removed", "Down", "Up", "Local", "Gateway", "Internet",
372        ]);
373
374        link_properties_metadata.record(&link_properties);
375        link_state_metadata.record(&link_state);
376
377        Self { link_properties, link_state }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use diagnostics_assertions::{AnyBytesProperty, AnyNumericProperty, assert_data_tree};
385
386    use crate::telemetry::testing::setup_test;
387    use windowed_stats::experimental::clock::Timed;
388    use windowed_stats::experimental::inspect::TimeMatrixClient;
389    use windowed_stats::experimental::testing::TimeMatrixCall;
390
391    #[fuchsia::test]
392    fn test_log_time_series_metadata_to_inspect() {
393        let mut harness = setup_test();
394
395        let client =
396            TimeMatrixClient::new(harness.inspect_node.create_child("link_properties_state"));
397        let _link_properties_state = LinkPropertiesStateLogger::new(
398            &harness.inspect_metadata_node,
399            &harness.inspect_metadata_path,
400            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
401            &client,
402        );
403
404        let tree = harness.get_inspect_data_tree();
405        assert_data_tree!(
406            @executor harness.exec,
407            tree,
408            root: contains {
409                test_stats: contains {
410                    metadata: {
411                        link_properties: {
412                            index: {
413                                "0": "has_address",
414                                "1": "has_default_route",
415                                "2": "has_dns",
416                                "3": "has_http_reachability",
417                            },
418                        },
419                        link_state: {
420                            index: {
421                                "0": "None",
422                                "1": "Removed",
423                                "2": "Down",
424                                "3": "Up",
425                                "4": "Local",
426                                "5": "Gateway",
427                                "6": "Internet",
428                            },
429                        },
430                    },
431                    link_properties_state: contains {
432                        link_properties_v4_TYPE_ethernet: {
433                            "type": "bitset",
434                            "data": AnyBytesProperty,
435                            metadata: {
436                                index_node_path: "root/test_stats/metadata/link_properties",
437                            }
438                        },
439                        link_properties_v6_TYPE_ethernet: {
440                            "type": "bitset",
441                            "data": AnyBytesProperty,
442                            metadata: {
443                                index_node_path: "root/test_stats/metadata/link_properties",
444                            }
445                        },
446                        link_state_v4_TYPE_ethernet: {
447                            "type": "bitset",
448                            "data": AnyBytesProperty,
449                            metadata: {
450                                index_node_path: "root/test_stats/metadata/link_state",
451                            }
452                        },
453                        link_state_v6_TYPE_ethernet: {
454                            "type": "bitset",
455                            "data": AnyBytesProperty,
456                            metadata: {
457                                index_node_path: "root/test_stats/metadata/link_state",
458                            }
459                        }
460                    }
461                }
462            }
463        )
464    }
465
466    #[fuchsia::test]
467    fn test_log_link_properties() {
468        let harness = setup_test();
469
470        let link_properties_state = LinkPropertiesStateLogger::new(
471            &harness.inspect_metadata_node,
472            &harness.inspect_metadata_path,
473            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
474            &harness.mock_time_matrix_client,
475        );
476
477        // Update the link properties with an interface type not present in the types
478        // provided to `LinkPropertiesStateLogger`.
479        link_properties_state.update_link_properties(
480            vec![InterfaceIdentifier::Type(InterfaceType::WlanClient)],
481            &IpVersions {
482                ipv4: LinkProperties { has_address: true, ..Default::default() },
483                ipv6: LinkProperties::default(),
484            },
485        );
486
487        // There should be no calls to the `TYPE_ethernet` time series since the
488        // update above was for `WlanClient`. There should be no calls to the
489        // `TYPE_wlanclient` field either since they were not initialized.
490        let mut time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
491        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..], &[]);
492        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..], &[]);
493        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_wlanclient")[..], &[]);
494        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_wlanclient")[..], &[]);
495
496        // Update the link properties with a present interface type.
497        link_properties_state.update_link_properties(
498            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
499            &IpVersions {
500                ipv4: LinkProperties { has_address: true, ..Default::default() },
501                ipv6: LinkProperties {
502                    has_address: true,
503                    has_default_route: true,
504                    ..Default::default()
505                },
506            },
507        );
508
509        time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
510        // The first bit is set for the v4 call, since `has_address` is true.
511        assert_eq!(
512            &time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..],
513            &[TimeMatrixCall::Fold(Timed::now(1 << 0)),]
514        );
515        // The first and second bit are set for the v6 call, since `has_address`
516        // and `has_default_route` are true.
517        assert_eq!(
518            &time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..],
519            &[TimeMatrixCall::Fold(Timed::now((1 << 0) | (1 << 1))),]
520        );
521
522        // Ensure that updating the same identifier with the same properties
523        // for v4 results in no calls to the v4 time matrix.
524        link_properties_state.update_link_properties(
525            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
526            &IpVersions {
527                ipv4: LinkProperties { has_address: true, ..Default::default() },
528                ipv6: LinkProperties {
529                    has_address: true,
530                    has_default_route: true,
531                    has_dns: true,
532                    ..Default::default()
533                },
534            },
535        );
536        time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
537        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..], &[]);
538        assert_eq!(
539            &time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..],
540            &[TimeMatrixCall::Fold(Timed::now((1 << 0) | (1 << 1) | (1 << 2))),]
541        );
542    }
543
544    #[fuchsia::test]
545    fn test_log_link_state() {
546        let harness = setup_test();
547
548        let link_properties_state = LinkPropertiesStateLogger::new(
549            &harness.inspect_metadata_node,
550            &harness.inspect_metadata_path,
551            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
552            &harness.mock_time_matrix_client,
553        );
554
555        // Update the link state with an interface type not present in the types
556        // provided to `LinkPropertiesStateLogger`.
557        link_properties_state.update_link_state(
558            vec![InterfaceIdentifier::Type(InterfaceType::WlanClient)],
559            &IpVersions { ipv4: Default::default(), ipv6: LinkState::Gateway },
560        );
561
562        // There should be no calls to the `TYPE_ethernet` time series since the
563        // update above was for `WlanClient`. There should be no calls to the
564        // `TYPE_wlanclient` field either since they were not initialized.
565        let mut time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
566        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..], &[]);
567        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..], &[]);
568        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_wlanclient")[..], &[]);
569        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_wlanclient")[..], &[]);
570
571        // Update the link state with a present interface type.
572        link_properties_state.update_link_state(
573            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
574            &IpVersions { ipv4: LinkState::Internet, ipv6: LinkState::Local },
575        );
576
577        time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
578        assert_eq!(
579            &time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..],
580            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Internet.to_id())),]
581        );
582        assert_eq!(
583            &time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..],
584            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Local.to_id())),]
585        );
586
587        // Ensure that updating the same identifier with the same properties
588        // for v4 results in no calls to the v4 time matrix.
589        link_properties_state.update_link_state(
590            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
591            &IpVersions { ipv4: LinkState::Internet, ipv6: LinkState::Gateway },
592        );
593        time_matrix_calls = harness.mock_time_matrix_client.drain_calls();
594        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..], &[]);
595        assert_eq!(
596            &time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..],
597            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Gateway.to_id())),]
598        );
599    }
600}