Skip to main content

khive_runtime/
telemetry_config.rs

1use serde::{Deserialize, Serialize};
2
3use crate::engine_config::ConfigError;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "lowercase")]
7pub enum TelemetryCarrier {
8    Durable,
9    Ephemeral,
10}
11
12impl TelemetryCarrier {
13    pub const fn as_str(self) -> &'static str {
14        match self {
15            Self::Durable => "durable",
16            Self::Ephemeral => "ephemeral",
17        }
18    }
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum TelemetryFailurePosture {
24    Stop,
25    Gap,
26}
27
28impl TelemetryFailurePosture {
29    pub const fn as_str(self) -> &'static str {
30        match self {
31            Self::Stop => "stop",
32            Self::Gap => "gap",
33        }
34    }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38pub struct TelemetryPolicy {
39    pub classified: bool,
40    pub carrier: TelemetryCarrier,
41    pub failure_posture: TelemetryFailurePosture,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct TelemetryChannelConfig {
46    pub kinds: Vec<String>,
47    pub carrier: TelemetryCarrier,
48    pub failure_posture: TelemetryFailurePosture,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(try_from = "RawTelemetryConfig")]
53pub struct TelemetryConfig {
54    pub stream: String,
55    pub default_carrier: Option<TelemetryCarrier>,
56    pub channels: Vec<TelemetryChannelConfig>,
57}
58
59impl Default for TelemetryConfig {
60    fn default() -> Self {
61        Self {
62            stream: default_stream(),
63            default_carrier: None,
64            channels: Vec::new(),
65        }
66    }
67}
68
69impl TelemetryConfig {
70    pub fn validate(&self) -> Result<(), ConfigError> {
71        if self.stream.len() > 512 || self.stream.contains('\0') {
72            return Err(invalid(
73                "telemetry.stream",
74                "must be at most 512 UTF-8 bytes and contain no U+0000",
75            ));
76        }
77        for (index, channel) in self.channels.iter().enumerate() {
78            let entry = format!("telemetry.channels[{index}]");
79            if channel.kinds.is_empty() {
80                return Err(invalid(&entry, "kinds must not be empty"));
81            }
82            for kind in &channel.kinds {
83                let suffix = kind.strip_prefix("*.");
84                if kind.is_empty()
85                    || kind.chars().any(|c| c.is_whitespace() || c.is_control())
86                    || (kind.contains('*')
87                        && !suffix.is_some_and(|s| !s.is_empty() && !s.contains('*')))
88                {
89                    return Err(invalid(
90                        &entry,
91                        format!(
92                            "invalid kind pattern {kind:?}; expected a nonempty exact name or *.suffix without whitespace or control characters"
93                        ),
94                    ));
95                }
96                for (previous_index, previous) in self.channels[..index].iter().enumerate() {
97                    for previous_kind in &previous.kinds {
98                        if patterns_overlap(kind, previous_kind) {
99                            return Err(invalid(
100                                &entry,
101                                format!(
102                                    "kind pattern {kind:?} overlaps {previous_kind:?} in telemetry.channels[{previous_index}]"
103                                ),
104                            ));
105                        }
106                    }
107                }
108            }
109        }
110        Ok(())
111    }
112
113    pub fn validate_activation(&self) -> Result<(), ConfigError> {
114        self.declared_default()?;
115        self.validate()
116    }
117
118    fn declared_default(&self) -> Result<TelemetryCarrier, ConfigError> {
119        self.default_carrier.ok_or_else(|| {
120            invalid(
121                "telemetry.default_carrier",
122                "must be explicitly declared when the telemetry pack is loaded",
123            )
124        })
125    }
126
127    pub fn policy_for_kind(&self, kind: &str) -> Result<TelemetryPolicy, ConfigError> {
128        let default_carrier = self.declared_default()?;
129        for channel in &self.channels {
130            if channel
131                .kinds
132                .iter()
133                .any(|pattern| pattern_matches(pattern, kind))
134            {
135                return Ok(TelemetryPolicy {
136                    classified: true,
137                    carrier: channel.carrier,
138                    failure_posture: channel.failure_posture,
139                });
140            }
141        }
142        Ok(TelemetryPolicy {
143            classified: false,
144            carrier: default_carrier,
145            failure_posture: match default_carrier {
146                TelemetryCarrier::Durable => TelemetryFailurePosture::Stop,
147                TelemetryCarrier::Ephemeral => TelemetryFailurePosture::Gap,
148            },
149        })
150    }
151}
152
153fn pattern_matches(pattern: &str, kind: &str) -> bool {
154    match pattern.strip_prefix('*') {
155        Some(suffix) => kind.ends_with(suffix),
156        None => pattern == kind,
157    }
158}
159
160fn patterns_overlap(first: &str, second: &str) -> bool {
161    match (first.strip_prefix('*'), second.strip_prefix('*')) {
162        (Some(first), Some(second)) => first.ends_with(second) || second.ends_with(first),
163        (Some(_), None) => pattern_matches(first, second),
164        (None, Some(_)) => pattern_matches(second, first),
165        (None, None) => first == second,
166    }
167}
168
169fn invalid(entry: impl Into<String>, reason: impl Into<String>) -> ConfigError {
170    ConfigError::InvalidTelemetryConfig {
171        entry: entry.into(),
172        reason: reason.into(),
173    }
174}
175
176fn default_stream() -> String {
177    "telemetry".to_string()
178}
179
180// Decode policy values with the enclosing channel index available for diagnostics.
181#[derive(Deserialize)]
182#[serde(deny_unknown_fields)]
183struct RawTelemetryConfig {
184    #[serde(default = "default_stream")]
185    stream: String,
186    #[serde(default)]
187    default_carrier: Option<String>,
188    #[serde(default)]
189    channels: Vec<RawTelemetryChannelConfig>,
190}
191
192#[derive(Deserialize)]
193#[serde(deny_unknown_fields)]
194struct RawTelemetryChannelConfig {
195    kinds: Vec<String>,
196    carrier: String,
197    failure_posture: String,
198}
199
200fn parse_carrier(value: &str, entry: &str) -> Result<TelemetryCarrier, ConfigError> {
201    match value {
202        "durable" => Ok(TelemetryCarrier::Durable),
203        "ephemeral" => Ok(TelemetryCarrier::Ephemeral),
204        _ => Err(invalid(
205            entry,
206            format!("unknown carrier {value:?}; expected durable or ephemeral"),
207        )),
208    }
209}
210
211impl TryFrom<RawTelemetryConfig> for TelemetryConfig {
212    type Error = ConfigError;
213
214    fn try_from(raw: RawTelemetryConfig) -> Result<Self, Self::Error> {
215        let default_carrier = raw
216            .default_carrier
217            .as_deref()
218            .map(|value| parse_carrier(value, "telemetry.default_carrier"))
219            .transpose()?;
220        let mut channels = Vec::with_capacity(raw.channels.len());
221        for (index, channel) in raw.channels.into_iter().enumerate() {
222            let entry = format!("telemetry.channels[{index}]");
223            let carrier = parse_carrier(&channel.carrier, &entry)?;
224            let failure_posture = match channel.failure_posture.as_str() {
225                "stop" => TelemetryFailurePosture::Stop,
226                "gap" => TelemetryFailurePosture::Gap,
227                value => {
228                    return Err(invalid(
229                        &entry,
230                        format!("unknown failure_posture {value:?}; expected stop or gap"),
231                    ));
232                }
233            };
234            channels.push(TelemetryChannelConfig {
235                kinds: channel.kinds,
236                carrier,
237                failure_posture,
238            });
239        }
240        let config = Self {
241            stream: raw.stream,
242            default_carrier,
243            channels,
244        };
245        config.validate()?;
246        Ok(config)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn missing_default_is_structurally_valid_but_cannot_activate() {
256        for text in ["", "stream = \"events\"\n"] {
257            let missing: TelemetryConfig = toml::from_str(text).unwrap();
258            assert_eq!(missing.default_carrier, None);
259            missing.validate().unwrap();
260            assert!(missing
261                .validate_activation()
262                .unwrap_err()
263                .to_string()
264                .contains("telemetry.default_carrier"));
265            assert!(missing.policy_for_kind("unclassified").is_err());
266            assert_eq!(
267                serde_json::from_value::<TelemetryConfig>(serde_json::to_value(&missing).unwrap())
268                    .unwrap(),
269                missing
270            );
271        }
272        for carrier in ["durable", "ephemeral"] {
273            let declared: TelemetryConfig =
274                toml::from_str(&format!("default_carrier = {carrier:?}")).unwrap();
275            declared.validate_activation().unwrap();
276            assert!(!declared.policy_for_kind("unclassified").unwrap().classified);
277        }
278    }
279
280    #[test]
281    fn classification_tracks_matching_even_when_the_carrier_equals_the_default() {
282        for carrier in [TelemetryCarrier::Durable, TelemetryCarrier::Ephemeral] {
283            let config = TelemetryConfig {
284                default_carrier: Some(carrier),
285                channels: vec![TelemetryChannelConfig {
286                    kinds: vec!["*.heartbeat".into(), "run.started".into()],
287                    carrier,
288                    failure_posture: TelemetryFailurePosture::Stop,
289                }],
290                ..TelemetryConfig::default()
291            };
292            let round_trip: TelemetryConfig =
293                serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap();
294            for kind in ["run.heartbeat", "run.started", "unclassified"] {
295                let policy = config.policy_for_kind(kind).unwrap();
296                assert_eq!(policy.carrier, carrier);
297                assert_eq!(policy.classified, kind != "unclassified");
298                assert_eq!(round_trip.policy_for_kind(kind).unwrap(), policy);
299            }
300        }
301    }
302
303    #[test]
304    fn directly_constructed_config_rejects_ambiguous_channels() {
305        let mut config = TelemetryConfig {
306            channels: vec![
307                TelemetryChannelConfig {
308                    kinds: vec!["*.heartbeat".to_string()],
309                    carrier: TelemetryCarrier::Ephemeral,
310                    failure_posture: TelemetryFailurePosture::Gap,
311                },
312                TelemetryChannelConfig {
313                    kinds: vec!["run.heartbeat".to_string()],
314                    carrier: TelemetryCarrier::Durable,
315                    failure_posture: TelemetryFailurePosture::Stop,
316                },
317            ],
318            ..TelemetryConfig::default()
319        };
320        let error = config.validate().expect_err("overlap must refuse");
321        assert!(matches!(
322            error,
323            ConfigError::InvalidTelemetryConfig { entry, .. }
324                if entry == "telemetry.channels[1]"
325        ));
326
327        config.channels[1].kinds.clear();
328        let error = config.validate().expect_err("empty channel must refuse");
329        assert!(
330            error.to_string().contains("telemetry.channels[1]"),
331            "{error}"
332        );
333    }
334
335    #[test]
336    fn disjoint_suffixes_and_same_channel_patterns_remain_valid() {
337        let config: TelemetryConfig = toml::from_str(
338            r#"
339default_carrier = "durable"
340[[channels]]
341kinds = ["*.heartbeat", "run.heartbeat"]
342carrier = "ephemeral"
343failure_posture = "gap"
344[[channels]]
345kinds = ["*.notheartbeat", "heartbeat"]
346carrier = "durable"
347failure_posture = "stop"
348"#,
349        )
350        .expect("distinct suffixes have no common kind");
351        assert_eq!(
352            config.policy_for_kind("run.heartbeat").unwrap().carrier,
353            TelemetryCarrier::Ephemeral
354        );
355        assert_eq!(
356            config.policy_for_kind("run.notheartbeat").unwrap().carrier,
357            TelemetryCarrier::Durable
358        );
359        assert_eq!(
360            config.policy_for_kind("heartbeat").unwrap().carrier,
361            TelemetryCarrier::Durable
362        );
363    }
364
365    #[test]
366    fn config_serializes_effective_policy_and_round_trips() {
367        let config = TelemetryConfig {
368            default_carrier: Some(TelemetryCarrier::Ephemeral),
369            channels: vec![TelemetryChannelConfig {
370                kinds: vec!["run.started".to_string()],
371                carrier: TelemetryCarrier::Durable,
372                failure_posture: TelemetryFailurePosture::Stop,
373            }],
374            ..TelemetryConfig::default()
375        };
376        let value = serde_json::to_value(&config).unwrap();
377        assert_eq!(value["stream"], "telemetry");
378        assert_eq!(value["default_carrier"], "ephemeral");
379        assert_eq!(value["channels"][0]["carrier"], "durable");
380        assert_eq!(value["channels"][0]["failure_posture"], "stop");
381        assert_eq!(
382            serde_json::from_value::<TelemetryConfig>(value).unwrap(),
383            config
384        );
385    }
386}