Skip to main content

synwire_core/observability/
attribute_mapper.rs

1//! `OTel` attribute mapper trait and default `GenAI` implementation.
2
3use crate::observability::gen_ai;
4use serde_json::Value;
5use std::collections::HashMap;
6
7/// Trait for mapping domain-specific attributes to OpenTelemetry key-value
8/// pairs.
9pub trait OTelAttributeMapper: Send + Sync {
10    /// Maps the given attributes into OTel-compatible key-value pairs.
11    fn map_attributes(&self, input: &HashMap<String, Value>) -> Vec<(String, String)>;
12}
13
14/// Default attribute mapper that translates `GenAI` semantic convention keys.
15///
16/// Recognised input keys (matching `gen_ai::*` constants) are mapped directly.
17/// Unrecognised keys are prefixed with `synwire.custom.`.
18#[derive(Debug, Default)]
19pub struct GenAIAttributeMapper;
20
21impl GenAIAttributeMapper {
22    /// Creates a new mapper.
23    pub const fn new() -> Self {
24        Self
25    }
26}
27
28/// Known `GenAI` attribute keys.
29const KNOWN_KEYS: &[&str] = &[
30    gen_ai::OPERATION_NAME,
31    gen_ai::PROVIDER_NAME,
32    gen_ai::REQUEST_MODEL,
33    gen_ai::REQUEST_TEMPERATURE,
34    gen_ai::REQUEST_MAX_TOKENS,
35    gen_ai::RESPONSE_MODEL,
36    gen_ai::RESPONSE_FINISH_REASONS,
37    gen_ai::RESPONSE_ID,
38    gen_ai::USAGE_INPUT_TOKENS,
39    gen_ai::USAGE_OUTPUT_TOKENS,
40];
41
42impl OTelAttributeMapper for GenAIAttributeMapper {
43    fn map_attributes(&self, input: &HashMap<String, Value>) -> Vec<(String, String)> {
44        let mut out = Vec::with_capacity(input.len());
45        for (key, value) in input {
46            let otel_key = if KNOWN_KEYS.contains(&key.as_str()) {
47                key.clone()
48            } else {
49                format!("synwire.custom.{key}")
50            };
51
52            let otel_value = match value {
53                Value::String(s) => s.clone(),
54                Value::Number(n) => n.to_string(),
55                Value::Bool(b) => b.to_string(),
56                Value::Null => String::new(),
57                other => other.to_string(),
58            };
59
60            out.push((otel_key, otel_value));
61        }
62        out
63    }
64}
65
66#[cfg(test)]
67#[allow(clippy::unwrap_used)]
68mod tests {
69    use super::*;
70    use serde_json::json;
71
72    #[test]
73    fn maps_known_keys_directly() {
74        let mapper = GenAIAttributeMapper::new();
75        let mut input = HashMap::new();
76        let _ = input.insert(gen_ai::REQUEST_MODEL.to_owned(), json!("gpt-4"));
77        let _ = input.insert(gen_ai::USAGE_INPUT_TOKENS.to_owned(), json!(100));
78
79        let attrs = mapper.map_attributes(&input);
80        assert_eq!(attrs.len(), 2);
81
82        let model_attr = attrs.iter().find(|(k, _)| k == gen_ai::REQUEST_MODEL);
83        assert!(model_attr.is_some());
84        assert_eq!(model_attr.unwrap().1, "gpt-4");
85    }
86
87    #[test]
88    fn prefixes_unknown_keys() {
89        let mapper = GenAIAttributeMapper::new();
90        let mut input = HashMap::new();
91        let _ = input.insert("my.custom.key".to_owned(), json!("value"));
92
93        let attrs = mapper.map_attributes(&input);
94        assert_eq!(attrs.len(), 1);
95        assert_eq!(attrs[0].0, "synwire.custom.my.custom.key");
96    }
97
98    #[test]
99    fn handles_various_value_types() {
100        let mapper = GenAIAttributeMapper::new();
101        let mut input = HashMap::new();
102        let _ = input.insert(gen_ai::REQUEST_TEMPERATURE.to_owned(), json!(0.7));
103        let _ = input.insert("flag".to_owned(), json!(true));
104        let _ = input.insert("empty".to_owned(), json!(null));
105
106        let attrs = mapper.map_attributes(&input);
107        assert_eq!(attrs.len(), 3);
108    }
109}