Skip to main content

rig_core/telemetry/
mod.rs

1//! GenAI tracing spans, completion-parent adoption, and opt-in content recording.
2//!
3//! ```
4//! use rig_core::telemetry::{GenAiOperation, SpanBuilder};
5//!
6//! let span = SpanBuilder::new("provider", "model", GenAiOperation::Chat).build();
7//! ```
8use 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/// Macro implementation dependency; public because exported macro expansions
21/// must be able to resolve it from downstream crates.
22#[doc(hidden)]
23pub use tracing as __tracing;
24
25/// Declares a span field without a value, including in [`completion_parent_span!`].
26pub use tracing::field::Empty;
27
28/// Declares caller-supplied header fields and canonical completion fields.
29/// The header must end with a comma; nonempty extras must begin with one.
30#[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        // Both blocks are spliced verbatim into `info_span!`: the header block
38        // must end with a trailing comma, the extras block must begin with one.
39        // Violating either surfaces as an `info_span!` parse error at the call
40        // site, not here.
41        { $($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/// A GenAI operation and its canonical span name. Completion operations carry
83/// message content and may adopt a completion-parent span; the others record
84/// only usage and identity on a fresh span.
85///
86/// Completion operations report the well-known `gen_ai.operation.name` values
87/// of the OpenTelemetry GenAI semantic conventions. Streaming is a delivery
88/// mode, not an operation: it is recorded as `gen_ai.request.stream` (see
89/// [`SpanBuilder::streaming`]).
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum GenAiOperation {
92    /// A chat completion (`chat`), unary or streamed.
93    Chat,
94    /// A Gemini generate-content request (`generate_content`), unary or streamed.
95    GenerateContent,
96    /// A text (or image) embedding request.
97    Embeddings,
98    /// A reranking request.
99    Rerank,
100    /// An audio transcription request.
101    Transcription,
102    /// An image generation request.
103    ImageGeneration,
104    /// An audio generation (text-to-speech) request.
105    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
126/// Span field holding the provider's transport request id (the request-id
127/// response header), recorded on success and on provider errors. GenAI
128/// semantic conventions define no attribute for it. Rig's own completion
129/// spans declare it; it is not required of an adopted parent, which simply
130/// does not record it when undeclared.
131pub const PROVIDER_REQUEST_ID_FIELD: &str = "rig.provider_request_id";
132
133/// Marker field for completion-parent adoption, independent of tracing target.
134/// Its value is ignored. Adoption requires every field in
135/// [`COMPLETION_PARENT_REQUIRED_FIELDS`]; otherwise the builder creates a child
136/// span and warns once per incomplete callsite.
137pub const COMPLETION_PARENT_MARKER_FIELD: &str = "rig.completion_parent";
138
139/// Fields required alongside [`COMPLETION_PARENT_MARKER_FIELD`] for adoption.
140/// Each must be statically declared with [`Empty`] or a value.
141/// [`completion_parent_span!`] declares the complete set.
142pub 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/// Declares a completion-parent span with the marker and all required fields.
160/// Defaults to the current span as parent. An optional `parent: <expr>` between
161/// `target` and `name` accepts any parent supported by [`tracing::info_span!`],
162/// including `None`.
163///
164/// Records the supplied operation and system instructions. Provider and model
165/// fields are populated on adoption. Extra fields must not duplicate the marker
166/// or required fields: recording a duplicate name updates only its first field.
167/// Use [`Empty`] or `Option::<&str>::None` for unset values without a direct
168/// `tracing` dependency.
169///
170/// ```
171/// use rig_core::telemetry::completion_parent_span;
172///
173/// let span = completion_parent_span!(
174///     target: "my_runtime",
175///     name: "chat",
176///     operation: "chat",
177///     system_instructions: Option::<&str>::None,
178///     gen_ai.agent.name = "assistant",
179/// );
180/// ```
181#[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    // Default arm: delegates to the explicit-parent arm so the two cannot
206    // drift in the fields they declare.
207    (
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
225// `#[macro_export]` places the macro at the crate root; re-export it here so
226// it is also reachable at its documented home alongside the contract
227// constants it implements.
228pub use crate::completion_parent_span;
229
230/// Classification of the current span for completion-parent adoption.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232enum CompletionParentVerdict {
233    /// The marker and all required fields are present.
234    Adopt,
235    /// The marker, but the span omits at least one field in
236    /// [`COMPLETION_PARENT_REQUIRED_FIELDS`]. The missing names are computed
237    /// only if a warning is emitted.
238    RejectMissingFields,
239    /// No marker at all: an ordinary ambient span that becomes the parent of a
240    /// fresh `rig::completions` child. Never warns.
241    NotAParent,
242}
243
244/// Returns undeclared required fields for the warning diagnostic.
245fn 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
254/// Classifies span metadata without logging or changing global state.
255fn classify_completion_parent(metadata: &tracing::Metadata<'_>) -> CompletionParentVerdict {
256    let fields = metadata.fields();
257    // Exact match, never a prefix: a runtime field that merely starts with the
258    // marker name (`rig.completion_parent.id`, say) is not the marker and must
259    // not make its span a rejected parent.
260    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
273/// Incomplete parent callsites already reported, bounded by the program's
274/// static callsites. Each callsite receives its own missing-field diagnostic.
275static NEAR_MISS_WARNED: LazyLock<Mutex<HashSet<Identifier>>> =
276    LazyLock::new(|| Mutex::new(HashSet::new()));
277
278/// Clear the per-callsite warn budget.
279///
280/// The budget is process-global, so without this the warning tests are coupled:
281/// whichever runs first consumes the budget for any callsite they share, and the
282/// other sees silence. `cargo nextest` hides that (one process per test) while
283/// `cargo test` exposes it, so the coupling would be green in CI and red
284/// locally — the worst orientation for a latent test bug. Resetting makes each
285/// test independent of callsite identity, ordering, and runner.
286#[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
294/// Warns once per incomplete parent callsite, recovering a poisoned dedup lock.
295/// A subscriber panic must not prevent subsequent completions.
296fn 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            // Release the lock before invoking subscribers, which may re-enter
310            // the builder and deadlock if the guard is still held.
311            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
347/// Builder for a canonical GenAI span.
348///
349/// A completion operation reuses the current span when it declares
350/// [`COMPLETION_PARENT_MARKER_FIELD`] and every field in
351/// [`COMPLETION_PARENT_REQUIRED_FIELDS`], and otherwise opens a
352/// `rig::completions` child of the current span. Every other operation opens a
353/// fresh `rig::modalities` span.
354pub 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    /// Create a span builder for a provider request.
364    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    /// Record whether the request streams its response, as
375    /// `gen_ai.request.stream`. Only completion spans record it.
376    pub fn streaming(mut self, streaming: bool) -> Self {
377        self.streaming = Some(streaming);
378        self
379    }
380
381    /// Set the system instructions sent with the request when sensitive content
382    /// telemetry has been explicitly enabled. Only completion spans record them.
383    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    /// Build the operation's canonical span, or enrich Rig's current
393    /// completion-parent span for a completion operation.
394    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    /// The current span, enriched with this request, when it is a conforming
434    /// completion parent. Warns once per near-miss callsite.
435    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(&current);
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
548/// A provider-only item, named by its `type` and nothing else: its payload
549/// is the provider's, not content.
550fn 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        // Rig's normalized assistant content does not retain provider finish
657        // reasons such as length or content filtering. Avoid claiming a clean
658        // stop when the actual reason is unavailable.
659        "unknown"
660    };
661    vec![TelemetryOutputMessage {
662        role: "assistant",
663        parts: assistant_parts(content),
664        finish_reason,
665    }]
666}
667
668/// Serializes system instructions using the normalized GenAI telemetry shape.
669pub 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
682/// Records serialized model input messages on `gen_ai.input.messages` when
683/// content telemetry is explicitly enabled.
684///
685/// Message content can contain prompts, retrieved context, tool results, and
686/// other sensitive or high-cardinality data. Keep this disabled unless the
687/// caller has explicitly opted in for debugging/observability.
688pub 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
698/// Records serialized model output messages on `gen_ai.output.messages` when
699/// content telemetry is explicitly enabled.
700///
701/// Message content can contain model responses, tool calls, and other sensitive
702/// or high-cardinality data. Keep this disabled unless the caller has explicitly
703/// opted in for debugging/observability.
704pub 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
715/// Records GenAI usage and response metadata on tracing spans.
716pub trait SpanCombinator {
717    /// Record Rig-normalized token usage fields on the span.
718    fn record_token_usage(&self, usage: &Usage);
719
720    /// Record a response's ID, model, and token usage on the span.
721    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        // A counter the provider did not report leaves its span field unset;
731        // a reported zero is recorded as zero.
732        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;