1use crate::completion::{AssistantContent, Message, Usage};
9use crate::message::{
10 DocumentData, DocumentSourceKind, Image, MimeType, Opaque, ToolResult, ToolResultContent,
11 UserContent,
12};
13use base64::Engine;
14use serde::Serialize;
15use std::borrow::Borrow;
16use std::collections::HashSet;
17use std::sync::{LazyLock, Mutex};
18use tracing::callsite::Identifier;
19
20#[doc(hidden)]
23pub use tracing as __tracing;
24
25pub use tracing::field::Empty;
27
28#[doc(hidden)]
31#[macro_export]
32macro_rules! __rig_canonical_completion_span {
33 (
34 target: $target:literal,
35 $(parent: $parent:expr,)?
36 name: $name:literal,
37 { $($header:tt)* }
42 { $($extra:tt)* }
43 ) => {
44 $crate::telemetry::__tracing::info_span!(
45 target: $target,
46 $(parent: $parent,)?
47 $name,
48 $($header)*
49 gen_ai.request.stream = $crate::telemetry::__tracing::field::Empty,
50 gen_ai.response.id = $crate::telemetry::__tracing::field::Empty,
51 gen_ai.response.model = $crate::telemetry::__tracing::field::Empty,
52 rig.provider_request_id = $crate::telemetry::__tracing::field::Empty,
53 gen_ai.usage.input_tokens = $crate::telemetry::__tracing::field::Empty,
54 gen_ai.usage.output_tokens = $crate::telemetry::__tracing::field::Empty,
55 gen_ai.usage.cache_read.input_tokens = $crate::telemetry::__tracing::field::Empty,
56 gen_ai.usage.cache_creation.input_tokens = $crate::telemetry::__tracing::field::Empty,
57 gen_ai.usage.tool_use_prompt_tokens = $crate::telemetry::__tracing::field::Empty,
58 gen_ai.usage.reasoning_tokens = $crate::telemetry::__tracing::field::Empty,
59 gen_ai.input.messages = $crate::telemetry::__tracing::field::Empty,
60 gen_ai.output.messages = $crate::telemetry::__tracing::field::Empty
61 $($extra)*
62 )
63 };
64}
65
66macro_rules! new_completion_span {
67 ($name:literal, $provider:expr, $request_model:expr, $operation:expr, $system:expr) => {
68 $crate::__rig_canonical_completion_span!(
69 target: "rig::completions",
70 name: $name,
71 {
72 gen_ai.operation.name = $operation,
73 gen_ai.provider.name = $provider,
74 gen_ai.request.model = $request_model,
75 gen_ai.system_instructions = $system,
76 }
77 {}
78 )
79 };
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum GenAiOperation {
92 Chat,
94 GenerateContent,
96 Embeddings,
98 Rerank,
100 Transcription,
102 ImageGeneration,
104 AudioGeneration,
106}
107
108impl GenAiOperation {
109 fn as_str(self) -> &'static str {
110 match self {
111 Self::Chat => "chat",
112 Self::GenerateContent => "generate_content",
113 Self::Embeddings => "embeddings",
114 Self::Rerank => "rerank",
115 Self::Transcription => "transcription",
116 Self::ImageGeneration => "image_generation",
117 Self::AudioGeneration => "audio_generation",
118 }
119 }
120
121 pub(crate) fn is_completion(self) -> bool {
122 matches!(self, Self::Chat | Self::GenerateContent)
123 }
124}
125
126pub const PROVIDER_REQUEST_ID_FIELD: &str = "rig.provider_request_id";
132
133pub const COMPLETION_PARENT_MARKER_FIELD: &str = "rig.completion_parent";
138
139pub const COMPLETION_PARENT_REQUIRED_FIELDS: &[&str] = &[
143 "gen_ai.operation.name",
144 "gen_ai.provider.name",
145 "gen_ai.request.model",
146 "gen_ai.system_instructions",
147 "gen_ai.response.id",
148 "gen_ai.response.model",
149 "gen_ai.usage.input_tokens",
150 "gen_ai.usage.output_tokens",
151 "gen_ai.usage.cache_read.input_tokens",
152 "gen_ai.usage.cache_creation.input_tokens",
153 "gen_ai.usage.tool_use_prompt_tokens",
154 "gen_ai.usage.reasoning_tokens",
155 "gen_ai.input.messages",
156 "gen_ai.output.messages",
157];
158
159#[macro_export]
182macro_rules! completion_parent_span {
183 (
184 target: $target:literal,
185 parent: $parent:expr,
186 name: $name:literal,
187 operation: $operation:expr,
188 system_instructions: $system:expr
189 $(, $($extra:tt)*)?
190 ) => {
191 $crate::__rig_canonical_completion_span!(
192 target: $target,
193 parent: $parent,
194 name: $name,
195 {
196 rig.completion_parent = true,
197 gen_ai.operation.name = $operation,
198 gen_ai.system_instructions = $system,
199 gen_ai.provider.name = $crate::telemetry::__tracing::field::Empty,
200 gen_ai.request.model = $crate::telemetry::__tracing::field::Empty,
201 }
202 { $(, $($extra)*)? }
203 )
204 };
205 (
208 target: $target:literal,
209 name: $name:literal,
210 operation: $operation:expr,
211 system_instructions: $system:expr
212 $(, $($extra:tt)*)?
213 ) => {
214 $crate::completion_parent_span!(
215 target: $target,
216 parent: $crate::telemetry::__tracing::Span::current(),
217 name: $name,
218 operation: $operation,
219 system_instructions: $system
220 $(, $($extra)*)?
221 )
222 };
223}
224
225pub use crate::completion_parent_span;
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232enum CompletionParentVerdict {
233 Adopt,
235 RejectMissingFields,
239 NotAParent,
242}
243
244fn missing_required_fields(metadata: &tracing::Metadata<'_>) -> Vec<&'static str> {
246 let fields = metadata.fields();
247 COMPLETION_PARENT_REQUIRED_FIELDS
248 .iter()
249 .copied()
250 .filter(|name| fields.field(name).is_none())
251 .collect()
252}
253
254fn classify_completion_parent(metadata: &tracing::Metadata<'_>) -> CompletionParentVerdict {
256 let fields = metadata.fields();
257 if fields.field(COMPLETION_PARENT_MARKER_FIELD).is_none() {
261 return CompletionParentVerdict::NotAParent;
262 }
263 if COMPLETION_PARENT_REQUIRED_FIELDS
264 .iter()
265 .all(|name| fields.field(name).is_some())
266 {
267 CompletionParentVerdict::Adopt
268 } else {
269 CompletionParentVerdict::RejectMissingFields
270 }
271}
272
273static NEAR_MISS_WARNED: LazyLock<Mutex<HashSet<Identifier>>> =
276 LazyLock::new(|| Mutex::new(HashSet::new()));
277
278#[cfg(test)]
287fn reset_near_miss_warnings() {
288 NEAR_MISS_WARNED
289 .lock()
290 .unwrap_or_else(|poisoned| poisoned.into_inner())
291 .clear();
292}
293
294fn warn_once_on_completion_parent_verdict(
297 verdict: CompletionParentVerdict,
298 metadata: &tracing::Metadata<'_>,
299) {
300 match verdict {
301 CompletionParentVerdict::Adopt | CompletionParentVerdict::NotAParent => {}
302 CompletionParentVerdict::RejectMissingFields => {
303 let first_sighting = {
304 let mut warned = NEAR_MISS_WARNED
305 .lock()
306 .unwrap_or_else(std::sync::PoisonError::into_inner);
307 warned.insert(metadata.callsite())
308 };
309 if !first_sighting {
312 return;
313 }
314 tracing::warn!(
315 marker = COMPLETION_PARENT_MARKER_FIELD,
316 missing_fields = ?missing_required_fields(metadata),
317 "completion-parent span declares the marker but not every required field \
318 and is not adopted; provider telemetry lands on a fresh child span \
319 instead — declare the span with \
320 `rig_core::telemetry::completion_parent_span!`"
321 );
322 }
323 }
324}
325
326macro_rules! new_modality_span {
327 ($name:literal, $provider:expr, $request_model:expr, $operation:expr) => {
328 $crate::telemetry::__tracing::info_span!(
329 target: "rig::modalities",
330 $name,
331 gen_ai.operation.name = $operation,
332 gen_ai.provider.name = $provider,
333 gen_ai.request.model = $request_model,
334 gen_ai.response.id = $crate::telemetry::__tracing::field::Empty,
335 gen_ai.response.model = $crate::telemetry::__tracing::field::Empty,
336 rig.provider_request_id = $crate::telemetry::__tracing::field::Empty,
337 gen_ai.usage.input_tokens = $crate::telemetry::__tracing::field::Empty,
338 gen_ai.usage.output_tokens = $crate::telemetry::__tracing::field::Empty,
339 gen_ai.usage.cache_read.input_tokens = $crate::telemetry::__tracing::field::Empty,
340 gen_ai.usage.cache_creation.input_tokens = $crate::telemetry::__tracing::field::Empty,
341 gen_ai.usage.tool_use_prompt_tokens = $crate::telemetry::__tracing::field::Empty,
342 gen_ai.usage.reasoning_tokens = $crate::telemetry::__tracing::field::Empty,
343 )
344 };
345}
346
347pub struct SpanBuilder<'a> {
355 provider: &'a str,
356 request_model: &'a str,
357 operation: GenAiOperation,
358 system_instructions: Option<String>,
359 streaming: Option<bool>,
360}
361
362impl<'a> SpanBuilder<'a> {
363 pub fn new(provider: &'a str, request_model: &'a str, operation: GenAiOperation) -> Self {
365 Self {
366 provider,
367 request_model,
368 operation,
369 system_instructions: None,
370 streaming: None,
371 }
372 }
373
374 pub fn streaming(mut self, streaming: bool) -> Self {
377 self.streaming = Some(streaming);
378 self
379 }
380
381 pub fn system_instructions(
384 mut self,
385 system_instructions: Option<&'a str>,
386 record_content: bool,
387 ) -> Self {
388 self.system_instructions = system_instructions_json(system_instructions, record_content);
389 self
390 }
391
392 pub fn build(self) -> tracing::Span {
395 if self.operation.is_completion()
396 && let Some(parent) = self.adopt_completion_parent()
397 {
398 return parent;
399 }
400
401 let (provider, model) = (self.provider, self.request_model);
402 let op = self.operation.as_str();
403 let sys = self.system_instructions.as_deref();
404 let span = match self.operation {
405 GenAiOperation::Chat => new_completion_span!("chat", provider, model, op, sys),
406 GenAiOperation::GenerateContent => {
407 new_completion_span!("generate_content", provider, model, op, sys)
408 }
409 GenAiOperation::Embeddings => new_modality_span!("embeddings", provider, model, op),
410 GenAiOperation::Rerank => new_modality_span!("rerank", provider, model, op),
411 GenAiOperation::Transcription => {
412 new_modality_span!("transcription", provider, model, op)
413 }
414 GenAiOperation::ImageGeneration => {
415 new_modality_span!("image_generation", provider, model, op)
416 }
417 GenAiOperation::AudioGeneration => {
418 new_modality_span!("audio_generation", provider, model, op)
419 }
420 };
421 if self.operation.is_completion() {
422 self.record_streaming(&span);
423 }
424 span
425 }
426
427 fn record_streaming(&self, span: &tracing::Span) {
428 if let Some(streaming) = self.streaming {
429 span.record("gen_ai.request.stream", streaming);
430 }
431 }
432
433 fn adopt_completion_parent(&self) -> Option<tracing::Span> {
436 let current = tracing::Span::current();
437 let metadata = current.metadata()?;
438 let verdict = classify_completion_parent(metadata);
439 warn_once_on_completion_parent_verdict(verdict, metadata);
440 if verdict != CompletionParentVerdict::Adopt {
441 return None;
442 }
443 current.record("gen_ai.operation.name", self.operation.as_str());
444 current.record("gen_ai.provider.name", self.provider);
445 current.record("gen_ai.request.model", self.request_model);
446 self.record_streaming(¤t);
447 if let Some(system_instructions) = self.system_instructions.as_deref() {
448 current.record("gen_ai.system_instructions", system_instructions);
449 }
450 Some(current)
451 }
452}
453
454#[derive(Serialize)]
455struct TelemetryChatMessage {
456 role: &'static str,
457 parts: Vec<TelemetryPart>,
458}
459
460#[derive(Serialize)]
461struct TelemetryOutputMessage {
462 role: &'static str,
463 parts: Vec<TelemetryPart>,
464 finish_reason: &'static str,
465}
466
467#[derive(Serialize)]
468#[serde(tag = "type", rename_all = "snake_case")]
469enum TelemetryPart {
470 Text {
471 content: String,
472 },
473 ToolCall {
474 #[serde(skip_serializing_if = "Option::is_none")]
475 id: Option<String>,
476 name: String,
477 arguments: serde_json::Value,
478 },
479 ToolCallResponse {
480 #[serde(skip_serializing_if = "Option::is_none")]
481 id: Option<String>,
482 response: serde_json::Value,
483 },
484 Reasoning {
485 content: String,
486 },
487 Opaque {
488 kind: String,
489 },
490 Uri {
491 #[serde(skip_serializing_if = "Option::is_none")]
492 mime_type: Option<String>,
493 modality: &'static str,
494 uri: String,
495 },
496 File {
497 #[serde(skip_serializing_if = "Option::is_none")]
498 mime_type: Option<String>,
499 modality: &'static str,
500 file_id: String,
501 },
502 Blob {
503 #[serde(skip_serializing_if = "Option::is_none")]
504 mime_type: Option<String>,
505 modality: &'static str,
506 content: String,
507 },
508}
509
510fn media_part<T>(
511 data: &DocumentSourceKind,
512 media_type: Option<&T>,
513 modality: &'static str,
514) -> Option<TelemetryPart>
515where
516 T: MimeType,
517{
518 let mime_type = media_type.map(|media_type| media_type.to_mime_type().to_string());
519 match data {
520 DocumentSourceKind::Url(uri) => Some(TelemetryPart::Uri {
521 mime_type,
522 modality,
523 uri: uri.clone(),
524 }),
525 DocumentSourceKind::FileId(file_id) => Some(TelemetryPart::File {
526 mime_type,
527 modality,
528 file_id: file_id.clone(),
529 }),
530 DocumentSourceKind::Base64(content) => Some(TelemetryPart::Blob {
531 mime_type,
532 modality,
533 content: content.clone(),
534 }),
535 DocumentSourceKind::Raw(content) => Some(TelemetryPart::Blob {
536 mime_type,
537 modality,
538 content: base64::engine::general_purpose::STANDARD.encode(content),
539 }),
540 DocumentSourceKind::Unknown => None,
541 }
542}
543
544fn image_part(image: &Image) -> Option<TelemetryPart> {
545 media_part(&image.data, image.media_type.as_ref(), "image")
546}
547
548fn opaque_part(opaque: &Opaque) -> TelemetryPart {
551 TelemetryPart::Opaque {
552 kind: opaque.kind().unwrap_or("unknown").to_owned(),
553 }
554}
555
556fn tool_result_response(result: &ToolResult) -> serde_json::Value {
557 let mut content = result
558 .content
559 .iter()
560 .filter_map(|content| match content {
561 ToolResultContent::Text(text) => Some(serde_json::Value::String(text.text.clone())),
562 ToolResultContent::Json { value } => Some(value.clone()),
563 ToolResultContent::Image(image) => {
564 image_part(image).and_then(|part| serde_json::to_value(part).ok())
565 }
566 })
567 .collect::<Vec<_>>();
568
569 if content.len() == 1 {
570 content.pop().unwrap_or(serde_json::Value::Null)
571 } else {
572 serde_json::Value::Array(content)
573 }
574}
575
576fn user_parts(content: &[UserContent]) -> Vec<TelemetryPart> {
577 content
578 .iter()
579 .filter_map(|content| match content {
580 UserContent::Text(text) => Some(TelemetryPart::Text {
581 content: text.text.clone(),
582 }),
583 UserContent::ToolResult(result) => Some(TelemetryPart::ToolCallResponse {
584 id: Some(result.call.to_string()),
585 response: tool_result_response(result),
586 }),
587 UserContent::Image(image) => image_part(image),
588 UserContent::Audio(audio) => {
589 media_part(&audio.data, audio.media_type.as_ref(), "audio")
590 }
591 UserContent::Video(video) => {
592 media_part(&video.data, video.media_type.as_ref(), "video")
593 }
594 UserContent::Document(document) => match &document.data {
595 DocumentData::Text(content) => Some(TelemetryPart::Text {
596 content: content.clone(),
597 }),
598 DocumentData::File(data) => {
599 media_part(data, document.media_type.as_ref(), "document")
600 }
601 },
602 })
603 .collect()
604}
605
606fn assistant_parts(content: &[AssistantContent]) -> Vec<TelemetryPart> {
607 content
608 .iter()
609 .flat_map(|content| match content {
610 AssistantContent::Text(text) => vec![TelemetryPart::Text {
611 content: text.text.clone(),
612 }],
613 AssistantContent::ToolCall(tool_call) => vec![TelemetryPart::ToolCall {
614 id: Some(tool_call.id.to_string()),
615 name: tool_call.function.name.clone().into(),
616 arguments: tool_call.function.arguments_value(),
617 }],
618 AssistantContent::Reasoning(reasoning) => vec![TelemetryPart::Reasoning {
619 content: reasoning.text.clone(),
620 }],
621 AssistantContent::Image(image) => image_part(image).into_iter().collect(),
622 AssistantContent::Opaque(opaque) => vec![opaque_part(opaque)],
623 })
624 .collect()
625}
626
627fn input_messages(messages: &[impl Borrow<Message>]) -> Vec<TelemetryChatMessage> {
628 messages
629 .iter()
630 .map(|message| match message.borrow() {
631 Message::System { content } => TelemetryChatMessage {
632 role: "system",
633 parts: vec![TelemetryPart::Text {
634 content: content.clone(),
635 }],
636 },
637 Message::User { content } => TelemetryChatMessage {
638 role: "user",
639 parts: user_parts(content),
640 },
641 Message::Assistant(turn) => TelemetryChatMessage {
642 role: "assistant",
643 parts: assistant_parts(&turn.content),
644 },
645 })
646 .collect()
647}
648
649fn output_messages(content: &[AssistantContent]) -> Vec<TelemetryOutputMessage> {
650 let finish_reason = if content
651 .iter()
652 .any(|content| matches!(content, AssistantContent::ToolCall(_)))
653 {
654 "tool_call"
655 } else {
656 "unknown"
660 };
661 vec![TelemetryOutputMessage {
662 role: "assistant",
663 parts: assistant_parts(content),
664 finish_reason,
665 }]
666}
667
668pub fn system_instructions_json(instructions: Option<&str>, enabled: bool) -> Option<String> {
670 if !enabled {
671 return None;
672 }
673
674 instructions.and_then(|instructions| {
675 serde_json::to_string(&vec![TelemetryPart::Text {
676 content: instructions.to_string(),
677 }])
678 .ok()
679 })
680}
681
682pub fn record_model_input(span: &tracing::Span, messages: &[impl Borrow<Message>], enabled: bool) {
689 if !enabled || span.is_disabled() {
690 return;
691 }
692
693 if let Ok(messages) = serde_json::to_string(&input_messages(messages)) {
694 span.record("gen_ai.input.messages", messages);
695 }
696}
697
698pub fn record_model_output(span: &tracing::Span, content: &[AssistantContent], enabled: bool) {
705 if !enabled || span.is_disabled() {
706 return;
707 }
708
709 let messages = output_messages(content);
710 if let Ok(messages) = serde_json::to_string(&messages) {
711 span.record("gen_ai.output.messages", messages);
712 }
713}
714
715pub trait SpanCombinator {
717 fn record_token_usage(&self, usage: &Usage);
719
720 fn record_response(&self, response_id: Option<&str>, model: Option<&str>, usage: &Usage);
722}
723
724impl SpanCombinator for tracing::Span {
725 fn record_token_usage(&self, usage: &Usage) {
726 if self.is_disabled() {
727 return;
728 }
729
730 let fields = [
733 ("gen_ai.usage.input_tokens", usage.input_tokens),
734 ("gen_ai.usage.output_tokens", usage.output_tokens),
735 (
736 "gen_ai.usage.cache_read.input_tokens",
737 usage.cached_input_tokens,
738 ),
739 (
740 "gen_ai.usage.cache_creation.input_tokens",
741 usage.cache_creation_input_tokens,
742 ),
743 (
744 "gen_ai.usage.tool_use_prompt_tokens",
745 usage.tool_use_prompt_tokens,
746 ),
747 ("gen_ai.usage.reasoning_tokens", usage.reasoning_tokens),
748 ];
749 for (field, value) in fields {
750 if let Some(value) = value {
751 self.record(field, value);
752 }
753 }
754 }
755
756 fn record_response(&self, response_id: Option<&str>, model: Option<&str>, usage: &Usage) {
757 if self.is_disabled() {
758 return;
759 }
760 if let Some(id) = response_id {
761 self.record("gen_ai.response.id", id);
762 }
763 if let Some(model) = model {
764 self.record("gen_ai.response.model", model);
765 }
766 self.record_token_usage(usage);
767 }
768}
769
770#[cfg(test)]
771mod tests;