1use std::collections::{BTreeMap, HashMap};
30
31use table::requests::{SEMANTIC_VALUE_MIXED, SEMANTIC_VALUE_UNKNOWN, validate_semantic_option};
32
33pub const METRIC_TYPE_COUNTER: &str = "counter";
37pub const METRIC_TYPE_UPDOWN_COUNTER: &str = "updown_counter";
38pub const METRIC_TYPE_GAUGE: &str = "gauge";
39pub const METRIC_TYPE_HISTOGRAM: &str = "histogram";
40pub const METRIC_TYPE_GAUGE_HISTOGRAM: &str = "gauge_histogram";
41pub const METRIC_TYPE_SUMMARY: &str = "summary";
42pub const METRIC_TYPE_INFO: &str = "info";
43pub const METRIC_TYPE_STATESET: &str = "stateset";
44
45pub fn openmetrics_unit_to_ucum(unit: &str) -> Option<&'static str> {
50 Some(match unit {
51 "seconds" => "s",
52 "celsius" => "Cel",
53 "meters" => "m",
54 "bytes" => "By",
55 "ratios" => "1",
56 "volts" => "V",
57 "amperes" => "A",
58 "joules" => "J",
59 "grams" => "g",
60 _ => return None,
61 })
62}
63
64#[derive(Debug, Default)]
66pub struct SemanticIndex {
67 tables: HashMap<String, BTreeMap<&'static str, String>>,
69}
70
71impl SemanticIndex {
72 pub fn is_empty(&self) -> bool {
73 self.tables.is_empty()
74 }
75
76 pub fn record_scalar(&mut self, table: &str, key: &'static str, value: &str) {
80 if let Some(scalars) = self.tables.get_mut(table) {
83 match scalars.get(key).map(String::as_str) {
84 Some(existing) if existing == value => {}
85 Some(SEMANTIC_VALUE_MIXED) | Some(SEMANTIC_VALUE_UNKNOWN) => {}
86 Some(_) => {
87 scalars.insert(key, collapse_value(key));
88 }
89 None => {
90 scalars.insert(key, value.to_string());
91 }
92 }
93 } else {
94 self.tables.insert(
95 table.to_string(),
96 BTreeMap::from([(key, value.to_string())]),
97 );
98 }
99 }
100
101 pub fn encode(&self, schema: &str) -> Option<String> {
105 if self.tables.is_empty() {
106 return None;
107 }
108 serde_json::to_string(&BTreeMap::from([(schema, &self.tables)])).ok()
109 }
110
111 pub(crate) fn merge_from(&mut self, other: &SemanticIndex) {
112 for (table, scalars) in &other.tables {
113 for (key, value) in scalars {
114 self.record_scalar(table, key, value);
115 }
116 }
117 }
118
119 pub(crate) fn merge_table_from(&mut self, other: &mut SemanticIndex, table: &str) {
121 if let Some(scalars) = other.tables.remove(table) {
122 for (key, value) in scalars {
123 self.record_scalar(table, key, &value);
124 }
125 }
126 }
127
128 #[cfg(test)]
129 fn options_of(&self, table: &str) -> Option<&BTreeMap<&'static str, String>> {
130 self.tables.get(table)
131 }
132}
133
134#[derive(Debug, Default)]
139pub struct SemanticIndexes {
140 default: SemanticIndex,
142 overrides: HashMap<String, SemanticIndex>,
144}
145
146impl SemanticIndexes {
147 pub fn is_empty(&self) -> bool {
148 self.default.is_empty() && self.overrides.values().all(SemanticIndex::is_empty)
149 }
150
151 pub fn index_for(&mut self, schema: Option<&str>) -> &mut SemanticIndex {
153 match schema {
154 None => &mut self.default,
155 Some(schema) => {
156 if !self.overrides.contains_key(schema) {
157 self.overrides
158 .insert(schema.to_string(), SemanticIndex::default());
159 }
160 self.overrides.get_mut(schema).expect("just inserted")
161 }
162 }
163 }
164
165 pub fn encode(&self, default_schema: &str) -> Option<String> {
170 if self.is_empty() {
171 return None;
172 }
173 let mut by_schema: BTreeMap<&str, &HashMap<String, BTreeMap<&'static str, String>>> =
174 BTreeMap::new();
175 let mut merged_default;
176 if let Some(aliased) = self.overrides.get(default_schema) {
177 merged_default = SemanticIndex::default();
178 merged_default.merge_from(&self.default);
179 merged_default.merge_from(aliased);
180 by_schema.insert(default_schema, &merged_default.tables);
181 } else if !self.default.is_empty() {
182 by_schema.insert(default_schema, &self.default.tables);
183 }
184 for (schema, index) in &self.overrides {
185 if schema != default_schema && !index.is_empty() {
186 by_schema.insert(schema, &index.tables);
187 }
188 }
189 serde_json::to_string(&by_schema).ok()
190 }
191}
192
193fn collapse_value(key: &str) -> String {
197 if validate_semantic_option(key, SEMANTIC_VALUE_MIXED) {
198 SEMANTIC_VALUE_MIXED.to_string()
199 } else {
200 SEMANTIC_VALUE_UNKNOWN.to_string()
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use table::requests::{
207 SEMANTIC_METRIC_METADATA_QUALITY, SEMANTIC_METRIC_TYPE, SEMANTIC_METRIC_UNIT,
208 };
209
210 use super::*;
211
212 type Decoded = BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>;
213
214 #[test]
215 fn test_scalar_recording_keeps_first_then_collapses_on_conflict() {
216 let mut index = SemanticIndex::default();
217 index.record_scalar("t", SEMANTIC_METRIC_TYPE, "counter");
218 index.record_scalar("t", SEMANTIC_METRIC_TYPE, "counter");
219 assert_eq!(
220 index
221 .options_of("t")
222 .unwrap()
223 .get(SEMANTIC_METRIC_TYPE)
224 .map(String::as_str),
225 Some("counter")
226 );
227
228 index.record_scalar("t", SEMANTIC_METRIC_TYPE, "gauge");
230 assert_eq!(
231 index
232 .options_of("t")
233 .unwrap()
234 .get(SEMANTIC_METRIC_TYPE)
235 .map(String::as_str),
236 Some("mixed")
237 );
238 index.record_scalar("t", SEMANTIC_METRIC_TYPE, "histogram");
240 assert_eq!(
241 index
242 .options_of("t")
243 .unwrap()
244 .get(SEMANTIC_METRIC_TYPE)
245 .map(String::as_str),
246 Some("mixed")
247 );
248 }
249
250 #[test]
251 fn test_scalar_conflict_without_mixed_domain_collapses_to_unknown() {
252 let mut index = SemanticIndex::default();
253 index.record_scalar("t", SEMANTIC_METRIC_METADATA_QUALITY, "declared");
254 index.record_scalar("t", SEMANTIC_METRIC_METADATA_QUALITY, "inferred");
255 assert_eq!(
258 index
259 .options_of("t")
260 .unwrap()
261 .get(SEMANTIC_METRIC_METADATA_QUALITY)
262 .map(String::as_str),
263 Some("unknown")
264 );
265 }
266
267 #[test]
268 fn test_encode_is_none_when_empty_and_round_trips() {
269 let index = SemanticIndex::default();
270 assert!(index.is_empty());
271 assert_eq!(index.encode("public"), None);
272
273 let mut index = SemanticIndex::default();
274 index.record_scalar("metric_a", SEMANTIC_METRIC_TYPE, "counter");
275 index.record_scalar("metric_a", SEMANTIC_METRIC_UNIT, "By");
276 let json = index.encode("public").unwrap();
277 let parsed: Decoded = serde_json::from_str(&json).unwrap();
278 let table = parsed.get("public").unwrap().get("metric_a").unwrap();
279 assert_eq!(
280 table.get(SEMANTIC_METRIC_TYPE).map(String::as_str),
281 Some("counter")
282 );
283 assert_eq!(
284 table.get(SEMANTIC_METRIC_UNIT).map(String::as_str),
285 Some("By")
286 );
287 }
288
289 #[test]
290 fn test_indexes_keep_schemas_apart_and_merge_default_alias() {
291 let mut indexes = SemanticIndexes::default();
292 assert!(indexes.is_empty());
293 assert_eq!(indexes.encode("public"), None);
294
295 indexes
297 .index_for(None)
298 .record_scalar("cpu_usage", SEMANTIC_METRIC_TYPE, "counter");
299 indexes.index_for(Some("tenant_b")).record_scalar(
300 "cpu_usage",
301 SEMANTIC_METRIC_TYPE,
302 "gauge",
303 );
304 let parsed: Decoded = serde_json::from_str(&indexes.encode("public").unwrap()).unwrap();
305 assert_eq!(
306 parsed["public"]["cpu_usage"][SEMANTIC_METRIC_TYPE],
307 "counter"
308 );
309 assert_eq!(
310 parsed["tenant_b"]["cpu_usage"][SEMANTIC_METRIC_TYPE],
311 "gauge"
312 );
313
314 indexes
317 .index_for(Some("public"))
318 .record_scalar("cpu_usage", SEMANTIC_METRIC_TYPE, "gauge");
319 let parsed: Decoded = serde_json::from_str(&indexes.encode("public").unwrap()).unwrap();
320 assert_eq!(parsed["public"]["cpu_usage"][SEMANTIC_METRIC_TYPE], "mixed");
321 }
322
323 #[test]
324 fn test_openmetrics_unit_mapping() {
325 assert_eq!(openmetrics_unit_to_ucum("seconds"), Some("s"));
326 assert_eq!(openmetrics_unit_to_ucum("bytes"), Some("By"));
327 assert_eq!(openmetrics_unit_to_ucum("ratios"), Some("1"));
328 assert_eq!(openmetrics_unit_to_ucum("requests"), None);
330 assert_eq!(openmetrics_unit_to_ucum(""), None);
331 assert_eq!(openmetrics_unit_to_ucum("By"), None);
333 }
334}