Skip to main content

nemo_relay/observability/
mod.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optional observability integrations for NeMo Relay Core.
5
6use crate::api::event::EventNormalizationExt;
7use serde::{Deserialize, Serialize};
8
9/// Copies a projected OTLP attribute to a second attribute name.
10///
11/// `key` names the fully-qualified projected attribute and `alias` names the
12/// additional attribute to emit with the same typed value.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15pub struct OtlpAttributeMapping {
16    /// Fully-qualified projected attribute to copy.
17    pub key: String,
18    /// Additional attribute name receiving the copied value.
19    pub alias: String,
20}
21
22impl OtlpAttributeMapping {
23    /// Creates an attribute mapping.
24    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/// Export representation for point-in-time mark events.
49///
50/// Marks remain canonical ATOF events regardless of this setting. Exporters
51/// apply the selected projection only when translating those events into a
52/// downstream trace format.
53#[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    /// Use each exporter’s native handling for marks.
58    #[default]
59    Inherit,
60    /// Force marks into exporter-native trace span events.
61    Event,
62    /// Render non-excluded marks as zero-duration trace child spans so
63    /// trace-tree consumers can display them directly. High-volume
64    /// `llm.chunk` marks remain exporter-native events.
65    Tool,
66}
67
68/// Semantic projection emitted by an OpenTelemetry exporter.
69#[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    /// Relay's complete lifecycle projection, including `nemo_relay.*` attributes.
74    #[default]
75    Full,
76    /// OpenTelemetry GenAI semantic conventions.
77    GenAi,
78    /// OpenInference semantic conventions.
79    #[serde(rename = "openinference")]
80    OpenInference,
81}
82
83/// Default mark names excluded from tool projection because they are emitted
84/// at high volume and are better represented as exporter-native events.
85/// Return the default mark names excluded from OpenTelemetry projections.
86pub 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
243/// Validates OTLP attribute mappings shared by exporter configuration surfaces.
244pub 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
276/// Projects only top-level JSON fields as OTLP attributes.
277///
278/// Nested objects and arrays remain JSON strings so arbitrary payloads do not
279/// create ambiguous dotted attribute paths or unbounded attribute sets.
280pub(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
298/// Adds canonical session-correlation attributes from event metadata and the
299/// active scope-stack instance.
300pub(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
336/// Serializes a value and projects its top-level JSON fields as OTLP attributes.
337pub(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
391/// Keeps the start attributes needed to resolve mappings at the end of a span.
392///
393/// The final span attributes must still take precedence over mapped aliases, so
394/// retain both mapped source keys and aliases that were already present at
395/// start. The span itself owns all other start attributes and does not need a
396/// second copy in the active-span state.
397pub(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
412/// Resolves typed aliases from a complete set of projected attributes.
413///
414/// Callers that project a span across multiple lifecycle events must pass every
415/// real span attribute so projected fields always take precedence over aliases.
416pub(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
442/// Returns whether a mark matches a configured projection exclusion.
443///
444/// Agent hook adapters may preserve the canonical event name in metadata while
445/// using a generic mark name, so both representations are matched.
446pub(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
461/// Resolves a configured mark projection for one event.
462///
463/// Exclusions only affect tool projection; all other modes retain their
464/// configured exporter-native behavior.
465pub(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    // Prefer the provider-echoed model, but fall back to the requested model
501    // when pricing does not recognize the echoed model alias.
502    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;