1use tracing::Span;
15
16use crate::LlmUsage;
17
18pub const GEN_AI_SYSTEM: &str = "gen_ai.system";
22
23pub const GEN_AI_PROVIDER_NAME: &str = "gen_ai.provider.name";
25
26pub const GEN_AI_OPERATION_NAME: &str = "gen_ai.operation.name";
28
29pub const GEN_AI_REQUEST_MODEL: &str = "gen_ai.request.model";
33
34pub const GEN_AI_REQUEST_MAX_TOKENS: &str = "gen_ai.request.max_tokens";
36
37pub const GEN_AI_REQUEST_TEMPERATURE: &str = "gen_ai.request.temperature";
39
40pub const GEN_AI_REQUEST_TOP_P: &str = "gen_ai.request.top_p";
42
43pub const GEN_AI_REQUEST_TOP_K: &str = "gen_ai.request.top_k";
45
46pub const GEN_AI_REQUEST_STREAM: &str = "gen_ai.request.stream";
48
49pub const GEN_AI_REQUEST_FREQUENCY_PENALTY: &str = "gen_ai.request.frequency_penalty";
51
52pub const GEN_AI_REQUEST_PRESENCE_PENALTY: &str = "gen_ai.request.presence_penalty";
54
55pub const GEN_AI_RESPONSE_MODEL: &str = "gen_ai.response.model";
59
60pub const GEN_AI_RESPONSE_FINISH_REASONS: &str = "gen_ai.response.finish_reasons";
62
63pub const GEN_AI_RESPONSE_ID: &str = "gen_ai.response.id";
65
66pub const GEN_AI_USAGE_INPUT_TOKENS: &str = "gen_ai.usage.input_tokens";
70
71pub const GEN_AI_USAGE_OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens";
73
74pub const GEN_AI_USAGE_TOTAL_TOKENS: &str = "gen_ai.usage.total_tokens";
76
77pub const GEN_AI_USAGE_CACHE_READ_TOKENS: &str = "gen_ai.usage.cache_read_tokens";
79
80pub const GEN_AI_USAGE_CACHE_CREATION_TOKENS: &str = "gen_ai.usage.cache_creation_tokens";
82
83pub const GEN_AI_USAGE_THINKING_TOKENS: &str = "gen_ai.usage.thinking_tokens";
85
86pub const GEN_AI_CONVERSATION_ID: &str = "gen_ai.conversation.id";
90
91pub const GEN_AI_TOOL_NAME: &str = "gen_ai.tool.name";
95
96pub const GEN_AI_TOOL_CALL_ID: &str = "gen_ai.tool.call_id";
98
99pub const GEN_AI_CONTENT_PROMPT: &str = "gen_ai.content.prompt";
103
104pub const GEN_AI_CONTENT_COMPLETION: &str = "gen_ai.content.completion";
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120pub enum GenAiProvider {
121 Gemini,
123 OpenAI,
125 Anthropic,
127 DeepSeek,
129 Groq,
131 Ollama,
133 AzureOpenAI,
135 AzureAiInference,
137 AwsBedrock,
139 MistralAi,
141 Perplexity,
143 XAi,
145}
146
147impl GenAiProvider {
148 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub enum GenAiOperation {
177 Chat,
179 GenerateContent,
181 TextCompletion,
183 Embeddings,
185 ExecuteTool,
187 InvokeAgent,
189}
190
191impl GenAiOperation {
192 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
205pub 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 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 pub fn stream(mut self, stream: bool) -> Self {
258 self.stream = stream;
259 self
260 }
261
262 pub fn temperature(mut self, temp: f64) -> Self {
264 self.temperature = Some(temp);
265 self
266 }
267
268 pub fn max_tokens(mut self, max: i64) -> Self {
270 self.max_tokens = Some(max);
271 self
272 }
273
274 pub fn top_p(mut self, p: f64) -> Self {
276 self.top_p = Some(p);
277 self
278 }
279
280 pub fn top_k(mut self, k: f64) -> Self {
282 self.top_k = Some(k);
283 self
284 }
285
286 pub fn conversation_id(mut self, id: impl Into<String>) -> Self {
288 self.conversation_id = Some(id.into());
289 self
290 }
291
292 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 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
347pub struct GenAiResponseRecorder;
372
373impl GenAiResponseRecorder {
374 pub fn record_response_model(model: &str) {
376 Span::current().record("gen_ai.response.model", model);
377 }
378
379 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 pub fn record_usage(usage: &LlmUsage) {
389 crate::record_llm_usage(usage);
390 }
391}
392
393pub 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
435pub 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
470pub 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 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}