Skip to main content

adk_telemetry/
semconv.rs

1//! OpenTelemetry GenAI Semantic Convention attribute constants and helpers.
2//!
3//! Based on OTel Semantic Conventions v1.41.0:
4//! <https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/>
5//!
6//! This module provides:
7//! - All `gen_ai.*` attribute name constants
8//! - [`GenAiProvider`] and [`GenAiOperation`] enums
9//! - [`GenAiSpanBuilder`] fluent API for creating model call spans
10//! - [`GenAiResponseRecorder`] for recording response attributes
11//! - [`map_finish_reason`] for provider-specific finish reason mapping
12//! - [`tool_call_semconv_span`] and [`agent_run_semconv_span`] helpers
13
14use tracing::Span;
15
16use crate::LlmUsage;
17
18// --- Provider & Operation ---
19
20/// The `gen_ai.system` attribute (legacy, kept for backward compatibility).
21pub const GEN_AI_SYSTEM: &str = "gen_ai.system";
22
23/// The `gen_ai.provider.name` attribute (v1.41.0+).
24pub const GEN_AI_PROVIDER_NAME: &str = "gen_ai.provider.name";
25
26/// The `gen_ai.operation.name` attribute.
27pub const GEN_AI_OPERATION_NAME: &str = "gen_ai.operation.name";
28
29// --- Request Attributes ---
30
31/// The `gen_ai.request.model` attribute.
32pub const GEN_AI_REQUEST_MODEL: &str = "gen_ai.request.model";
33
34/// The `gen_ai.request.max_tokens` attribute.
35pub const GEN_AI_REQUEST_MAX_TOKENS: &str = "gen_ai.request.max_tokens";
36
37/// The `gen_ai.request.temperature` attribute.
38pub const GEN_AI_REQUEST_TEMPERATURE: &str = "gen_ai.request.temperature";
39
40/// The `gen_ai.request.top_p` attribute.
41pub const GEN_AI_REQUEST_TOP_P: &str = "gen_ai.request.top_p";
42
43/// The `gen_ai.request.top_k` attribute.
44pub const GEN_AI_REQUEST_TOP_K: &str = "gen_ai.request.top_k";
45
46/// The `gen_ai.request.stream` attribute.
47pub const GEN_AI_REQUEST_STREAM: &str = "gen_ai.request.stream";
48
49/// The `gen_ai.request.frequency_penalty` attribute.
50pub const GEN_AI_REQUEST_FREQUENCY_PENALTY: &str = "gen_ai.request.frequency_penalty";
51
52/// The `gen_ai.request.presence_penalty` attribute.
53pub const GEN_AI_REQUEST_PRESENCE_PENALTY: &str = "gen_ai.request.presence_penalty";
54
55// --- Response Attributes ---
56
57/// The `gen_ai.response.model` attribute.
58pub const GEN_AI_RESPONSE_MODEL: &str = "gen_ai.response.model";
59
60/// The `gen_ai.response.finish_reasons` attribute.
61pub const GEN_AI_RESPONSE_FINISH_REASONS: &str = "gen_ai.response.finish_reasons";
62
63/// The `gen_ai.response.id` attribute.
64pub const GEN_AI_RESPONSE_ID: &str = "gen_ai.response.id";
65
66// --- Token Usage ---
67
68/// The `gen_ai.usage.input_tokens` attribute.
69pub const GEN_AI_USAGE_INPUT_TOKENS: &str = "gen_ai.usage.input_tokens";
70
71/// The `gen_ai.usage.output_tokens` attribute.
72pub const GEN_AI_USAGE_OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens";
73
74/// The `gen_ai.usage.total_tokens` attribute.
75pub const GEN_AI_USAGE_TOTAL_TOKENS: &str = "gen_ai.usage.total_tokens";
76
77/// The `gen_ai.usage.cache_read_tokens` attribute.
78pub const GEN_AI_USAGE_CACHE_READ_TOKENS: &str = "gen_ai.usage.cache_read_tokens";
79
80/// The `gen_ai.usage.cache_creation_tokens` attribute.
81pub const GEN_AI_USAGE_CACHE_CREATION_TOKENS: &str = "gen_ai.usage.cache_creation_tokens";
82
83/// The `gen_ai.usage.thinking_tokens` attribute.
84pub const GEN_AI_USAGE_THINKING_TOKENS: &str = "gen_ai.usage.thinking_tokens";
85
86// --- Conversation ---
87
88/// The `gen_ai.conversation.id` attribute.
89pub const GEN_AI_CONVERSATION_ID: &str = "gen_ai.conversation.id";
90
91// --- Tool Attributes ---
92
93/// The `gen_ai.tool.name` attribute.
94pub const GEN_AI_TOOL_NAME: &str = "gen_ai.tool.name";
95
96/// The `gen_ai.tool.call_id` attribute.
97pub const GEN_AI_TOOL_CALL_ID: &str = "gen_ai.tool.call_id";
98
99// --- Content Events ---
100
101/// The `gen_ai.content.prompt` event name.
102pub const GEN_AI_CONTENT_PROMPT: &str = "gen_ai.content.prompt";
103
104/// The `gen_ai.content.completion` event name.
105pub const GEN_AI_CONTENT_COMPLETION: &str = "gen_ai.content.completion";
106
107// =============================================================================
108// Enums
109// =============================================================================
110
111/// Well-known GenAI provider identifiers per OTel semconv registry.
112///
113/// # Example
114/// ```
115/// use adk_telemetry::semconv::GenAiProvider;
116/// assert_eq!(GenAiProvider::Gemini.as_str(), "gcp.gemini");
117/// assert_eq!(GenAiProvider::OpenAI.as_str(), "openai");
118/// ```
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120pub enum GenAiProvider {
121    /// Google Gemini (via AI Studio or Vertex AI).
122    Gemini,
123    /// OpenAI.
124    OpenAI,
125    /// Anthropic.
126    Anthropic,
127    /// DeepSeek.
128    DeepSeek,
129    /// Groq.
130    Groq,
131    /// Ollama (local).
132    Ollama,
133    /// Azure OpenAI Service.
134    AzureOpenAI,
135    /// Azure AI Inference.
136    AzureAiInference,
137    /// AWS Bedrock.
138    AwsBedrock,
139    /// Mistral AI.
140    MistralAi,
141    /// Perplexity.
142    Perplexity,
143    /// xAI (Grok).
144    XAi,
145}
146
147impl GenAiProvider {
148    /// Returns the OTel semconv `gen_ai.provider.name` string.
149    pub fn as_str(&self) -> &'static str {
150        match self {
151            Self::Gemini => "gcp.gemini",
152            Self::OpenAI => "openai",
153            Self::Anthropic => "anthropic",
154            Self::DeepSeek => "deepseek",
155            Self::Groq => "groq",
156            Self::Ollama => "ollama",
157            Self::AzureOpenAI => "azure.ai.openai",
158            Self::AzureAiInference => "azure.ai.inference",
159            Self::AwsBedrock => "aws.bedrock",
160            Self::MistralAi => "mistral_ai",
161            Self::Perplexity => "perplexity",
162            Self::XAi => "x_ai",
163        }
164    }
165}
166
167/// Well-known GenAI operation names per OTel semconv.
168///
169/// # Example
170/// ```
171/// use adk_telemetry::semconv::GenAiOperation;
172/// assert_eq!(GenAiOperation::Chat.as_str(), "chat");
173/// assert_eq!(GenAiOperation::Embeddings.as_str(), "embeddings");
174/// ```
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub enum GenAiOperation {
177    /// Chat completion.
178    Chat,
179    /// Generate content (Gemini-style).
180    GenerateContent,
181    /// Text completion (legacy).
182    TextCompletion,
183    /// Embedding generation.
184    Embeddings,
185    /// Tool execution.
186    ExecuteTool,
187    /// Agent invocation.
188    InvokeAgent,
189}
190
191impl GenAiOperation {
192    /// Returns the OTel semconv operation name string.
193    pub fn as_str(&self) -> &'static str {
194        match self {
195            Self::Chat => "chat",
196            Self::GenerateContent => "generate_content",
197            Self::TextCompletion => "text_completion",
198            Self::Embeddings => "embeddings",
199            Self::ExecuteTool => "execute_tool",
200            Self::InvokeAgent => "invoke_agent",
201        }
202    }
203}
204
205// =============================================================================
206// GenAiSpanBuilder
207// =============================================================================
208
209/// Builder for creating model call spans with full OTel GenAI semconv attributes.
210///
211/// Providers use this to construct spans with all available metadata.
212/// Fields not set are omitted from the span (recorded as `Empty`).
213///
214/// # Example
215/// ```
216/// use adk_telemetry::semconv::{GenAiSpanBuilder, GenAiProvider, GenAiOperation};
217///
218/// let span = GenAiSpanBuilder::new(GenAiProvider::Gemini, GenAiOperation::Chat, "gemini-2.5-flash")
219///     .stream(true)
220///     .temperature(0.7)
221///     .max_tokens(4096)
222///     .build();
223/// ```
224pub struct GenAiSpanBuilder {
225    provider: GenAiProvider,
226    operation: GenAiOperation,
227    model: String,
228    stream: bool,
229    temperature: Option<f64>,
230    max_tokens: Option<i64>,
231    top_p: Option<f64>,
232    top_k: Option<f64>,
233    conversation_id: Option<String>,
234}
235
236impl GenAiSpanBuilder {
237    /// Create a new span builder with required fields.
238    pub fn new(
239        provider: GenAiProvider,
240        operation: GenAiOperation,
241        model: impl Into<String>,
242    ) -> Self {
243        Self {
244            provider,
245            operation,
246            model: model.into(),
247            stream: false,
248            temperature: None,
249            max_tokens: None,
250            top_p: None,
251            top_k: None,
252            conversation_id: None,
253        }
254    }
255
256    /// Set whether this is a streaming request.
257    pub fn stream(mut self, stream: bool) -> Self {
258        self.stream = stream;
259        self
260    }
261
262    /// Set the temperature parameter.
263    pub fn temperature(mut self, temp: f64) -> Self {
264        self.temperature = Some(temp);
265        self
266    }
267
268    /// Set the max tokens parameter.
269    pub fn max_tokens(mut self, max: i64) -> Self {
270        self.max_tokens = Some(max);
271        self
272    }
273
274    /// Set the top_p parameter.
275    pub fn top_p(mut self, p: f64) -> Self {
276        self.top_p = Some(p);
277        self
278    }
279
280    /// Set the top_k parameter.
281    pub fn top_k(mut self, k: f64) -> Self {
282        self.top_k = Some(k);
283        self
284    }
285
286    /// Set the conversation/session ID for correlation.
287    pub fn conversation_id(mut self, id: impl Into<String>) -> Self {
288        self.conversation_id = Some(id.into());
289        self
290    }
291
292    /// Build and return the tracing [`Span`].
293    ///
294    /// The span name follows the pattern: `gen_ai.{operation_name} {model}`.
295    /// All `gen_ai.*` response and usage fields are pre-declared as `Empty`
296    /// so they can be recorded later via [`GenAiResponseRecorder`].
297    pub fn build(self) -> Span {
298        let span_name = format!("gen_ai.{} {}", self.operation.as_str(), self.model);
299        let provider_str = self.provider.as_str();
300        let operation_str = self.operation.as_str();
301
302        let span = tracing::info_span!(
303            "gen_ai.call",
304            "otel.name" = %span_name,
305            "gen_ai.system" = %provider_str,
306            "gen_ai.provider.name" = %provider_str,
307            "gen_ai.operation.name" = %operation_str,
308            "gen_ai.request.model" = %self.model,
309            "gen_ai.request.stream" = self.stream,
310            "gen_ai.request.temperature" = tracing::field::Empty,
311            "gen_ai.request.max_tokens" = tracing::field::Empty,
312            "gen_ai.request.top_p" = tracing::field::Empty,
313            "gen_ai.request.top_k" = tracing::field::Empty,
314            "gen_ai.conversation.id" = tracing::field::Empty,
315            "gen_ai.response.model" = tracing::field::Empty,
316            "gen_ai.response.finish_reasons" = tracing::field::Empty,
317            "gen_ai.usage.input_tokens" = tracing::field::Empty,
318            "gen_ai.usage.output_tokens" = tracing::field::Empty,
319            "gen_ai.usage.total_tokens" = tracing::field::Empty,
320            "gen_ai.usage.cache_read_tokens" = tracing::field::Empty,
321            "gen_ai.usage.cache_creation_tokens" = tracing::field::Empty,
322            "gen_ai.usage.thinking_tokens" = tracing::field::Empty,
323            "otel.kind" = "client",
324        );
325
326        // Record optional fields that were set
327        if let Some(temp) = self.temperature {
328            span.record("gen_ai.request.temperature", temp);
329        }
330        if let Some(max) = self.max_tokens {
331            span.record("gen_ai.request.max_tokens", max);
332        }
333        if let Some(p) = self.top_p {
334            span.record("gen_ai.request.top_p", p);
335        }
336        if let Some(k) = self.top_k {
337            span.record("gen_ai.request.top_k", k);
338        }
339        if let Some(ref conv_id) = self.conversation_id {
340            span.record("gen_ai.conversation.id", conv_id.as_str());
341        }
342
343        span
344    }
345}
346
347// =============================================================================
348// GenAiResponseRecorder
349// =============================================================================
350
351/// Records response-time attributes on the current span.
352///
353/// Call after receiving the model response to populate response model,
354/// finish reasons, and token usage.
355///
356/// # Example
357/// ```
358/// use adk_telemetry::semconv::GenAiResponseRecorder;
359/// use adk_telemetry::LlmUsage;
360///
361/// // After receiving model response:
362/// GenAiResponseRecorder::record_response_model("gemini-2.5-flash-001");
363/// GenAiResponseRecorder::record_finish_reasons(&["stop"]);
364/// GenAiResponseRecorder::record_usage(&LlmUsage {
365///     input_tokens: 100,
366///     output_tokens: 50,
367///     total_tokens: 150,
368///     ..Default::default()
369/// });
370/// ```
371pub struct GenAiResponseRecorder;
372
373impl GenAiResponseRecorder {
374    /// Record the response model (may differ from request model).
375    pub fn record_response_model(model: &str) {
376        Span::current().record("gen_ai.response.model", model);
377    }
378
379    /// Record finish reasons as a comma-separated string.
380    ///
381    /// OTel semconv specifies this as a string array; tracing encodes as CSV.
382    pub fn record_finish_reasons(reasons: &[&str]) {
383        let joined = reasons.join(",");
384        Span::current().record("gen_ai.response.finish_reasons", joined.as_str());
385    }
386
387    /// Record token usage (delegates to existing [`record_llm_usage`](crate::record_llm_usage)).
388    pub fn record_usage(usage: &LlmUsage) {
389        crate::record_llm_usage(usage);
390    }
391}
392
393// =============================================================================
394// Finish Reason Mapping
395// =============================================================================
396
397/// Maps provider-specific finish reason strings to OTel semconv values.
398///
399/// Known mappings are converted; unknown values pass through unchanged.
400///
401/// # Example
402/// ```
403/// use adk_telemetry::semconv::{map_finish_reason, GenAiProvider};
404///
405/// assert_eq!(map_finish_reason(GenAiProvider::Gemini, "STOP"), "stop");
406/// assert_eq!(map_finish_reason(GenAiProvider::OpenAI, "length"), "max_tokens");
407/// assert_eq!(map_finish_reason(GenAiProvider::Anthropic, "end_turn"), "stop");
408/// assert_eq!(map_finish_reason(GenAiProvider::Gemini, "UNKNOWN"), "UNKNOWN");
409/// ```
410pub fn map_finish_reason(provider: GenAiProvider, raw: &str) -> &str {
411    match provider {
412        GenAiProvider::Gemini => match raw {
413            "STOP" => "stop",
414            "MAX_TOKENS" => "max_tokens",
415            "SAFETY" => "content_filter",
416            _ => raw,
417        },
418        GenAiProvider::OpenAI | GenAiProvider::AzureOpenAI => match raw {
419            "stop" => "stop",
420            "length" => "max_tokens",
421            "tool_calls" => "tool_calls",
422            "content_filter" => "content_filter",
423            _ => raw,
424        },
425        GenAiProvider::Anthropic => match raw {
426            "end_turn" => "stop",
427            "max_tokens" => "max_tokens",
428            "tool_use" => "tool_calls",
429            _ => raw,
430        },
431        _ => raw,
432    }
433}
434
435// =============================================================================
436// Tool Call Span
437// =============================================================================
438
439/// Create a span for tool execution with GenAI semconv attributes.
440///
441/// Emits `gen_ai.tool.name` always and `gen_ai.tool.call_id` when provided.
442/// Pre-declares `gen_ai.conversation.id` and `gen_ai.system` as `Empty` for
443/// propagation from parent spans via [`AdkSpanLayer`](crate::AdkSpanLayer).
444///
445/// # Example
446/// ```
447/// use adk_telemetry::semconv::tool_call_semconv_span;
448///
449/// let span = tool_call_semconv_span("weather_tool", Some("call_abc123"));
450/// let _enter = span.enter();
451/// ```
452pub fn tool_call_semconv_span(tool_name: &str, call_id: Option<&str>) -> Span {
453    let span = tracing::info_span!(
454        "execute_tool",
455        "gen_ai.tool.name" = %tool_name,
456        "gen_ai.tool.call_id" = tracing::field::Empty,
457        "gen_ai.conversation.id" = tracing::field::Empty,
458        "gen_ai.system" = tracing::field::Empty,
459        "gen_ai.provider.name" = tracing::field::Empty,
460        "otel.kind" = "internal",
461    );
462
463    if let Some(id) = call_id {
464        span.record("gen_ai.tool.call_id", id);
465    }
466
467    span
468}
469
470// =============================================================================
471// Agent Run Span
472// =============================================================================
473
474/// Create a span for agent execution with conversation ID.
475///
476/// Sets `gen_ai.conversation.id` to `session_id` when provided, which
477/// propagates to all child spans via [`AdkSpanLayer`](crate::AdkSpanLayer).
478///
479/// # Example
480/// ```
481/// use adk_telemetry::semconv::agent_run_semconv_span;
482///
483/// let span = agent_run_semconv_span("my-agent", "inv-123", Some("session-456"));
484/// let _enter = span.enter();
485/// ```
486pub fn agent_run_semconv_span(
487    agent_name: &str,
488    invocation_id: &str,
489    session_id: Option<&str>,
490) -> Span {
491    let span = tracing::info_span!(
492        "agent.execute",
493        "agent.name" = %agent_name,
494        "invocation.id" = %invocation_id,
495        "gen_ai.conversation.id" = tracing::field::Empty,
496        "otel.kind" = "internal",
497    );
498
499    if let Some(sid) = session_id {
500        span.record("gen_ai.conversation.id", sid);
501    }
502
503    span
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use tracing_subscriber::layer::SubscriberExt;
510
511    /// Helper to run test code with a subscriber so spans are not disabled.
512    fn with_subscriber(f: impl FnOnce()) {
513        let subscriber = tracing_subscriber::registry()
514            .with(tracing_subscriber::fmt::layer().with_test_writer());
515        tracing::subscriber::with_default(subscriber, f);
516    }
517
518    #[test]
519    fn test_provider_as_str() {
520        assert_eq!(GenAiProvider::Gemini.as_str(), "gcp.gemini");
521        assert_eq!(GenAiProvider::OpenAI.as_str(), "openai");
522        assert_eq!(GenAiProvider::Anthropic.as_str(), "anthropic");
523        assert_eq!(GenAiProvider::DeepSeek.as_str(), "deepseek");
524        assert_eq!(GenAiProvider::Groq.as_str(), "groq");
525        assert_eq!(GenAiProvider::Ollama.as_str(), "ollama");
526        assert_eq!(GenAiProvider::AzureOpenAI.as_str(), "azure.ai.openai");
527        assert_eq!(GenAiProvider::AzureAiInference.as_str(), "azure.ai.inference");
528        assert_eq!(GenAiProvider::AwsBedrock.as_str(), "aws.bedrock");
529        assert_eq!(GenAiProvider::MistralAi.as_str(), "mistral_ai");
530        assert_eq!(GenAiProvider::Perplexity.as_str(), "perplexity");
531        assert_eq!(GenAiProvider::XAi.as_str(), "x_ai");
532    }
533
534    #[test]
535    fn test_operation_as_str() {
536        assert_eq!(GenAiOperation::Chat.as_str(), "chat");
537        assert_eq!(GenAiOperation::GenerateContent.as_str(), "generate_content");
538        assert_eq!(GenAiOperation::TextCompletion.as_str(), "text_completion");
539        assert_eq!(GenAiOperation::Embeddings.as_str(), "embeddings");
540        assert_eq!(GenAiOperation::ExecuteTool.as_str(), "execute_tool");
541        assert_eq!(GenAiOperation::InvokeAgent.as_str(), "invoke_agent");
542    }
543
544    #[test]
545    fn test_map_finish_reason_gemini() {
546        assert_eq!(map_finish_reason(GenAiProvider::Gemini, "STOP"), "stop");
547        assert_eq!(map_finish_reason(GenAiProvider::Gemini, "MAX_TOKENS"), "max_tokens");
548        assert_eq!(map_finish_reason(GenAiProvider::Gemini, "SAFETY"), "content_filter");
549        assert_eq!(map_finish_reason(GenAiProvider::Gemini, "UNKNOWN_REASON"), "UNKNOWN_REASON");
550    }
551
552    #[test]
553    fn test_map_finish_reason_openai() {
554        assert_eq!(map_finish_reason(GenAiProvider::OpenAI, "stop"), "stop");
555        assert_eq!(map_finish_reason(GenAiProvider::OpenAI, "length"), "max_tokens");
556        assert_eq!(map_finish_reason(GenAiProvider::OpenAI, "tool_calls"), "tool_calls");
557        assert_eq!(map_finish_reason(GenAiProvider::OpenAI, "content_filter"), "content_filter");
558        assert_eq!(map_finish_reason(GenAiProvider::OpenAI, "other"), "other");
559    }
560
561    #[test]
562    fn test_map_finish_reason_anthropic() {
563        assert_eq!(map_finish_reason(GenAiProvider::Anthropic, "end_turn"), "stop");
564        assert_eq!(map_finish_reason(GenAiProvider::Anthropic, "max_tokens"), "max_tokens");
565        assert_eq!(map_finish_reason(GenAiProvider::Anthropic, "tool_use"), "tool_calls");
566        assert_eq!(map_finish_reason(GenAiProvider::Anthropic, "stop_sequence"), "stop_sequence");
567    }
568
569    #[test]
570    fn test_map_finish_reason_unknown_provider_passthrough() {
571        assert_eq!(map_finish_reason(GenAiProvider::Ollama, "done"), "done");
572        assert_eq!(map_finish_reason(GenAiProvider::DeepSeek, "stop"), "stop");
573    }
574
575    #[test]
576    fn test_span_builder_creates_span() {
577        with_subscriber(|| {
578            let span = GenAiSpanBuilder::new(
579                GenAiProvider::Gemini,
580                GenAiOperation::Chat,
581                "gemini-2.5-flash",
582            )
583            .stream(true)
584            .temperature(0.7)
585            .max_tokens(4096)
586            .conversation_id("session-123")
587            .build();
588
589            assert!(!span.is_disabled());
590        });
591    }
592
593    #[test]
594    fn test_tool_call_semconv_span_with_call_id() {
595        with_subscriber(|| {
596            let span = tool_call_semconv_span("weather_tool", Some("call_abc"));
597            assert!(!span.is_disabled());
598        });
599    }
600
601    #[test]
602    fn test_tool_call_semconv_span_without_call_id() {
603        with_subscriber(|| {
604            let span = tool_call_semconv_span("weather_tool", None);
605            assert!(!span.is_disabled());
606        });
607    }
608
609    #[test]
610    fn test_agent_run_semconv_span_with_session() {
611        with_subscriber(|| {
612            let span = agent_run_semconv_span("my-agent", "inv-1", Some("session-1"));
613            assert!(!span.is_disabled());
614        });
615    }
616
617    #[test]
618    fn test_agent_run_semconv_span_without_session() {
619        with_subscriber(|| {
620            let span = agent_run_semconv_span("my-agent", "inv-1", None);
621            assert!(!span.is_disabled());
622        });
623    }
624}