use std::collections::BTreeSet;
use etdl_parser::ast::{EtlDocument, Node};
use crate::validate::Diagnostic;
const DIAGNOSTICS_SUPPLEMENT: &str = "etdl.diagnostics";
pub const DIAGNOSTICS_SCHEMA: &str = "etdl.diagnostics/1.0";
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Correlation {
pub id: String,
#[serde(rename = "spanAttribute")]
pub span_attribute: String,
#[serde(rename = "spanValue")]
pub span_value: String,
#[serde(rename = "causeRef")]
pub cause_ref: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct AnomalyRule {
pub id: String,
pub monitors: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct DiagnosticsData {
pub correlations: Vec<Correlation>,
pub anomaly_rules: Vec<AnomalyRule>,
}
pub fn parse_and_validate_diagnostics(doc: &EtlDocument) -> (DiagnosticsData, Vec<Diagnostic>) {
let mut diagnostics = Vec::new();
let mut data = DiagnosticsData::default();
if !crate::validate::declares_supplement(doc, DIAGNOSTICS_SUPPLEMENT) {
return (data, diagnostics);
}
let Some(ext) = doc.extensions.get("x-diagnostics") else {
return (data, diagnostics);
};
if let Some(raw) = ext.get("correlations") {
match serde_yaml::from_value::<Vec<Correlation>>(raw.clone()) {
Ok(candidates) => {
let mut seen_ids = BTreeSet::new();
for correlation in candidates {
let mut has_error = false;
if !seen_ids.insert(correlation.id.clone()) {
diagnostics.push(Diagnostic::error(
"E-151",
format!("x-diagnostics: duplicate correlation id '{}'", correlation.id),
));
has_error = true;
}
if !resolve_cause_ref(doc, &correlation.cause_ref) {
diagnostics.push(Diagnostic::error(
"E-150",
format!(
"x-diagnostics: correlation '{}': causeRef '{}' does not resolve to a Gate or Basic Event",
correlation.id, correlation.cause_ref
),
));
has_error = true;
}
if !has_error {
data.correlations.push(correlation);
}
}
}
Err(e) => {
diagnostics.push(Diagnostic::error(
"E-150",
format!("x-diagnostics: invalid correlation manifest: {e}"),
));
}
}
}
if let Some(raw) = ext.get("anomalyRules") {
match serde_yaml::from_value::<Vec<AnomalyRule>>(raw.clone()) {
Ok(candidates) => {
let mut seen_ids = BTreeSet::new();
for rule in candidates {
let mut has_error = false;
if !seen_ids.insert(rule.id.clone()) {
diagnostics.push(Diagnostic::error(
"E-151",
format!("x-diagnostics: duplicate anomaly rule id '{}'", rule.id),
));
has_error = true;
}
let node = resolve_monitors_ref(doc, &rule.monitors);
if node.is_none() {
diagnostics.push(Diagnostic::error(
"E-150",
format!(
"x-diagnostics: anomaly rule '{}': monitors '{}' does not resolve to a node",
rule.id, rule.monitors
),
));
has_error = true;
}
if let Some(Node::Operation(op)) = node {
if operation_lacks_correlated_cause(op, &data.correlations) {
diagnostics.push(Diagnostic::warning(
"W-412",
format!(
"x-diagnostics: anomaly rule '{}': monitored Operation '{}' has no correlated cause on record",
rule.id, rule.monitors
),
));
}
}
if !has_error {
data.anomaly_rules.push(rule);
}
}
}
Err(e) => {
diagnostics.push(Diagnostic::error(
"E-150",
format!("x-diagnostics: invalid anomaly rule manifest: {e}"),
));
}
}
}
(data, diagnostics)
}
fn resolve_cause_ref(doc: &EtlDocument, cause_ref: &str) -> bool {
let rest = cause_ref.trim_start_matches('#');
let Some(after) = rest.strip_prefix("/faultTrees/") else {
return false;
};
match after.split('/').collect::<Vec<_>>().as_slice() {
[tree_id, "gates", gate_id] if !tree_id.is_empty() && !gate_id.is_empty() => doc
.fault_trees
.as_ref()
.and_then(|fts| fts.get(*tree_id))
.and_then(|ft| ft.gates.as_ref())
.is_some_and(|gates| gates.contains_key(*gate_id)),
[tree_id, "basicEvents", event_id] if !tree_id.is_empty() && !event_id.is_empty() => doc
.fault_trees
.as_ref()
.and_then(|fts| fts.get(*tree_id))
.is_some_and(|ft| ft.basic_events.contains_key(*event_id)),
_ => false,
}
}
fn resolve_monitors_ref<'a>(doc: &'a EtlDocument, node_ref: &str) -> Option<&'a Node> {
let rest = node_ref.trim_start_matches('#');
let after = rest.strip_prefix("/eventTrees/")?;
match after.split('/').collect::<Vec<_>>().as_slice() {
[tree_id, "nodes", node_id] if !tree_id.is_empty() && !node_id.is_empty() => {
doc.event_trees.get(*tree_id).and_then(|t| t.nodes.get(*node_id))
}
_ => None,
}
}
fn operation_lacks_correlated_cause(op: &etdl_parser::ast::Operation, correlations: &[Correlation]) -> bool {
let Some(prob_source) = &op.on_failure_probability_source else {
return true;
};
let Some(ft_id) = fault_tree_id_from_pointer(&prob_source.pointer) else {
return true;
};
!correlations
.iter()
.any(|c| fault_tree_id_from_pointer(&c.cause_ref) == Some(ft_id))
}
fn fault_tree_id_from_pointer(pointer: &str) -> Option<&str> {
let rest = pointer.trim_start_matches('#');
let after = rest.strip_prefix("/faultTrees/")?;
after.split('/').next()
}
#[derive(Debug, Default)]
pub struct DiagnosticsExtension;
impl DiagnosticsExtension {
pub fn new() -> Self {
DiagnosticsExtension
}
}
pub struct DiagnosticsResult {
pub correlations: Vec<Correlation>,
pub anomaly_rules: Vec<AnomalyRule>,
}
impl crate::extension::ExtensionResult for DiagnosticsResult {
fn extension_id(&self) -> &str {
DIAGNOSTICS_SUPPLEMENT
}
}
impl crate::extension::EtdlExtension for DiagnosticsExtension {
fn id(&self) -> &str {
DIAGNOSTICS_SUPPLEMENT
}
fn version(&self) -> &str {
"1.0"
}
fn descriptor(&self) -> crate::extension::SupplementDescriptor {
crate::extension::SupplementDescriptor {
summary: "Declared telemetry-span-to-Fault-Tree-cause correlations and \
monitored-node anomaly rules; structural metadata only, no automated \
correlation or inference.",
schema: Some(DIAGNOSTICS_SCHEMA),
diagnostic_codes: &["E-150", "E-151", "W-412"],
requires: &[],
}
}
fn validate(
&self,
doc: &EtlDocument,
_context: &crate::extension::ExtensionContext<'_>,
diagnostics: &mut Vec<Diagnostic>,
) {
let (_data, extra) = parse_and_validate_diagnostics(doc);
diagnostics.extend(extra);
}
fn process(
&self,
doc: &EtlDocument,
_context: &crate::extension::ExtensionContext<'_>,
_diagnostics: &mut Vec<Diagnostic>,
) -> Box<dyn crate::extension::ExtensionResult + '_> {
let (data, _extra) = parse_and_validate_diagnostics(doc);
Box::new(DiagnosticsResult {
correlations: data.correlations,
anomaly_rules: data.anomaly_rules,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extension::{builtin_registry, EtdlExtension, ExtensionContext};
fn doc_with_diagnostics(x_diagnostics_yaml: &str) -> EtlDocument {
let yaml = format!(
r##"
etdl: "1.0.0"
info: {{ title: "T", version: "1.0.0", domain: "D" }}
supplements:
- id: etdl.diagnostics
version: "1.0"
eventTrees:
OrderFulfillment:
initiatingEvent: {{ id: I, message: "a#/m", next: RetryBarrier }}
nodes:
RetryBarrier:
type: barrier
branches:
- outcome: SUCCESS
condition: default
probability: 1.0
next: ProcessPaymentOperation
ProcessPaymentOperation:
type: operation
action: execute
handler: "h"
next: C
onFailureProbabilitySource: "#/faultTrees/PaymentGatewayFailure/topEvent"
C: {{ type: consequence, operation: terminate }}
faultTrees:
PaymentGatewayFailure:
topEvent: {{ id: Top, description: "t", rootCause: GatewayUnreachable }}
basicEvents:
GatewayUnreachable: {{ description: "d", probability: 0.01 }}
x-diagnostics:
{x_diagnostics_yaml}
"##
);
serde_yaml::from_str(&yaml).unwrap()
}
#[test]
fn diagnostics_extension_is_registered_and_built_in() {
let registry = builtin_registry();
assert!(registry.contains(DIAGNOSTICS_SUPPLEMENT));
assert!(registry.list().contains(&DIAGNOSTICS_SUPPLEMENT));
}
#[test]
fn document_without_x_diagnostics_has_no_diagnostics() {
let yaml = r#"
etdl: "1.0.0"
info: { title: "T", version: "1.0.0", domain: "D" }
eventTrees:
T:
initiatingEvent: { id: I, message: "a#/m", next: C }
nodes:
C: { type: consequence, operation: terminate }
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let (data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(data.correlations.is_empty());
assert!(data.anomaly_rules.is_empty());
assert!(diagnostics.is_empty());
}
#[test]
fn valid_correlation_and_correlated_anomaly_rule_have_no_diagnostics() {
let doc = doc_with_diagnostics(
r##" correlations:
- id: gateway-timeout-correlation
spanAttribute: "etdl.node.id"
spanValue: "ProcessPaymentOperation"
causeRef: "#/faultTrees/PaymentGatewayFailure/basicEvents/GatewayUnreachable"
anomalyRules:
- id: payment-operation-anomaly
monitors: "#/eventTrees/OrderFulfillment/nodes/ProcessPaymentOperation"
"##,
);
let (data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(diagnostics.is_empty(), "unexpected: {diagnostics:?}");
assert_eq!(data.correlations.len(), 1);
assert_eq!(data.anomaly_rules.len(), 1);
}
#[test]
fn missing_correlations_and_anomaly_rules_keys_are_not_an_error() {
let doc = doc_with_diagnostics(" {}");
let (data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(data.correlations.is_empty());
assert!(data.anomaly_rules.is_empty());
assert!(diagnostics.is_empty());
}
#[test]
fn malformed_correlations_produces_e150() {
let doc = doc_with_diagnostics(" correlations: \"oops\"");
let (data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(data.correlations.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-150"));
}
#[test]
fn unresolvable_cause_ref_produces_e150() {
let doc = doc_with_diagnostics(
r##" correlations:
- id: c1
spanAttribute: "etdl.node.id"
spanValue: "x"
causeRef: "#/faultTrees/PaymentGatewayFailure/basicEvents/DoesNotExist"
"##,
);
let (data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(data.correlations.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-150"));
}
#[test]
fn cause_ref_at_undeclared_gate_produces_e150() {
let doc = doc_with_diagnostics(
r##" correlations:
- id: c1
spanAttribute: "etdl.node.id"
spanValue: "x"
causeRef: "#/faultTrees/PaymentGatewayFailure/gates/DoesNotExist"
"##,
);
let (_data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(diagnostics.iter().any(|d| d.code == "E-150"));
}
#[test]
fn unresolvable_monitors_produces_e150() {
let doc = doc_with_diagnostics(
r##" anomalyRules:
- id: r1
monitors: "#/eventTrees/OrderFulfillment/nodes/DoesNotExist"
"##,
);
let (data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(data.anomaly_rules.is_empty());
assert!(diagnostics.iter().any(|d| d.code == "E-150"));
}
#[test]
fn duplicate_correlation_id_produces_e151() {
let doc = doc_with_diagnostics(
r##" correlations:
- id: dup
spanAttribute: "a"
spanValue: "x"
causeRef: "#/faultTrees/PaymentGatewayFailure/basicEvents/GatewayUnreachable"
- id: dup
spanAttribute: "b"
spanValue: "y"
causeRef: "#/faultTrees/PaymentGatewayFailure/basicEvents/GatewayUnreachable"
"##,
);
let (_data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(diagnostics.iter().any(|d| d.code == "E-151" && d.message.contains("duplicate correlation id")));
}
#[test]
fn duplicate_anomaly_rule_id_produces_e151() {
let doc = doc_with_diagnostics(
r##" anomalyRules:
- id: dup
monitors: "#/eventTrees/OrderFulfillment/nodes/RetryBarrier"
- id: dup
monitors: "#/eventTrees/OrderFulfillment/nodes/ProcessPaymentOperation"
"##,
);
let (_data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(diagnostics.iter().any(|d| d.code == "E-151" && d.message.contains("duplicate anomaly rule id")));
}
#[test]
fn correlation_and_anomaly_rule_may_share_an_id() {
let doc = doc_with_diagnostics(
r##" correlations:
- id: shared
spanAttribute: "a"
spanValue: "x"
causeRef: "#/faultTrees/PaymentGatewayFailure/basicEvents/GatewayUnreachable"
anomalyRules:
- id: shared
monitors: "#/eventTrees/OrderFulfillment/nodes/ProcessPaymentOperation"
"##,
);
let (_data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(!diagnostics.iter().any(|d| d.code == "E-151"));
}
#[test]
fn monitored_operation_with_no_probability_source_produces_w412() {
let doc = doc_with_diagnostics(
r##" anomalyRules:
- id: r1
monitors: "#/eventTrees/OrderFulfillment/nodes/RetryBarrier"
"##,
);
let (data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert_eq!(data.anomaly_rules.len(), 1);
assert!(!diagnostics.iter().any(|d| d.code == "W-412"));
}
#[test]
fn monitored_operation_with_uncorrelated_probability_source_produces_w412() {
let doc = doc_with_diagnostics(
r##" anomalyRules:
- id: r1
monitors: "#/eventTrees/OrderFulfillment/nodes/ProcessPaymentOperation"
"##,
);
let (data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert_eq!(data.anomaly_rules.len(), 1);
assert!(diagnostics.iter().any(|d| d.code == "W-412"));
assert!(!diagnostics.iter().any(|d| d.is_error()));
}
#[test]
fn monitored_operation_with_correlated_probability_source_has_no_w412() {
let doc = doc_with_diagnostics(
r##" correlations:
- id: c1
spanAttribute: "etdl.node.id"
spanValue: "ProcessPaymentOperation"
causeRef: "#/faultTrees/PaymentGatewayFailure/basicEvents/GatewayUnreachable"
anomalyRules:
- id: r1
monitors: "#/eventTrees/OrderFulfillment/nodes/ProcessPaymentOperation"
"##,
);
let (_data, diagnostics) = parse_and_validate_diagnostics(&doc);
assert!(diagnostics.is_empty(), "unexpected: {diagnostics:?}");
}
#[test]
fn process_returns_typed_result_with_correct_extension_id() {
let doc = doc_with_diagnostics(
r##" correlations:
- id: c1
spanAttribute: "etdl.node.id"
spanValue: "ProcessPaymentOperation"
causeRef: "#/faultTrees/PaymentGatewayFailure/basicEvents/GatewayUnreachable"
"##,
);
let ext = DiagnosticsExtension::new();
let base = std::path::Path::new(".");
let ctx = ExtensionContext::new(&doc, base);
let mut diagnostics = Vec::new();
let result = ext.process(&doc, &ctx, &mut diagnostics);
assert!(diagnostics.is_empty(), "unexpected: {diagnostics:?}");
assert_eq!(result.extension_id(), DIAGNOSTICS_SUPPLEMENT);
assert!(result.basic_event_overrides().is_empty());
}
}