1use std::{
15 borrow::Cow,
16 fmt,
17 sync::{Arc, LazyLock},
18};
19
20use saluki_common::collections::FastHashMap;
21use saluki_error::{generic_error, GenericError};
22use serde::{Deserialize, Deserializer};
23use serde_json::Value;
24
25use super::Concept;
26
27#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "lowercase")]
30pub enum Provider {
31 Datadog,
33 Otel,
35}
36
37#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "lowercase")]
44pub enum ValueType {
45 String,
47 Int64,
49 Float64,
51}
52
53#[derive(Debug, Clone, Default, Deserialize)]
59pub struct Condition {
60 #[serde(default, deserialize_with = "null_as_default")]
64 pub attribute: String,
65 #[serde(default)]
67 pub present: Option<bool>,
68 #[serde(default)]
70 pub eq: Option<String>,
71}
72
73#[derive(Debug, Clone, Deserialize)]
75pub struct TagInfo {
76 #[serde(default, deserialize_with = "null_as_default")]
78 pub name: String,
79 pub provider: Provider,
81 #[serde(default, deserialize_with = "null_as_default")]
83 pub version: String,
84 #[serde(rename = "type")]
86 pub value_type: ValueType,
87 #[serde(default, deserialize_with = "null_elements_as_default")]
91 pub when: Vec<Condition>,
92}
93
94fn null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
96where
97 D: Deserializer<'de>,
98 T: Deserialize<'de> + Default,
99{
100 Option::<T>::deserialize(deserializer).map(Option::unwrap_or_default)
101}
102
103fn null_elements_as_default<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
105where
106 D: Deserializer<'de>,
107 T: Deserialize<'de> + Default,
108{
109 let elements = Option::<Vec<Option<T>>>::deserialize(deserializer)?;
110 Ok(elements.into_iter().flatten().map(Option::unwrap_or_default).collect())
111}
112
113#[derive(Clone)]
118pub struct Registry {
119 version: String,
120 content_hash: String,
121 fingerprint: u64,
122 mappings: FastHashMap<Concept, Vec<TagInfo>>,
123}
124
125impl Registry {
126 pub fn from_json(json: &str) -> Result<Self, GenericError> {
133 Self::from_slice(json.as_bytes())
134 }
135
136 pub fn from_slice(json: &[u8]) -> Result<Self, GenericError> {
149 let (registry, mut skipped) = Self::from_slice_permissive(json)?;
150 skipped.unknown_concepts.clear();
151 if !skipped.is_empty() {
152 return Err(generic_error!(
153 "Registry JSON has parts the registry cannot represent: {}",
154 skipped
155 ));
156 }
157 Ok(registry)
158 }
159
160 pub(crate) fn from_slice_permissive(json: &[u8]) -> Result<(Self, SkipReport), GenericError> {
178 let document: Value = serde_json::from_slice(json).map_err(malformed_error)?;
179 let concepts = document.get("concepts").and_then(Value::as_object);
180 let content_hash = document
181 .pointer("/metadata/content_hash")
182 .and_then(Value::as_str)
183 .unwrap_or_default();
184 check_required(concepts.is_some_and(|c| !c.is_empty()), content_hash)?;
185
186 let mut skipped = SkipReport::default();
187 let mut mappings = FastHashMap::default();
188 for (key, mapping) in concepts.into_iter().flatten() {
189 let Some(concept) = Concept::from_name(key) else {
190 skipped.unknown_concepts.push(key.clone());
191 continue;
192 };
193 let entries: &[Value] = match mapping {
194 Value::Null => &[],
196 Value::Object(map) => match map.get("fallbacks") {
197 None | Some(Value::Null) => &[],
198 Some(Value::Array(entries)) => entries.as_slice(),
199 _ => {
200 skipped.malformed_concepts.push(key.clone());
201 continue;
202 }
203 },
204 _ => {
205 skipped.malformed_concepts.push(key.clone());
206 continue;
207 }
208 };
209 let mut fallbacks = Vec::with_capacity(entries.len());
210 for (position, entry) in entries.iter().enumerate() {
211 match TagInfo::deserialize(entry) {
212 Ok(tag) => fallbacks.push(tag),
213 Err(_) => skipped.fallbacks.push((key.clone(), position)),
214 }
215 }
216 mappings.insert(concept, fallbacks);
217 }
218
219 let registry = Self {
220 version: document
221 .get("version")
222 .and_then(Value::as_str)
223 .unwrap_or_default()
224 .to_owned(),
225 content_hash: content_hash.to_owned(),
226 fingerprint: fingerprint_for(json),
227 mappings,
228 };
229 Ok((registry, skipped))
230 }
231
232 pub fn content_hash(&self) -> &str {
237 &self.content_hash
238 }
239
240 pub fn fingerprint(&self) -> u64 {
246 self.fingerprint
247 }
248
249 pub fn get_attribute_precedence(&self, concept: Concept) -> Option<&[TagInfo]> {
252 self.mappings.get(&concept).map(Vec::as_slice)
253 }
254
255 pub fn version(&self) -> &str {
259 &self.version
260 }
261}
262
263#[derive(Debug, Default, PartialEq, Eq)]
268pub(crate) struct SkipReport {
269 unknown_concepts: Vec<String>,
271
272 malformed_concepts: Vec<String>,
274
275 fallbacks: Vec<(String, usize)>,
277}
278
279impl SkipReport {
280 pub(crate) fn is_empty(&self) -> bool {
282 self.unknown_concepts.is_empty() && self.malformed_concepts.is_empty() && self.fallbacks.is_empty()
283 }
284}
285
286impl fmt::Display for SkipReport {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 if self.is_empty() {
289 return f.write_str("nothing skipped");
290 }
291 let fallbacks: Vec<String> = self
292 .fallbacks
293 .iter()
294 .take(MAX_LISTED_KEYS)
295 .map(|(key, position)| format!("{:?}[{position}]", shorten_key(key)))
296 .collect();
297 let quoted = |keys: &[String]| -> Vec<String> {
298 keys.iter()
299 .take(MAX_LISTED_KEYS)
300 .map(|key| format!("{:?}", shorten_key(key)))
301 .collect()
302 };
303 let sections: [(&str, usize, Vec<String>); 3] = [
304 (
305 "unknown concepts",
306 self.unknown_concepts.len(),
307 quoted(&self.unknown_concepts),
308 ),
309 (
310 "malformed concepts",
311 self.malformed_concepts.len(),
312 quoted(&self.malformed_concepts),
313 ),
314 ("unrepresentable fallbacks", self.fallbacks.len(), fallbacks),
315 ];
316 let mut separator = "";
317 for (label, count, listed) in sections.into_iter().filter(|(_, count, _)| *count > 0) {
318 let more = if count > listed.len() { ", ..." } else { "" };
319 write!(f, "{separator}{label}: {count} ({}{more})", listed.join(", "))?;
320 separator = "; ";
321 }
322 Ok(())
323 }
324}
325
326const MAX_LISTED_KEYS: usize = 10;
328
329const MAX_KEY_CHARS: usize = 64;
331
332fn shorten_key(key: &str) -> Cow<'_, str> {
333 if key.chars().count() <= MAX_KEY_CHARS {
334 return Cow::Borrowed(key);
335 }
336 let mut shortened: String = key.chars().take(MAX_KEY_CHARS).collect();
337 shortened.push('…');
338 Cow::Owned(shortened)
339}
340
341fn malformed_error(e: serde_json::Error) -> GenericError {
343 generic_error!(
344 "Registry JSON is malformed at line {}, column {}.",
345 e.line(),
346 e.column()
347 )
348}
349
350fn check_required(has_concepts: bool, content_hash: &str) -> Result<(), GenericError> {
352 if !has_concepts {
353 return Err(generic_error!("Registry JSON contains no concepts."));
354 }
355 if content_hash.is_empty() {
356 return Err(generic_error!("Registry JSON is missing `metadata.content_hash`."));
357 }
358 Ok(())
359}
360
361const MAPPINGS_JSON: &str = include_str!("mappings.json");
364
365fn fingerprint_for(json: &[u8]) -> u64 {
367 use std::hash::Hasher as _;
368
369 let mut hasher = std::collections::hash_map::DefaultHasher::new();
370 hasher.write(json);
371 hasher.finish()
372}
373
374pub static EMBEDDED_REGISTRY: LazyLock<Arc<Registry>> = LazyLock::new(|| {
383 Arc::new(Registry::from_json(MAPPINGS_JSON).expect("embedded semantic mappings.json failed to load"))
384});
385
386#[cfg(any(test, feature = "test-util"))]
388pub fn registry_json(concepts: &str) -> String {
389 format!(r#"{{"version":"test","metadata":{{"content_hash":"test"}},"concepts":{concepts}}}"#)
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn from_json_accepts_when_conditions() {
398 let with_when = registry_json(
399 r#"{
400 "http.status_code": {
401 "canonical": "http.status_code",
402 "fallbacks": [
403 {"name": "rpc.response.status_code", "provider": "otel", "type": "int64",
404 "when": [{"attribute": "rpc.system", "eq": "grpc"}]}
405 ]
406 }
407 }"#,
408 );
409 let registry = Registry::from_json(&with_when).expect("registry with a `when` clause should parse");
410 let tags = registry
411 .get_attribute_precedence(Concept::HttpStatusCode)
412 .expect("concept missing");
413 assert_eq!(tags.len(), 1);
414 assert_eq!(tags[0].when.len(), 1);
415 assert_eq!(tags[0].when[0].attribute, "rpc.system");
416 assert_eq!(tags[0].when[0].eq.as_deref(), Some("grpc"));
417 assert_eq!(tags[0].when[0].present, None);
418 }
419
420 #[test]
421 fn embedded_mappings_use_when_for_grpc_fallback() {
422 let registry = &EMBEDDED_REGISTRY;
425 let tags = registry
426 .get_attribute_precedence(Concept::RpcGrpcStatusCode)
427 .expect("rpc.grpc.status_code concept missing");
428 let gated = tags.iter().filter(|t| !t.when.is_empty()).count();
429 assert!(gated >= 4, "expected at least four when-gated fallbacks, found {gated}");
430 }
431
432 #[test]
433 fn every_concept_variant_is_registered() {
434 for concept in Concept::ALL {
435 assert!(
436 EMBEDDED_REGISTRY.get_attribute_precedence(*concept).is_some(),
437 "concept {:?} (\"{}\") has no entry in mappings.json",
438 concept,
439 concept.as_str(),
440 );
441 }
442 }
443
444 #[test]
445 fn http_status_code_has_int_and_string_fallbacks() {
446 let registry = &EMBEDDED_REGISTRY;
448 let tags = registry
449 .get_attribute_precedence(Concept::HttpStatusCode)
450 .expect("http.status_code concept missing");
451
452 let has_int_status = tags
453 .iter()
454 .any(|t| t.name == "http.status_code" && t.value_type == ValueType::Int64);
455 let has_str_status = tags
456 .iter()
457 .any(|t| t.name == "http.status_code" && t.value_type == ValueType::String);
458 let has_int_response = tags
459 .iter()
460 .any(|t| t.name == "http.response.status_code" && t.value_type == ValueType::Int64);
461 let has_str_response = tags
462 .iter()
463 .any(|t| t.name == "http.response.status_code" && t.value_type == ValueType::String);
464
465 assert!(has_int_status && has_str_status && has_int_response && has_str_response);
466 }
467
468 #[test]
469 fn from_json_ignores_unknown_concepts() {
470 let json = registry_json(
471 r#"{
472 "this.is.not.a.real.concept": {"canonical": "x", "fallbacks": []},
473 "db.statement": {"fallbacks": [{"name": "db.statement", "provider": "datadog", "type": "string"}]}
474 }"#,
475 );
476 let registry = Registry::from_json(&json).expect("unknown concepts should be ignored");
477 assert_eq!(registry.mappings.len(), 1);
478 assert_eq!(
479 registry.get_attribute_precedence(Concept::DbStatement).map(<[_]>::len),
480 Some(1)
481 );
482 }
483
484 #[test]
486 fn from_slice_loads_the_embedded_mappings_extended_with_additions() {
487 let mut document: Value = serde_json::from_str(MAPPINGS_JSON).expect("embedded mappings should be JSON");
488 let concepts = document["concepts"]
489 .as_object_mut()
490 .expect("concepts should be an object");
491 concepts.insert(
492 "future.concept".to_owned(),
493 serde_json::json!({
494 "canonical": "future.concept",
495 "fallbacks": [{"name": "future.concept", "provider": "otel", "type": "string"}]
496 }),
497 );
498 let added_fallback = serde_json::json!({"name": "added.http.method", "provider": "otel", "type": "string"});
499 concepts["http.method"]["fallbacks"]
500 .as_array_mut()
501 .expect("http.method should have fallbacks")
502 .push(added_fallback);
503 let extended = document.to_string();
504
505 let registry = Registry::from_slice(extended.as_bytes()).expect("additions should not break loading");
506 for concept in Concept::ALL {
507 assert!(
508 registry.get_attribute_precedence(*concept).is_some(),
509 "concept {concept:?} should be present"
510 );
511 }
512 let added = registry
513 .get_attribute_precedence(Concept::HttpMethod)
514 .expect("http.method should be present")
515 .iter()
516 .any(|tag| tag.name == "added.http.method");
517 assert!(added, "an added fallback for a known concept should be kept");
518
519 let (_, skipped) = Registry::from_slice_permissive(extended.as_bytes()).expect("payload should load");
520 assert!(skipped.unknown_concepts.contains(&"future.concept".to_owned()));
521 assert!(skipped.malformed_concepts.is_empty() && skipped.fallbacks.is_empty());
522 }
523
524 #[test]
525 fn from_slice_rejects_an_unusable_fallback_in_the_extended_embedded_mappings() {
526 let mut document: Value = serde_json::from_str(MAPPINGS_JSON).expect("embedded mappings should be JSON");
527 document["concepts"]["http.method"]["fallbacks"]
528 .as_array_mut()
529 .expect("http.method should have fallbacks")
530 .push(serde_json::json!({"name": "future.provider", "provider": "vendor", "type": "string"}));
531 document["concepts"]["future.concept"] = serde_json::json!({"fallbacks": []});
532
533 let error = Registry::from_slice(document.to_string().as_bytes())
534 .err()
535 .expect("an unrepresentable fallback should be rejected");
536 assert!(
537 error
538 .to_string()
539 .starts_with("Registry JSON has parts the registry cannot represent: unrepresentable fallbacks: 1"),
540 "{error}"
541 );
542 }
543
544 fn parse_strict(json: &str) -> Result<Registry, GenericError> {
545 Registry::from_json(json)
546 }
547
548 fn parse_permissive(json: &str) -> Result<Registry, GenericError> {
549 Registry::from_slice_permissive(json.as_bytes()).map(|(registry, _)| registry)
550 }
551
552 type Parser = fn(&str) -> Result<Registry, GenericError>;
553
554 const PARSERS: [(&str, Parser); 2] = [("strict", parse_strict), ("permissive", parse_permissive)];
555
556 #[test]
559 fn parsers_reject_malformed_input_by_position() {
560 for (name, parse) in PARSERS {
561 let error = parse("not json").err().expect("malformed input should be rejected");
562 assert_eq!(
563 error.to_string(),
564 "Registry JSON is malformed at line 1, column 2.",
565 "{name}"
566 );
567 }
568 }
569
570 #[test]
571 fn from_json_rejects_unusable_parts_the_permissive_parser_skips() {
572 let json = registry_json(
573 r#"{
574 "http.status_code": {"fallbacks": [{"name": "http.status_code", "provider": "X", "type": "string"}]},
575 "http.method": "not an object"
576 }"#,
577 );
578 let error = Registry::from_json(&json)
579 .err()
580 .expect("unrepresentable parts should be rejected");
581 assert_eq!(
582 error.to_string(),
583 r#"Registry JSON has parts the registry cannot represent: malformed concepts: 1 ("http.method"); "#
584 .to_owned()
585 + r#"unrepresentable fallbacks: 1 ("http.status_code"[0])"#
586 );
587 }
588
589 #[test]
590 fn from_json_rejects_absent_concepts_key_as_empty() {
591 let json = r#"{"version":"0.1.0","metadata":{"content_hash":"hash-a"}}"#;
593 let error = Registry::from_json(json)
594 .err()
595 .expect("absent concepts key should be rejected");
596 assert_eq!(error.to_string(), "Registry JSON contains no concepts.");
597 }
598
599 #[test]
602 fn from_slice_parses_the_embedded_mappings() {
603 let registry = Registry::from_slice(MAPPINGS_JSON.as_bytes()).expect("embedded mappings should parse");
604 for concept in Concept::ALL {
605 assert!(
606 registry.get_attribute_precedence(*concept).is_some(),
607 "concept {concept:?} should be present"
608 );
609 }
610 }
611
612 #[test]
615 fn parsers_reject_empty_concepts() {
616 let json = r#"{"version":"0.1.0","metadata":{"content_hash":"hash-a"},"concepts":{}}"#;
617 for (name, parse) in PARSERS {
618 let error = parse(json).err().expect("empty concepts should be rejected");
619 assert_eq!(error.to_string(), "Registry JSON contains no concepts.", "{name}");
620 }
621 }
622
623 #[test]
626 fn parsers_reject_missing_content_hash() {
627 let json = r#"{"version":"0.1.0","concepts":{"db.statement":{"canonical":"db.statement","fallbacks":[
628 {"name":"db.statement","provider":"datadog","type":"string"}]}}}"#;
629 for (name, parse) in PARSERS {
630 let error = parse(json).err().expect("missing content hash should be rejected");
631 assert_eq!(
632 error.to_string(),
633 "Registry JSON is missing `metadata.content_hash`.",
634 "{name}"
635 );
636 }
637 }
638
639 #[test]
640 fn from_json_rejects_empty_content_hash() {
641 let json = r#"{"version":"0.1.0","metadata":{"content_hash":""},"concepts":{"db.statement":{
642 "canonical":"db.statement","fallbacks":[{"name":"db.statement","provider":"datadog","type":"string"}]}}}"#;
643 let error = Registry::from_json(json)
644 .err()
645 .expect("empty content hash should be rejected");
646 assert_eq!(error.to_string(), "Registry JSON is missing `metadata.content_hash`.");
647 }
648
649 #[test]
650 fn content_hash_is_the_declared_label() {
651 let document: Value = serde_json::from_str(MAPPINGS_JSON).expect("embedded mappings should be JSON");
652 let declared = document["metadata"]["content_hash"]
653 .as_str()
654 .expect("embedded mappings should declare a content hash");
655 assert_eq!(EMBEDDED_REGISTRY.content_hash(), declared);
656 }
657
658 const FINGERPRINT_BASE_JSON: &str = r#"{
660 "version":"1.0.0",
661 "git_commit":"commit-a",
662 "timestamp":"2026-01-01T00:00:00Z",
663 "metadata":{"content_hash":"same-declared-hash"},
664 "concepts":{
665 "alpha":{"canonical":"ignored.alpha","fallbacks":[
666 {"name":"alpha.one","provider":"datadog","version":"1.0","type":"string"},
667 {"name":"alpha.two","provider":"otel","type":"float64",
668 "when":[{"attribute":"span.kind","present":true,"eq":"client"}]}
669 ]},
670 "beta":{"canonical":"ignored.beta","fallbacks":[{"name":"beta.one","provider":"datadog","type":"string"}]}
671 }
672}"#;
673
674 #[track_caller]
675 fn fingerprint_of(json: &str) -> u64 {
676 let (registry, skipped) = Registry::from_slice_permissive(json.as_bytes()).expect("registry should parse");
677 assert_eq!(registry.content_hash(), "same-declared-hash");
678 assert_eq!(skipped.unknown_concepts, ["alpha", "beta"]);
679 registry.fingerprint()
680 }
681
682 #[test]
685 fn fingerprint_changes_with_parsed_mappings() {
686 let base = fingerprint_of(FINGERPRINT_BASE_JSON);
687 let cases = [
688 (
689 "fallback name",
690 FINGERPRINT_BASE_JSON.replacen(r#""name":"alpha.one""#, r#""name":"alpha.changed""#, 1),
691 ),
692 (
693 "condition",
694 FINGERPRINT_BASE_JSON.replacen(r#""eq":"client""#, r#""eq":"server""#, 1),
695 ),
696 ];
697 for (name, json) in cases {
698 assert_ne!(
699 json, FINGERPRINT_BASE_JSON,
700 "{name}: the replacement must change the payload"
701 );
702 assert_ne!(fingerprint_of(&json), base, "{name}");
703 }
704 }
705
706 #[test]
709 fn fingerprint_changes_with_json_presentation_and_metadata() {
710 let base = fingerprint_of(FINGERPRINT_BASE_JSON);
711
712 let reformatted = FINGERPRINT_BASE_JSON.replace(" ", " ");
713 assert_ne!(fingerprint_of(&reformatted), base);
714
715 let metadata_changed =
716 FINGERPRINT_BASE_JSON.replacen(r#""git_commit":"commit-a""#, r#""git_commit":"commit-b""#, 1);
717 assert_ne!(fingerprint_of(&metadata_changed), base);
718 }
719
720 #[test]
723 fn fingerprint_is_deterministic_across_loads() {
724 let want = fingerprint_of(FINGERPRINT_BASE_JSON);
725 for _ in 0..100 {
726 assert_eq!(fingerprint_of(FINGERPRINT_BASE_JSON), want);
727 }
728 }
729
730 #[test]
733 fn fingerprint_matches_for_identical_payload_bytes() {
734 let (remote, skipped) =
735 Registry::from_slice_permissive(MAPPINGS_JSON.as_bytes()).expect("embedded mappings should parse");
736 assert!(
737 skipped.malformed_concepts.is_empty() && skipped.fallbacks.is_empty(),
738 "{skipped}"
739 );
740 assert_eq!(remote.fingerprint(), EMBEDDED_REGISTRY.fingerprint());
741 }
742
743 #[test]
746 fn fingerprint_differs_when_payload_bytes_change() {
747 const DB_STATEMENT: &str = concat!(
748 r#"{"db.statement":{"canonical":"db.statement","fallbacks":["#,
749 r#"{"name":"db.statement","provider":"datadog","type":"string"}]}}"#
750 );
751 const HTTP_METHOD: &str = concat!(
752 r#"{"http.method":{"canonical":"http.method","fallbacks":["#,
753 r#"{"name":"http.method","provider":"otel","type":"string"}]}}"#
754 );
755 let payload = |version: &str, content_hash: &str, concepts: &str| {
756 format!(r#"{{"version":"{version}","metadata":{{"content_hash":"{content_hash}"}},"concepts":{concepts}}}"#)
757 };
758 let fingerprint = |json: &str| Registry::from_json(json).expect("registry should parse").fingerprint();
759
760 let base = payload("1.0.0", "hash-a", DB_STATEMENT);
761 let cases = [
762 ("version", payload("2.0.0", "hash-a", DB_STATEMENT)),
763 (
764 "mappings under the same declared hash",
765 payload("1.0.0", "hash-a", HTTP_METHOD),
766 ),
767 (
768 "declared hash over the same mappings",
769 payload("1.0.0", "hash-b", DB_STATEMENT),
770 ),
771 ];
772 for (name, changed) in cases {
773 assert_ne!(fingerprint(&changed), fingerprint(&base), "{name}");
774 }
775 }
776
777 #[test]
778 fn permissive_skips_unknown_concepts_and_keeps_known_ones() {
779 let json = registry_json(
780 r#"{
781 "alpha": {"canonical": "alpha", "fallbacks": [{"name": "a", "provider": "otel", "type": "string"}]},
782 "db.statement": {"canonical": "db.statement", "fallbacks": [
783 {"name": "db.statement", "provider": "datadog", "type": "string"}]},
784 "zeta": {"canonical": "zeta", "fallbacks": []}
785 }"#,
786 );
787 let (registry, skipped) = Registry::from_slice_permissive(json.as_bytes()).expect("known concepts should load");
788 let tags = registry
789 .get_attribute_precedence(Concept::DbStatement)
790 .expect("known concept should be kept");
791 assert_eq!(tags.len(), 1);
792 assert_eq!(tags[0].name, "db.statement");
793 assert_eq!(registry.mappings.len(), 1);
794 assert_eq!(skipped.unknown_concepts, ["alpha", "zeta"]);
795 assert!(skipped.malformed_concepts.is_empty() && skipped.fallbacks.is_empty());
796 }
797
798 #[test]
799 fn permissive_accepts_a_payload_whose_concepts_are_all_unknown() {
800 let json = registry_json(r#"{"alpha": {"canonical": "alpha", "fallbacks": []}}"#);
802 let (registry, skipped) =
803 Registry::from_slice_permissive(json.as_bytes()).expect("unknown concepts are not a rejection");
804 assert!(registry.mappings.is_empty());
805 assert_eq!(skipped.unknown_concepts, ["alpha"]);
806 }
807
808 #[test]
809 fn permissive_skips_unrepresentable_fallbacks_and_keeps_the_rest() {
810 let json = registry_json(
811 r#"{"http.status_code": {"canonical": "http.status_code", "fallbacks": [
812 {"name": "unknown.provider", "provider": "vendor", "type": "string"},
813 {"name": "kept.first", "provider": "otel", "type": "int64"},
814 {"name": "unknown.type", "provider": "otel", "type": "bool"},
815 {"name": "absent.type", "provider": "otel"},
816 {"name": 5, "provider": "otel", "type": "string"},
817 {"name": "bad.when", "provider": "otel", "type": "string", "when": [{"attribute": 5}]},
818 "not an object",
819 {"name": "kept.second", "provider": "datadog", "type": "string", "future_field": true}
820 ]}}"#,
821 );
822 let (registry, skipped) = Registry::from_slice_permissive(json.as_bytes()).expect("payload should load");
823 let names: Vec<&str> = registry
824 .get_attribute_precedence(Concept::HttpStatusCode)
825 .expect("concept should be kept")
826 .iter()
827 .map(|tag| tag.name.as_str())
828 .collect();
829 assert_eq!(names, ["kept.first", "kept.second"]);
830 let positions: Vec<usize> = skipped.fallbacks.iter().map(|(_, position)| *position).collect();
831 assert_eq!(positions, [0, 2, 3, 4, 5, 6]);
832 assert!(skipped.fallbacks.iter().all(|(key, _)| key == "http.status_code"));
833 assert!(skipped.unknown_concepts.is_empty() && skipped.malformed_concepts.is_empty());
834 }
835
836 #[test]
837 fn parsers_treat_absent_fallbacks_as_empty() {
838 let json = registry_json(r#"{"db.statement": {"canonical": "db.statement"}}"#);
839 for (name, parse) in PARSERS {
840 let registry = parse(&json).expect("absent fallbacks should parse");
841 assert_eq!(
842 registry.get_attribute_precedence(Concept::DbStatement).map(<[_]>::len),
843 Some(0),
844 "{name}"
845 );
846 }
847 }
848
849 #[test]
850 fn parsers_treat_null_fallbacks_as_empty() {
851 let json = registry_json(r#"{"db.statement": {"canonical": "db.statement", "fallbacks": null}}"#);
852 for (name, parse) in PARSERS {
853 let registry = parse(&json).expect("null fallbacks should parse");
854 assert_eq!(
855 registry.get_attribute_precedence(Concept::DbStatement).map(<[_]>::len),
856 Some(0),
857 "{name}"
858 );
859 }
860 }
861
862 #[test]
863 fn permissive_skips_malformed_concepts() {
864 let json = registry_json(
865 r#"{
866 "db.statement": "not an object",
867 "http.method": {"canonical": "http.method", "fallbacks": {"name": "http.method"}},
868 "http.route": {"canonical": "http.route", "fallbacks": [
869 {"name": "http.route", "provider": "otel", "type": "string"}]}
870 }"#,
871 );
872 let (registry, skipped) = Registry::from_slice_permissive(json.as_bytes()).expect("payload should load");
873 assert!(registry.get_attribute_precedence(Concept::DbStatement).is_none());
874 assert!(registry.get_attribute_precedence(Concept::HttpMethod).is_none());
875 assert_eq!(
876 registry.get_attribute_precedence(Concept::HttpRoute).map(<[_]>::len),
877 Some(1)
878 );
879 assert_eq!(skipped.malformed_concepts, ["db.statement", "http.method"]);
880 }
881
882 #[test]
883 fn permissive_reads_version_and_content_hash() {
884 let json = r#"{"version":"2.1.0","metadata":{"content_hash":"hash-a"},"concepts":{"alpha":{}}}"#;
885 let (registry, _) = Registry::from_slice_permissive(json.as_bytes()).expect("payload should load");
886 assert_eq!(registry.version(), "2.1.0");
887 assert_eq!(registry.content_hash(), "hash-a");
888
889 let non_string_version = r#"{"version":2,"metadata":{"content_hash":"hash-a"},"concepts":{"alpha":{}}}"#;
890 let (registry, _) =
891 Registry::from_slice_permissive(non_string_version.as_bytes()).expect("a non-string version is not fatal");
892 assert_eq!(registry.version(), "");
893 }
894
895 #[test]
896 fn permissive_errors_never_quote_skipped_parts() {
897 let without_hash = r#"{"version":"0.1.0","concepts":{"secret.concept":{"fallbacks":[
898 {"name":"secret.name","provider":"secret-provider","type":"string"}]}}}"#;
899 let empty_hash = r#"{"metadata":{"content_hash":""},"concepts":{"secret.concept":{}}}"#;
900 for json in [without_hash, empty_hash] {
901 let error = Registry::from_slice_permissive(json.as_bytes())
902 .err()
903 .expect("missing content hash should be rejected");
904 assert_eq!(error.to_string(), "Registry JSON is missing `metadata.content_hash`.");
905 }
906
907 let malformed = r#"{"concepts":{"secret.concept":{"fallbacks":[secret]}}}"#;
908 let error = Registry::from_slice_permissive(malformed.as_bytes())
909 .err()
910 .expect("malformed JSON should be rejected");
911 assert_eq!(error.to_string(), "Registry JSON is malformed at line 1, column 45.");
912 }
913
914 #[test]
915 fn skip_report_display_names_skipped_parts() {
916 let json = registry_json(
917 r#"{
918 "alpha": {},
919 "beta": {},
920 "db.statement": {"fallbacks": [{"name": "x", "provider": "vendor", "type": "string"}]},
921 "http.method": "not an object",
922 "http.route": {"fallbacks": [
923 {"name": "http.route", "provider": "otel", "type": "string"},
924 {"name": "http.route", "provider": "otel"}]}
925 }"#,
926 );
927 let (_, skipped) = Registry::from_slice_permissive(json.as_bytes()).expect("payload should load");
928 assert_eq!(
929 skipped.to_string(),
930 r#"unknown concepts: 2 ("alpha", "beta"); malformed concepts: 1 ("http.method"); "#.to_owned()
931 + r#"unrepresentable fallbacks: 2 ("db.statement"[0], "http.route"[1])"#
932 );
933 assert_eq!(SkipReport::default().to_string(), "nothing skipped");
934 }
935
936 #[test]
937 fn parsers_treat_null_mapping_as_empty() {
938 let json = registry_json(r#"{"db.statement": null}"#);
939 for (name, parse) in PARSERS {
940 let registry = parse(&json).expect("null mapping should parse");
941 assert_eq!(
942 registry.get_attribute_precedence(Concept::DbStatement).map(<[_]>::len),
943 Some(0),
944 "{name}"
945 );
946 }
947 }
948
949 #[test]
951 fn parsers_read_null_condition_as_empty() {
952 let json = registry_json(
953 r#"{"db.statement": {"fallbacks": [
954 {"name": "db.statement", "provider": "datadog", "type": "string", "when": [null]}]}}"#,
955 );
956 for (name, parse) in PARSERS {
957 let registry = parse(&json).expect("a null condition should parse");
958 let tags = registry
959 .get_attribute_precedence(Concept::DbStatement)
960 .expect("concept should be kept");
961 assert_eq!(tags.len(), 1, "{name}");
962 let [condition] = tags[0].when.as_slice() else {
963 panic!("{name}: expected one condition, got {:?}", tags[0].when);
964 };
965 assert_eq!(condition.attribute, "", "{name}");
966 assert_eq!(condition.present, None, "{name}");
967 assert_eq!(condition.eq, None, "{name}");
968 }
969 }
970
971 #[test]
972 fn parsers_read_null_version_and_when_as_defaults() {
973 let json = registry_json(
974 r#"{"db.statement": {"fallbacks": [
975 {"name": "db.statement", "provider": "datadog", "type": "string", "version": null, "when": null}]}}"#,
976 );
977 for (name, parse) in PARSERS {
978 let registry = parse(&json).expect("null version and when should parse");
979 let tags = registry
980 .get_attribute_precedence(Concept::DbStatement)
981 .expect("concept should be kept");
982 assert_eq!(tags.len(), 1, "{name}");
983 assert_eq!(tags[0].version, "", "{name}");
984 assert!(tags[0].when.is_empty(), "{name}");
985 }
986 }
987
988 #[test]
989 fn fallback_missing_or_null_name_defaults_to_empty_string() {
990 let json = registry_json(
992 r#"{"db.statement": {"fallbacks": [
993 {"provider": "datadog", "type": "string"},
994 {"name": null, "provider": "otel", "type": "string"}
995 ]}}"#,
996 );
997 let (registry, skipped) = Registry::from_slice_permissive(json.as_bytes()).expect("payload should load");
998 assert!(skipped.fallbacks.is_empty(), "{skipped}");
999 let tags = registry
1000 .get_attribute_precedence(Concept::DbStatement)
1001 .expect("concept should be kept");
1002 assert_eq!(tags.len(), 2, "both fallbacks should be kept rather than skipped");
1003 assert!(tags.iter().all(|tag| tag.name.is_empty()));
1004 }
1005
1006 #[test]
1007 fn skip_report_display_shortens_long_keys_on_a_char_boundary() {
1008 let long_key: String = "é".repeat(70);
1010 let json = registry_json(&format!(r#"{{"{long_key}": {{}}}}"#));
1011 let (_, skipped) = Registry::from_slice_permissive(json.as_bytes()).expect("payload should load");
1012 let shortened: String = long_key.chars().take(64).chain(std::iter::once('…')).collect();
1013 assert_eq!(skipped.to_string(), format!("unknown concepts: 1 ({shortened:?})"));
1014 }
1015
1016 #[test]
1017 fn parsers_read_absent_or_null_condition_attribute_as_empty() {
1018 let json = registry_json(
1019 r#"{"db.statement": {"fallbacks": [
1020 {"name": "db.statement", "provider": "datadog", "type": "string",
1021 "when": [{"eq": "grpc"}, {"attribute": null, "present": true}]}]}}"#,
1022 );
1023 for (name, parse) in PARSERS {
1024 let registry = parse(&json).expect("conditions without an attribute should parse");
1025 let when = ®istry
1026 .get_attribute_precedence(Concept::DbStatement)
1027 .expect("concept should be kept")[0]
1028 .when;
1029 assert_eq!(when.len(), 2, "{name}");
1030 assert_eq!(when[0].attribute, "", "{name}");
1031 assert_eq!(when[0].eq.as_deref(), Some("grpc"), "{name}");
1032 assert_eq!(when[1].attribute, "", "{name}");
1033 assert_eq!(when[1].present, Some(true), "{name}");
1034 }
1035 }
1036
1037 #[test]
1038 fn skip_report_display_escapes_keys_and_caps_each_list() {
1039 let unknown: Vec<String> = (0..12).map(|i| format!(r#""k{i:02}": {{}}"#)).collect();
1040 let bad_entries = [r#"{"provider": "vendor"}"#; 11].join(", ");
1041 let json = registry_json(&format!(
1042 r#"{{"a\nbreak": {{}}, {}, "db.statement": {{"fallbacks": [{bad_entries}]}}}}"#,
1043 unknown.join(", ")
1044 ));
1045 let (_, skipped) = Registry::from_slice_permissive(json.as_bytes()).expect("payload should load");
1046 let listed_unknown: Vec<String> = std::iter::once(r#""a\nbreak""#.to_owned())
1047 .chain((0..9).map(|i| format!(r#""k{i:02}""#)))
1048 .collect();
1049 let listed_fallbacks: Vec<String> = (0..10).map(|i| format!(r#""db.statement"[{i}]"#)).collect();
1050 assert_eq!(
1051 skipped.to_string(),
1052 format!(
1053 "unknown concepts: 13 ({}, ...); unrepresentable fallbacks: 11 ({}, ...)",
1054 listed_unknown.join(", "),
1055 listed_fallbacks.join(", ")
1056 )
1057 );
1058 }
1059}