servers/otlp/metrics/
semantic.rs1use std::collections::{BTreeMap, HashMap};
28
29use table::requests::{SEMANTIC_VALUE_MIXED, SEMANTIC_VALUE_UNKNOWN, validate_semantic_option};
30
31#[derive(Debug, Default)]
33pub struct SemanticIndex {
34 tables: HashMap<String, BTreeMap<&'static str, String>>,
36}
37
38impl SemanticIndex {
39 pub fn is_empty(&self) -> bool {
40 self.tables.is_empty()
41 }
42
43 pub fn record_scalar(&mut self, table: &str, key: &'static str, value: &str) {
47 if let Some(scalars) = self.tables.get_mut(table) {
50 match scalars.get(key).map(String::as_str) {
51 Some(existing) if existing == value => {}
52 Some(SEMANTIC_VALUE_MIXED) | Some(SEMANTIC_VALUE_UNKNOWN) => {}
53 Some(_) => {
54 scalars.insert(key, collapse_value(key));
55 }
56 None => {
57 scalars.insert(key, value.to_string());
58 }
59 }
60 } else {
61 self.tables.insert(
62 table.to_string(),
63 BTreeMap::from([(key, value.to_string())]),
64 );
65 }
66 }
67
68 pub fn encode(&self) -> Option<String> {
71 if self.tables.is_empty() {
72 return None;
73 }
74 serde_json::to_string(&self.tables).ok()
75 }
76
77 #[cfg(test)]
78 fn options_of(&self, table: &str) -> Option<&BTreeMap<&'static str, String>> {
79 self.tables.get(table)
80 }
81}
82
83fn collapse_value(key: &str) -> String {
87 if validate_semantic_option(key, SEMANTIC_VALUE_MIXED) {
88 SEMANTIC_VALUE_MIXED.to_string()
89 } else {
90 SEMANTIC_VALUE_UNKNOWN.to_string()
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use table::requests::{
97 SEMANTIC_METRIC_METADATA_QUALITY, SEMANTIC_METRIC_TYPE, SEMANTIC_METRIC_UNIT,
98 };
99
100 use super::*;
101
102 #[test]
103 fn test_scalar_recording_keeps_first_then_collapses_on_conflict() {
104 let mut index = SemanticIndex::default();
105 index.record_scalar("t", SEMANTIC_METRIC_TYPE, "counter");
106 index.record_scalar("t", SEMANTIC_METRIC_TYPE, "counter");
107 assert_eq!(
108 index
109 .options_of("t")
110 .unwrap()
111 .get(SEMANTIC_METRIC_TYPE)
112 .map(String::as_str),
113 Some("counter")
114 );
115
116 index.record_scalar("t", SEMANTIC_METRIC_TYPE, "gauge");
118 assert_eq!(
119 index
120 .options_of("t")
121 .unwrap()
122 .get(SEMANTIC_METRIC_TYPE)
123 .map(String::as_str),
124 Some("mixed")
125 );
126 index.record_scalar("t", SEMANTIC_METRIC_TYPE, "histogram");
128 assert_eq!(
129 index
130 .options_of("t")
131 .unwrap()
132 .get(SEMANTIC_METRIC_TYPE)
133 .map(String::as_str),
134 Some("mixed")
135 );
136 }
137
138 #[test]
139 fn test_scalar_conflict_without_mixed_domain_collapses_to_unknown() {
140 let mut index = SemanticIndex::default();
141 index.record_scalar("t", SEMANTIC_METRIC_METADATA_QUALITY, "declared");
142 index.record_scalar("t", SEMANTIC_METRIC_METADATA_QUALITY, "inferred");
143 assert_eq!(
146 index
147 .options_of("t")
148 .unwrap()
149 .get(SEMANTIC_METRIC_METADATA_QUALITY)
150 .map(String::as_str),
151 Some("unknown")
152 );
153 }
154
155 #[test]
156 fn test_encode_is_none_when_empty_and_round_trips() {
157 let index = SemanticIndex::default();
158 assert!(index.is_empty());
159 assert_eq!(index.encode(), None);
160
161 let mut index = SemanticIndex::default();
162 index.record_scalar("metric_a", SEMANTIC_METRIC_TYPE, "counter");
163 index.record_scalar("metric_a", SEMANTIC_METRIC_UNIT, "By");
164 let json = index.encode().unwrap();
165 let parsed: BTreeMap<String, BTreeMap<String, String>> =
166 serde_json::from_str(&json).unwrap();
167 let table = parsed.get("metric_a").unwrap();
168 assert_eq!(
169 table.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
170 Some("counter")
171 );
172 assert_eq!(
173 table.get(SEMANTIC_METRIC_UNIT).map(String::as_str),
174 Some("By")
175 );
176 }
177}