1use crate::api::event::EventNormalizationExt;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15pub struct OtlpAttributeMapping {
16 pub key: String,
18 pub alias: String,
20}
21
22impl OtlpAttributeMapping {
23 pub fn new(key: impl Into<String>, alias: impl Into<String>) -> Self {
25 Self {
26 key: key.into(),
27 alias: alias.into(),
28 }
29 }
30}
31
32#[cfg(test)]
33use std::sync::Mutex;
34
35#[cfg(test)]
36pub(crate) fn test_mutex() -> &'static Mutex<()> {
37 crate::shared_runtime::runtime_owner_test_mutex()
38}
39
40pub mod atif;
41pub mod atof;
42pub(crate) mod manual;
43pub(crate) mod openinference;
44pub mod otel;
45mod otel_genai;
46pub mod plugin_component;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55#[serde(rename_all = "snake_case")]
56pub enum MarkProjection {
57 #[default]
59 Inherit,
60 Event,
62 Tool,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
71#[serde(rename_all = "snake_case")]
72pub enum OpenTelemetryType {
73 #[default]
75 Full,
76 GenAi,
78 #[serde(rename = "openinference")]
80 OpenInference,
81}
82
83pub fn default_mark_exclude_names() -> Vec<String> {
87 vec!["llm.chunk".to_string()]
88}
89
90pub(crate) fn relay_trace_id(uuid: uuid::Uuid) -> opentelemetry::trace::TraceId {
91 opentelemetry::trace::TraceId::from_bytes(*uuid.as_bytes())
92}
93
94pub(crate) fn relay_span_id(uuid: uuid::Uuid) -> opentelemetry::trace::SpanId {
95 let mut bytes = [0; 8];
96 bytes.copy_from_slice(&uuid.as_bytes()[8..]);
97 opentelemetry::trace::SpanId::from_bytes(bytes)
98}
99
100pub(crate) fn push_common_optimization_attributes(
101 attributes: &mut Vec<opentelemetry::KeyValue>,
102 summary: &crate::codec::optimization::LlmOptimizationSummary,
103) {
104 push_optimization_models_and_tokens(attributes, summary);
105 push_optimization_cost(attributes, "baseline", summary.baseline_cost.as_ref());
106 push_optimization_cost(attributes, "actual", summary.actual_cost.as_ref());
107 push_optimization_savings_and_status(attributes, summary);
108 push_optimization_pricing_provenance(attributes, summary);
109}
110
111fn push_optimization_models_and_tokens(
112 attributes: &mut Vec<opentelemetry::KeyValue>,
113 summary: &crate::codec::optimization::LlmOptimizationSummary,
114) {
115 if let Some(model) = summary.baseline_model.as_ref() {
116 attributes.push(opentelemetry::KeyValue::new(
117 "nemo_relay.llm.optimization.baseline_model",
118 model.model.clone(),
119 ));
120 }
121 if let Some(model) = summary.effective_model.as_ref() {
122 attributes.push(opentelemetry::KeyValue::new(
123 "nemo_relay.llm.optimization.effective_model",
124 model.model.clone(),
125 ));
126 }
127 if let Some(tokens) = summary.tokens_saved.prompt_tokens {
128 attributes.push(opentelemetry::KeyValue::new(
129 "nemo_relay.llm.optimization.prompt_tokens_saved",
130 i64::try_from(tokens).unwrap_or(i64::MAX),
131 ));
132 }
133 if let Some(tokens) = summary.tokens_saved.total_tokens {
134 attributes.push(opentelemetry::KeyValue::new(
135 "nemo_relay.llm.optimization.total_tokens_saved",
136 i64::try_from(tokens).unwrap_or(i64::MAX),
137 ));
138 }
139}
140
141fn push_optimization_cost(
142 attributes: &mut Vec<opentelemetry::KeyValue>,
143 label: &str,
144 cost: Option<&crate::codec::response::CostEstimate>,
145) {
146 let Some(cost) = cost else {
147 return;
148 };
149 if let Some(total) = cost.total_or_component_sum() {
150 attributes.push(opentelemetry::KeyValue::new(
151 format!("nemo_relay.llm.optimization.{label}_cost"),
152 total,
153 ));
154 }
155 attributes.push(opentelemetry::KeyValue::new(
156 format!("nemo_relay.llm.optimization.{label}_cost_currency"),
157 cost.currency.clone(),
158 ));
159 if let Some(source) = cost.pricing_source.as_ref() {
160 attributes.push(opentelemetry::KeyValue::new(
161 format!("nemo_relay.llm.optimization.{label}_pricing_source"),
162 source.clone(),
163 ));
164 }
165 if let Some(as_of) = cost.pricing_as_of.as_ref() {
166 attributes.push(opentelemetry::KeyValue::new(
167 format!("nemo_relay.llm.optimization.{label}_pricing_as_of"),
168 as_of.clone(),
169 ));
170 }
171}
172
173fn push_optimization_savings_and_status(
174 attributes: &mut Vec<opentelemetry::KeyValue>,
175 summary: &crate::codec::optimization::LlmOptimizationSummary,
176) {
177 if let Some(saved) = summary.estimated_cost_saved {
178 attributes.push(opentelemetry::KeyValue::new(
179 "nemo_relay.llm.optimization.estimated_cost_saved",
180 saved,
181 ));
182 if let Some(currency) = summary.currency.as_ref() {
183 attributes.push(opentelemetry::KeyValue::new(
184 "nemo_relay.llm.optimization.estimated_cost_saved_currency",
185 currency.clone(),
186 ));
187 }
188 }
189 if let Some(currency) = summary.currency.as_ref() {
190 attributes.push(opentelemetry::KeyValue::new(
191 "nemo_relay.llm.optimization.currency",
192 currency.clone(),
193 ));
194 }
195 let status = match summary.status {
196 crate::codec::optimization::LlmOptimizationSummaryStatus::Complete => "complete",
197 crate::codec::optimization::LlmOptimizationSummaryStatus::Partial => "partial",
198 };
199 attributes.push(opentelemetry::KeyValue::new(
200 "nemo_relay.llm.optimization.status",
201 status,
202 ));
203}
204
205fn push_optimization_pricing_provenance(
206 attributes: &mut Vec<opentelemetry::KeyValue>,
207 summary: &crate::codec::optimization::LlmOptimizationSummary,
208) {
209 let source = summary
210 .baseline_cost
211 .as_ref()
212 .and_then(|cost| cost.pricing_source.as_ref())
213 .or_else(|| {
214 summary
215 .actual_cost
216 .as_ref()
217 .and_then(|cost| cost.pricing_source.as_ref())
218 });
219 if let Some(source) = source {
220 attributes.push(opentelemetry::KeyValue::new(
221 "nemo_relay.llm.optimization.pricing_source",
222 source.clone(),
223 ));
224 }
225 let as_of = summary
226 .baseline_cost
227 .as_ref()
228 .and_then(|cost| cost.pricing_as_of.as_ref())
229 .or_else(|| {
230 summary
231 .actual_cost
232 .as_ref()
233 .and_then(|cost| cost.pricing_as_of.as_ref())
234 });
235 if let Some(as_of) = as_of {
236 attributes.push(opentelemetry::KeyValue::new(
237 "nemo_relay.llm.optimization.pricing_as_of",
238 as_of.clone(),
239 ));
240 }
241}
242
243pub fn validate_attribute_mappings(
245 mappings: &[OtlpAttributeMapping],
246) -> std::result::Result<(), String> {
247 let mut aliases = std::collections::HashSet::new();
248 for mapping in mappings {
249 if is_blank_attribute_mapping_name(&mapping.key) {
250 return Err("attribute mapping key must not be blank".to_string());
251 }
252 if is_blank_attribute_mapping_name(&mapping.alias) {
253 return Err("attribute mapping alias must not be blank".to_string());
254 }
255 if !aliases.insert(mapping.alias.trim()) {
256 return Err(format!(
257 "attribute mapping alias {:?} is duplicated",
258 mapping.alias
259 ));
260 }
261 }
262 Ok(())
263}
264
265fn is_blank_attribute_mapping_name(value: &str) -> bool {
266 value.chars().all(|character| {
267 character.is_whitespace()
268 || matches!(
269 unicode_general_category::get_general_category(character),
270 unicode_general_category::GeneralCategory::Control
271 | unicode_general_category::GeneralCategory::Format
272 )
273 })
274}
275
276pub(crate) fn push_top_level_json_attributes(
281 attributes: &mut Vec<opentelemetry::KeyValue>,
282 prefix: &str,
283 value: Option<&crate::json::Json>,
284) {
285 let Some(value) = value else {
286 return;
287 };
288 match value {
289 crate::json::Json::Object(values) => {
290 for (field, value) in values {
291 push_top_level_json_value(attributes, &format!("{prefix}.{field}"), value);
292 }
293 }
294 value => push_top_level_json_value(attributes, prefix, value),
295 }
296}
297
298pub(crate) fn push_session_identity_attributes(
301 attributes: &mut Vec<opentelemetry::KeyValue>,
302 event: &crate::api::event::Event,
303) {
304 use opentelemetry::KeyValue;
305
306 let metadata = event.metadata();
307 if let Some(session_id) = metadata
308 .and_then(|value| value.get("session_id"))
309 .and_then(crate::json::Json::as_str)
310 {
311 attributes.push(KeyValue::new("session.id", session_id.to_string()));
312 }
313 if let Some(user_id) = metadata
314 .and_then(|value| value.get("user_id"))
315 .and_then(crate::json::Json::as_str)
316 {
317 attributes.push(KeyValue::new("user.id", user_id.to_string()));
318 }
319 if let Some(agent_kind) = metadata
320 .and_then(|value| value.get("agent_kind"))
321 .and_then(crate::json::Json::as_str)
322 {
323 attributes.push(KeyValue::new(
324 "nemo_relay.agent.kind",
325 agent_kind.to_string(),
326 ));
327 }
328 if let Ok(stack) = crate::api::runtime::current_scope_stack().read() {
329 attributes.push(KeyValue::new(
330 "nemo_relay.session.instance_id",
331 stack.root_uuid().to_string(),
332 ));
333 }
334}
335
336pub(crate) fn push_serialized_top_level_attributes<T: Serialize + ?Sized>(
338 attributes: &mut Vec<opentelemetry::KeyValue>,
339 prefix: &str,
340 value: Option<&T>,
341) {
342 let Some(value) = value else {
343 return;
344 };
345 if let Ok(value) = serde_json::to_value(value) {
346 push_top_level_json_attributes(attributes, prefix, Some(&value));
347 }
348}
349
350fn push_top_level_json_value(
351 attributes: &mut Vec<opentelemetry::KeyValue>,
352 key: &str,
353 value: &crate::json::Json,
354) {
355 use opentelemetry::KeyValue;
356
357 match value {
358 crate::json::Json::Null => {}
359 crate::json::Json::Bool(value) => attributes.push(KeyValue::new(key.to_string(), *value)),
360 crate::json::Json::String(value) => {
361 attributes.push(KeyValue::new(key.to_string(), value.clone()))
362 }
363 crate::json::Json::Number(value) => {
364 if let Some(value) = value.as_i64() {
365 attributes.push(KeyValue::new(key.to_string(), value));
366 } else if let Some(value) = value.as_u64() {
367 if let Ok(value) = i64::try_from(value) {
368 attributes.push(KeyValue::new(key.to_string(), value));
369 } else {
370 attributes.push(KeyValue::new(key.to_string(), value.to_string()));
371 }
372 } else if let Some(value) = value.as_f64() {
373 attributes.push(KeyValue::new(key.to_string(), value));
374 }
375 }
376 crate::json::Json::Array(_) | crate::json::Json::Object(_) => {
377 if let Ok(value) = serde_json::to_string(value) {
378 attributes.push(KeyValue::new(key.to_string(), value));
379 }
380 }
381 }
382}
383
384pub(crate) fn apply_attribute_mappings(
385 attributes: &mut Vec<opentelemetry::KeyValue>,
386 mappings: &[OtlpAttributeMapping],
387) {
388 attributes.extend(attribute_mapping_aliases(attributes, mappings));
389}
390
391pub(crate) fn attribute_mapping_inputs(
398 attributes: &[opentelemetry::KeyValue],
399 mappings: &[OtlpAttributeMapping],
400) -> Vec<opentelemetry::KeyValue> {
401 attributes
402 .iter()
403 .filter(|attribute| {
404 mappings.iter().any(|mapping| {
405 attribute.key.as_str() == mapping.key || attribute.key.as_str() == mapping.alias
406 })
407 })
408 .cloned()
409 .collect()
410}
411
412pub(crate) fn attribute_mapping_aliases(
417 projected_attributes: &[opentelemetry::KeyValue],
418 mappings: &[OtlpAttributeMapping],
419) -> Vec<opentelemetry::KeyValue> {
420 if mappings.is_empty() {
421 return Vec::new();
422 }
423 let existing = projected_attributes
424 .iter()
425 .map(|attribute| attribute.key.as_str().to_string())
426 .collect::<std::collections::HashSet<_>>();
427 mappings
428 .iter()
429 .filter(|mapping| !existing.contains(mapping.alias.as_str()))
430 .filter_map(|mapping| {
431 projected_attributes
432 .iter()
433 .rev()
434 .find(|attribute| attribute.key.as_str() == mapping.key)
435 .map(|attribute| {
436 opentelemetry::KeyValue::new(mapping.alias.clone(), attribute.value.clone())
437 })
438 })
439 .collect()
440}
441
442pub(crate) fn mark_name_is_excluded(
447 event: &crate::api::event::Event,
448 excluded_names: &[String],
449) -> bool {
450 excluded_names.iter().any(|name| {
451 event.name() == name
452 || event
453 .metadata()
454 .and_then(crate::json::Json::as_object)
455 .and_then(|metadata| metadata.get("hook_event_name"))
456 .and_then(crate::json::Json::as_str)
457 == Some(name.as_str())
458 })
459}
460
461pub(crate) fn effective_mark_projection(
466 event: &crate::api::event::Event,
467 projection: MarkProjection,
468 excluded_names: &[String],
469) -> MarkProjection {
470 if projection == MarkProjection::Tool && mark_name_is_excluded(event, excluded_names) {
471 MarkProjection::Inherit
472 } else {
473 projection
474 }
475}
476
477#[cfg(test)]
478#[path = "../../tests/unit/observability/exporter_parity_tests.rs"]
479mod exporter_parity_tests;
480
481pub(crate) fn estimate_cost_for_response_or_requested_model(
482 event: &crate::api::event::Event,
483 response_model: Option<&str>,
484 usage: &crate::codec::response::Usage,
485) -> Option<crate::codec::response::CostEstimate> {
486 estimate_cost_for_response_or_model(
487 Some(event.name()),
488 event.model_name(),
489 response_model,
490 usage,
491 )
492}
493
494pub(crate) fn estimate_cost_for_response_or_model(
495 provider: Option<&str>,
496 requested_model: Option<&str>,
497 response_model: Option<&str>,
498 usage: &crate::codec::response::Usage,
499) -> Option<crate::codec::response::CostEstimate> {
500 if let Some(model_name) = response_model
503 && let Some(cost) =
504 crate::codec::response::estimate_cost_for_provider(provider, model_name, usage)
505 {
506 return Some(cost);
507 }
508
509 let requested_model = requested_model?;
510 if response_model == Some(requested_model) {
511 return None;
512 }
513 crate::codec::response::estimate_cost_for_provider(provider, requested_model, usage)
514}
515
516pub(crate) fn merge_usage(
517 primary: Option<&crate::codec::response::Usage>,
518 secondary: Option<&crate::codec::response::Usage>,
519) -> Option<crate::codec::response::Usage> {
520 match (primary, secondary) {
521 (None, None) => None,
522 (None, Some(usage)) | (Some(usage), None) => Some(usage.clone()),
523 (Some(primary), Some(secondary)) => Some(crate::codec::response::Usage {
524 prompt_tokens: primary.prompt_tokens.or(secondary.prompt_tokens),
525 completion_tokens: primary.completion_tokens.or(secondary.completion_tokens),
526 total_tokens: primary.total_tokens.or(secondary.total_tokens),
527 cache_read_tokens: primary.cache_read_tokens.or(secondary.cache_read_tokens),
528 cache_write_tokens: primary.cache_write_tokens.or(secondary.cache_write_tokens),
529 cost: primary.cost.clone().or_else(|| secondary.cost.clone()),
530 }),
531 }
532}
533
534pub(crate) fn model_name_for_llm_event(event: &crate::api::event::Event) -> Option<String> {
535 if event.category().map(|category| category.as_str()) != Some("llm") {
536 return None;
537 }
538 let manual_response_model =
539 manual::model_name_from_manual_llm_output(event.output()).map(ToOwned::to_owned);
540 let manual_request_model =
541 manual::model_name_from_manual_llm_output(event.input()).map(ToOwned::to_owned);
542 event
543 .normalized_llm_response()
544 .and_then(|response| response.as_ref().model.clone())
545 .or(manual_response_model)
546 .or_else(|| event.model_name().map(ToOwned::to_owned))
547 .or_else(|| {
548 event
549 .normalized_llm_request()
550 .and_then(|request| request.as_ref().model.clone())
551 })
552 .or(manual_request_model)
553}
554
555pub(crate) fn set_span_status_from_event_metadata<S>(span: &mut S, event: &crate::api::event::Event)
556where
557 S: opentelemetry::trace::Span,
558{
559 let Some(metadata) = event.metadata() else {
560 return;
561 };
562 let Some(status_code) = metadata
563 .get("otel.status_code")
564 .and_then(crate::json::Json::as_str)
565 else {
566 return;
567 };
568
569 let status = match status_code {
570 "OK" => opentelemetry::trace::Status::Ok,
571 "ERROR" => opentelemetry::trace::Status::error(
572 metadata
573 .get("otel.status_description")
574 .and_then(crate::json::Json::as_str)
575 .unwrap_or_default()
576 .to_string(),
577 ),
578 "UNSET" => opentelemetry::trace::Status::Unset,
579 _ => {
580 log::warn!(
581 target: "nemo_relay.observability",
582 event = "invalid_status_code",
583 status_code = "invalid";
584 "Unrecognized OpenTelemetry status code; using unset status"
585 );
586 opentelemetry::trace::Status::Unset
587 }
588 };
589 span.set_status(status);
590}
591
592#[cfg(test)]
593#[path = "../../tests/unit/observability/attribute_projection_tests.rs"]
594mod attribute_projection_tests;
595
596#[cfg(test)]
597#[path = "../../tests/unit/observability/mod_tests.rs"]
598mod tests;