Skip to main content

provide_telemetry/config/
probe.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5//! Observe this SDK's real config defaults, one environment variable at a time.
6//!
7//! Nothing here reads `spec/telemetry-api.yaml`. Applicability is determined
8//! differentially: build a config from an empty environment for the baseline,
9//! then rebuild once per variable with only that variable set. A variable this
10//! SDK parses changes the config object; one it ignores leaves it identical.
11//! The reported default and type come from the baseline config serialized
12//! through serde, so a probe can never claim support the parser does not have.
13//!
14//! `TelemetryConfig::from_map` is what makes this safe to run in-process: the
15//! comparison never touches the real environment, so the executable contract
16//! test can run alongside every other test rather than only in a subprocess.
17
18use std::collections::BTreeMap;
19use std::collections::HashMap;
20
21use serde::Serialize;
22use serde_json::Value;
23
24use super::TelemetryConfig;
25
26/// Values chosen to differ from every spec default, including valid values for
27/// validated fields — a rejected value proves the variable is read, but leaves
28/// no config object to diff.
29const PROBE_VALUES: &[&str] = &[
30    "DEBUG",
31    "json",
32    "red",
33    "3",
34    "1327",
35    "0.4271",
36    "probe-sentinel-value",
37    "false",
38    "true",
39    "http://probe.invalid:4318",
40    "probe-module=DEBUG",
41    "probe-key=probe-value",
42];
43
44/// One environment variable as observed from a real [`TelemetryConfig`].
45#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
46pub struct ProbedConfigEntry {
47    #[serde(rename = "type")]
48    pub type_name: String,
49    pub default: String,
50    pub applicable: bool,
51}
52
53impl ProbedConfigEntry {
54    fn unsupported() -> Self {
55        Self {
56            type_name: String::new(),
57            default: String::new(),
58            applicable: false,
59        }
60    }
61}
62
63/// Flatten a serialized config into dotted-path -> (rendered value, type name).
64/// Arrays and objects render as strings so comparison is by value.
65fn flatten(value: &Value, prefix: &str, out: &mut BTreeMap<String, (String, String)>) {
66    match value {
67        Value::Object(map) => {
68            for (key, nested) in map {
69                flatten(nested, &format!("{prefix}{key}."), out);
70            }
71        }
72        Value::Array(items) => {
73            let joined = items
74                .iter()
75                .map(render_scalar)
76                .collect::<Vec<_>>()
77                .join(",");
78            insert_flat(out, prefix, joined, "str");
79        }
80        Value::Bool(flag) => insert_flat(out, prefix, flag.to_string(), "bool"),
81        Value::Number(number) => {
82            let type_name = if number.is_i64() || number.is_u64() {
83                "int"
84            } else {
85                "float"
86            };
87            insert_flat(out, prefix, render_scalar(value), type_name);
88        }
89        Value::Null => insert_flat(out, prefix, String::new(), "str"),
90        Value::String(text) => insert_flat(out, prefix, text.clone(), "str"),
91    }
92}
93
94fn insert_flat(
95    out: &mut BTreeMap<String, (String, String)>,
96    prefix: &str,
97    value: String,
98    type_name: &str,
99) {
100    out.insert(
101        prefix.trim_end_matches('.').to_string(),
102        (value, type_name.to_string()),
103    );
104}
105
106/// Render a value the way a shell would have supplied it: a string is its own
107/// text, everything else is its JSON form.
108fn render_scalar(value: &Value) -> String {
109    match value {
110        Value::String(text) => text.clone(),
111        other => other.to_string(),
112    }
113}
114
115fn build(env: &HashMap<String, String>) -> Option<BTreeMap<String, (String, String)>> {
116    let config = TelemetryConfig::from_map(env).ok()?;
117    let serialized = serde_json::to_value(&config).ok()?;
118    let mut flat = BTreeMap::new();
119    flatten(&serialized, "", &mut flat);
120    Some(flat)
121}
122
123/// Express a numeric default in the units the environment variable uses.
124///
125/// An SDK may store a `..._TIMEOUT_SECONDS` value in milliseconds. Rather than
126/// hardcoding which fields are scaled, measure this SDK's own conversion factor
127/// from a known probe value and divide the baseline by it.
128fn default_in_variable_units(baseline: &str, probe_value: &str, observed: &str) -> String {
129    let (Ok(base), Ok(probed), Ok(obs)) = (
130        baseline.parse::<f64>(),
131        probe_value.parse::<f64>(),
132        observed.parse::<f64>(),
133    ) else {
134        return baseline.to_string();
135    };
136    // No zero guard needed before the division: a zero probed or observed
137    // value yields ±inf, NaN or 0.0 for scale, and every one of those is
138    // rejected below (NaN and ±inf via `fract() != 0.0`, which is NaN-true).
139    let scale = obs / probed;
140    if scale == 1.0 || scale <= 0.0 || scale.fract() != 0.0 {
141        return baseline.to_string();
142    }
143    let scaled = base / scale;
144    if scaled.fract() == 0.0 {
145        format!("{}", scaled as i64)
146    } else {
147        format!("{scaled}")
148    }
149}
150
151/// Probe one variable against the baseline, returning `None` when this SDK
152/// neither reacts to it nor rejects a value for it.
153///
154/// `probe_values` is a parameter so a test can narrow it to values this SDK
155/// only ever rejects, which is the one arm the full set cannot reach: for every
156/// variable in the contract, some probe value parses *and* changes the config
157/// before the loop runs out.
158fn probe_one(
159    baseline: &BTreeMap<String, (String, String)>,
160    env_var: &str,
161    probe_values: &[&str],
162) -> Option<ProbedConfigEntry> {
163    let mut rejected = false;
164    for probe_value in probe_values {
165        let env: HashMap<String, String> =
166            HashMap::from([(env_var.to_string(), (*probe_value).to_string())]);
167        let Some(observed) = build(&env) else {
168            // A rejected value still proves the variable is read.
169            rejected = true;
170            continue;
171        };
172
173        if let Some(key) = baseline
174            .iter()
175            .find(|(key, (value, _))| observed.get(*key).is_some_and(|(other, _)| other != value))
176            .map(|(key, _)| key)
177        {
178            let (base_value, base_type) = &baseline[key];
179            let (observed_value, _) = &observed[key];
180            return Some(ProbedConfigEntry {
181                type_name: base_type.clone(),
182                default: default_in_variable_units(base_value, probe_value, observed_value),
183                applicable: true,
184            });
185        }
186
187        // A key the probe *added* counts too: an empty map serializes to `{}`
188        // and contributes no flattened keys, so comparing only shared keys
189        // would read as "the SDK ignores this variable".
190        if observed.keys().any(|key| !baseline.contains_key(key)) {
191            return Some(ProbedConfigEntry {
192                type_name: "str".to_string(),
193                default: String::new(),
194                applicable: true,
195            });
196        }
197    }
198    rejected.then(|| ProbedConfigEntry {
199        applicable: true,
200        ..ProbedConfigEntry::unsupported()
201    })
202}
203
204/// Report what this SDK actually does with each named environment variable.
205///
206/// Used by both `rust/tests/config_applicability.rs` and the
207/// `config_probe` example that `spec/check_config_parity.py` shells out to, so
208/// the in-repo gate and the cross-language gate cannot disagree about what was
209/// measured.
210pub fn config_defaults_probe(env_vars: &[String]) -> BTreeMap<String, ProbedConfigEntry> {
211    let baseline = build(&HashMap::new()).expect("an empty environment must yield a valid config");
212    env_vars
213        .iter()
214        .map(|env_var| {
215            let entry = probe_one(&baseline, env_var, PROBE_VALUES)
216                .unwrap_or_else(ProbedConfigEntry::unsupported);
217            (env_var.clone(), entry)
218        })
219        .collect()
220}
221
222#[cfg(test)]
223#[path = "probe_tests.rs"]
224mod tests;