Skip to main content

rig_core/telemetry/
mod.rs

1//! This module primarily concerns being able to orchestrate telemetry across a given pipeline or workflow.
2//! This includes tracing, being able to send traces to an OpenTelemetry collector, setting up your
3//! agents with the correct tracing style so you can emit the right traces for platforms like Langfuse,
4//! and more.
5use crate::completion::{AssistantContent, Message, Usage};
6use crate::message::{
7    DocumentSourceKind, Image, MimeType, Reasoning, ReasoningContent, ToolResult,
8    ToolResultContent, UserContent,
9};
10use base64::Engine;
11use serde::Serialize;
12use std::collections::HashSet;
13use std::sync::{LazyLock, Mutex};
14use tracing::callsite::Identifier;
15
16/// Macro implementation dependency; public because exported macro expansions
17/// must be able to resolve it from downstream crates.
18#[doc(hidden)]
19pub use tracing as __tracing;
20
21/// Marks a span field as declared but not yet valued.
22///
23/// Re-exported so a runtime can declare a contract field through
24/// [`completion_parent_span!`](crate::completion_parent_span) without taking a
25/// direct `tracing` dependency — the crate's own `__tracing` path is hidden,
26/// and `Option::<&str>::None` is the only other portable spelling. `rig-core`
27/// already exposes `tracing` types across this module's public API
28/// ([`CompletionSpanBuilder::build`] returns a [`tracing::Span`]), so this adds
29/// no new semver surface.
30pub use tracing::field::Empty;
31
32/// Implementation detail of [`new_completion_span!`] and
33/// [`completion_parent_span!`](crate::completion_parent_span): declares a span
34/// with the caller's header fields followed by the canonical completion
35/// telemetry fields recorded over a completion's lifetime.
36///
37/// `tracing` bakes a span's field set into static metadata, so the canonical
38/// list can only be single-sourced by a macro that owns the whole
39/// `info_span!` invocation — a `const` list can never be spliced in. This is
40/// the one copy; [`COMPLETION_PARENT_REQUIRED_FIELDS`] is the checklist form
41/// of the same contract and a test asserts the two agree exactly.
42#[doc(hidden)]
43#[macro_export]
44macro_rules! __rig_canonical_completion_span {
45    (
46        target: $target:literal,
47        $(parent: $parent:expr,)?
48        name: $name:literal,
49        // Both blocks are spliced verbatim into `info_span!`: the header block
50        // must end with a trailing comma, the extras block must begin with one.
51        // Violating either surfaces as an `info_span!` parse error at the call
52        // site, not here.
53        { $($header:tt)* }
54        { $($extra:tt)* }
55    ) => {
56        $crate::telemetry::__tracing::info_span!(
57            target: $target,
58            $(parent: $parent,)?
59            $name,
60            $($header)*
61            gen_ai.response.id = $crate::telemetry::__tracing::field::Empty,
62            gen_ai.response.model = $crate::telemetry::__tracing::field::Empty,
63            gen_ai.usage.input_tokens = $crate::telemetry::__tracing::field::Empty,
64            gen_ai.usage.output_tokens = $crate::telemetry::__tracing::field::Empty,
65            gen_ai.usage.cache_read.input_tokens = $crate::telemetry::__tracing::field::Empty,
66            gen_ai.usage.cache_creation.input_tokens = $crate::telemetry::__tracing::field::Empty,
67            gen_ai.usage.tool_use_prompt_tokens = $crate::telemetry::__tracing::field::Empty,
68            gen_ai.usage.reasoning_tokens = $crate::telemetry::__tracing::field::Empty,
69            gen_ai.input.messages = $crate::telemetry::__tracing::field::Empty,
70            gen_ai.output.messages = $crate::telemetry::__tracing::field::Empty
71            $($extra)*
72        )
73    };
74}
75
76macro_rules! new_completion_span {
77    ($name:literal, $provider:expr, $request_model:expr, $operation:expr, $system:expr) => {
78        $crate::__rig_canonical_completion_span!(
79            target: "rig::completions",
80            name: $name,
81            {
82                gen_ai.operation.name = $operation,
83                gen_ai.provider.name = $provider,
84                gen_ai.request.model = $request_model,
85                gen_ai.system_instructions = $system,
86            }
87            {}
88        )
89    };
90}
91
92/// A supported GenAI completion operation and its canonical span name.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum CompletionOperation {
95    /// A chat completion.
96    Chat,
97    /// A streaming chat completion.
98    ChatStreaming,
99    /// A Gemini generate-content request.
100    GenerateContent,
101    /// A Gemini Interactions API request.
102    Interactions,
103    /// A streaming Gemini Interactions API request.
104    InteractionsStreaming,
105}
106
107impl CompletionOperation {
108    fn as_str(self) -> &'static str {
109        match self {
110            Self::Chat => "chat",
111            Self::ChatStreaming => "chat_streaming",
112            Self::GenerateContent => "generate_content",
113            Self::Interactions => "interactions",
114            Self::InteractionsStreaming => "interactions_streaming",
115        }
116    }
117}
118
119/// Core-owned marker field for a runtime span that may absorb provider completion fields.
120///
121/// Runtimes declare this field on their completion-parent spans without having
122/// to share a tracing target. The field's presence (its value is ignored) marks
123/// the span as a *candidate* for adoption, but adoption also requires the span
124/// to statically declare every field in [`COMPLETION_PARENT_REQUIRED_FIELDS`].
125///
126/// This second requirement exists because [`tracing::Span::record`] silently
127/// no-ops for any field absent from a span's static metadata: a span carrying
128/// only the marker would be adopted and then drop every recorded completion
129/// field, losing telemetry with no error. A span missing any required field is
130/// therefore *not* adopted — [`CompletionSpanBuilder::build`] creates a fresh
131/// `rig::completions` child span instead, so telemetry is never silently lost.
132///
133/// The declarative source of the contract is the
134/// [`completion_parent_span!`](crate::completion_parent_span) macro; declaring
135/// a span through it is what guarantees the marker and the field set stay in
136/// agreement. Changing [`COMPLETION_PARENT_REQUIRED_FIELDS`] therefore needs no
137/// marker change to degrade safely: a hand-written span built against an older
138/// field list simply fails the gate above and gets a fresh child span, with a
139/// warning (once per offending callsite) naming what it is missing.
140pub const COMPLETION_PARENT_MARKER_FIELD: &str = "rig.completion_parent";
141
142/// Fields a completion-parent span must statically declare (as
143/// [`tracing::field::Empty`] or a value) to be adopted by
144/// [`CompletionSpanBuilder::build`], alongside [`COMPLETION_PARENT_MARKER_FIELD`].
145///
146/// These mirror the canonical fields the builder's own `rig::completions` span
147/// declares, so an adopted span can absorb the request, response, usage, and
148/// content telemetry recorded over a completion's lifetime without dropping any.
149///
150/// This constant is the *checklist* form of the contract used by the adoption
151/// gate; the *declarative* form is the
152/// [`completion_parent_span!`](crate::completion_parent_span) macro, which is
153/// how a runtime declares a conforming span. A test asserts the two forms
154/// agree exactly, so neither can drift from the other.
155pub const COMPLETION_PARENT_REQUIRED_FIELDS: &[&str] = &[
156    "gen_ai.operation.name",
157    "gen_ai.provider.name",
158    "gen_ai.request.model",
159    "gen_ai.system_instructions",
160    "gen_ai.response.id",
161    "gen_ai.response.model",
162    "gen_ai.usage.input_tokens",
163    "gen_ai.usage.output_tokens",
164    "gen_ai.usage.cache_read.input_tokens",
165    "gen_ai.usage.cache_creation.input_tokens",
166    "gen_ai.usage.tool_use_prompt_tokens",
167    "gen_ai.usage.reasoning_tokens",
168    "gen_ai.input.messages",
169    "gen_ai.output.messages",
170];
171
172/// Declare a completion-parent span conforming to the adoption contract.
173///
174/// This macro is the single declarative source of the contract: it declares
175/// [`COMPLETION_PARENT_MARKER_FIELD`] and every field in
176/// [`COMPLETION_PARENT_REQUIRED_FIELDS`], so a span it builds is always
177/// adoptable by [`CompletionSpanBuilder::build`] and can absorb every field
178/// recorded over the completion's lifetime. Runtimes should declare their
179/// completion-parent spans through it rather than hand-writing the marker and
180/// field list — [`tracing::Span::record`] silently no-ops on undeclared
181/// fields, so a hand-written span that omits one field loses that telemetry
182/// with no error (and a span that omits enough to fail the adoption gate is
183/// not adopted at all). A span declared through this macro tracks the contract
184/// automatically; a hand-written one has to be maintained by hand.
185///
186/// By default the span is explicitly parented on
187/// [`tracing::Span::current()`]; pass `parent: <expr>` between `target` and
188/// `name` to override it with any parent expression [`tracing::info_span!`]
189/// accepts (including `None`). `operation` and `system_instructions` are
190/// declared with the given values; the provider records
191/// `gen_ai.provider.name` and `gen_ai.request.model` at adoption time.
192/// Additional runtime-specific fields may be appended after the named
193/// arguments. Extra fields must not repeat the marker or a required contract
194/// field: a duplicate compiles, but the span then declares two same-named
195/// fields and [`tracing::Span::record`] targets only the first.
196///
197/// Invoking this macro does not require a direct `tracing` dependency. To
198/// declare a field with no value yet, use [`Empty`] (re-exported here for
199/// exactly this reason) or `Option::<&str>::None`; [`tracing::field::Empty`]
200/// itself is the same type but only nameable by a crate that depends on
201/// `tracing` directly.
202///
203/// # Examples
204///
205/// ```
206/// use rig_core::telemetry::completion_parent_span;
207///
208/// let span = completion_parent_span!(
209///     target: "my_runtime",
210///     name: "chat",
211///     operation: "chat",
212///     system_instructions: Option::<&str>::None,
213///     gen_ai.agent.name = "assistant",
214/// );
215/// ```
216#[macro_export]
217macro_rules! completion_parent_span {
218    (
219        target: $target:literal,
220        parent: $parent:expr,
221        name: $name:literal,
222        operation: $operation:expr,
223        system_instructions: $system:expr
224        $(, $($extra:tt)*)?
225    ) => {
226        $crate::__rig_canonical_completion_span!(
227            target: $target,
228            parent: $parent,
229            name: $name,
230            {
231                rig.completion_parent = true,
232                gen_ai.operation.name = $operation,
233                gen_ai.system_instructions = $system,
234                gen_ai.provider.name = $crate::telemetry::__tracing::field::Empty,
235                gen_ai.request.model = $crate::telemetry::__tracing::field::Empty,
236            }
237            { $(, $($extra)*)? }
238        )
239    };
240    // Default arm: delegates to the explicit-parent arm so the two cannot
241    // drift in the fields they declare.
242    (
243        target: $target:literal,
244        name: $name:literal,
245        operation: $operation:expr,
246        system_instructions: $system:expr
247        $(, $($extra:tt)*)?
248    ) => {
249        $crate::completion_parent_span!(
250            target: $target,
251            parent: $crate::telemetry::__tracing::Span::current(),
252            name: $name,
253            operation: $operation,
254            system_instructions: $system
255            $(, $($extra)*)?
256        )
257    };
258}
259
260// `#[macro_export]` places the macro at the crate root; re-export it here so
261// it is also reachable at its documented home alongside the contract
262// constants it implements.
263pub use crate::completion_parent_span;
264
265/// What [`CompletionSpanBuilder::build`] decided about the span that is
266/// current when it runs.
267///
268/// This is the whole adoption decision table in one place, kept free of
269/// side effects so every row can be asserted directly. Emitting the
270/// diagnostics for it is [`warn_once_on_completion_parent_verdict`]'s job.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272enum CompletionParentVerdict {
273    /// The marker plus the complete contract — adopt and enrich.
274    Adopt,
275    /// The marker, but the span omits at least one field in
276    /// [`COMPLETION_PARENT_REQUIRED_FIELDS`]. The missing names are computed
277    /// only if a warning is emitted.
278    RejectMissingFields,
279    /// No marker at all: an ordinary ambient span that becomes the parent of a
280    /// fresh `rig::completions` child. Never warns.
281    NotAParent,
282}
283
284/// Fields in [`COMPLETION_PARENT_REQUIRED_FIELDS`] that `metadata` does not
285/// statically declare. Only called on the warning path — the adoption gate
286/// itself needs a yes/no, not the names, and allocating a `Vec` on every
287/// completion would be waste.
288fn missing_required_fields(metadata: &tracing::Metadata<'_>) -> Vec<&'static str> {
289    let fields = metadata.fields();
290    COMPLETION_PARENT_REQUIRED_FIELDS
291        .iter()
292        .copied()
293        .filter(|name| fields.field(name).is_none())
294        .collect()
295}
296
297/// Classify the span `metadata` as a completion parent. Pure: no logging, no
298/// global state — see [`CompletionParentVerdict`] for the decision table.
299fn classify_completion_parent(metadata: &tracing::Metadata<'_>) -> CompletionParentVerdict {
300    let fields = metadata.fields();
301    // Exact match, never a prefix: a runtime field that merely starts with the
302    // marker name (`rig.completion_parent.id`, say) is not the marker and must
303    // not make its span a rejected parent.
304    if fields.field(COMPLETION_PARENT_MARKER_FIELD).is_none() {
305        return CompletionParentVerdict::NotAParent;
306    }
307    if COMPLETION_PARENT_REQUIRED_FIELDS
308        .iter()
309        .all(|name| fields.field(name).is_some())
310    {
311        CompletionParentVerdict::Adopt
312    } else {
313        CompletionParentVerdict::RejectMissingFields
314    }
315}
316
317/// Near-miss parent callsites already reported.
318///
319/// Keyed per callsite rather than by one process-wide flag: two runtimes can
320/// each declare a near-miss parent, and a single flag would report whichever
321/// won the race and stay silent about the other — while the `missing_fields`
322/// list it printed describes only that one span. Callsites are static, so this
323/// set is bounded by the number of distinct near-miss spans in the program
324/// (normally zero).
325static NEAR_MISS_WARNED: LazyLock<Mutex<HashSet<Identifier>>> =
326    LazyLock::new(|| Mutex::new(HashSet::new()));
327
328/// Clear the per-callsite warn budget.
329///
330/// The budget is process-global, so without this the warning tests are coupled:
331/// whichever runs first consumes the budget for any callsite they share, and the
332/// other sees silence. `cargo nextest` hides that (one process per test) while
333/// `cargo test` exposes it, so the coupling would be green in CI and red
334/// locally — the worst orientation for a latent test bug. Resetting makes each
335/// test independent of callsite identity, ordering, and runner.
336#[cfg(test)]
337fn reset_near_miss_warnings() {
338    NEAR_MISS_WARNED
339        .lock()
340        .unwrap_or_else(|poisoned| poisoned.into_inner())
341        .clear();
342}
343
344/// Surface a verdict that a human should act on, once per offending callsite.
345///
346/// A rejected near-miss degrades safely — the fresh child span loses no
347/// telemetry — but silently, so the operator's only symptom would be a
348/// duplicated span layer in dashboards.
349///
350/// Neither a `Once` nor a poison-propagating lock: a subscriber panic must not
351/// turn a diagnostic into a hard failure on every subsequent completion, and a
352/// dedup set stays perfectly valid across an unrelated panic.
353fn warn_once_on_completion_parent_verdict(
354    verdict: CompletionParentVerdict,
355    metadata: &tracing::Metadata<'_>,
356) {
357    match verdict {
358        CompletionParentVerdict::Adopt | CompletionParentVerdict::NotAParent => {}
359        CompletionParentVerdict::RejectMissingFields => {
360            let first_sighting = {
361                let mut warned = NEAR_MISS_WARNED
362                    .lock()
363                    .unwrap_or_else(|poisoned| poisoned.into_inner());
364                warned.insert(metadata.callsite())
365            };
366            // The guard is released above, before `warn!`, and that scoping is
367            // load-bearing: `warn!` dispatches into arbitrary subscriber code,
368            // and a subscriber that transitively reaches `build` would
369            // deadlock re-entering this non-reentrant lock.
370            if !first_sighting {
371                return;
372            }
373            tracing::warn!(
374                marker = COMPLETION_PARENT_MARKER_FIELD,
375                missing_fields = ?missing_required_fields(metadata),
376                "completion-parent span declares the marker but not every required field \
377                 and is not adopted; provider telemetry lands on a fresh child span \
378                 instead — declare the span with \
379                 `rig_core::telemetry::completion_parent_span!`"
380            );
381        }
382    }
383}
384
385/// Builder for a canonical GenAI completion span.
386///
387/// Runtime spans declaring [`COMPLETION_PARENT_MARKER_FIELD`] and the
388/// [`COMPLETION_PARENT_REQUIRED_FIELDS`] are enriched and reused so one model
389/// turn has exactly one model span. Other ambient spans — and marker spans that
390/// omit a required field — remain parents of a newly created `rig::completions`
391/// span.
392pub struct CompletionSpanBuilder<'a> {
393    provider: &'a str,
394    request_model: &'a str,
395    operation: CompletionOperation,
396    system_instructions: Option<String>,
397}
398
399impl<'a> CompletionSpanBuilder<'a> {
400    /// Create a completion-span builder for a provider request.
401    pub fn new(provider: &'a str, request_model: &'a str, operation: CompletionOperation) -> Self {
402        Self {
403            provider,
404            request_model,
405            operation,
406            system_instructions: None,
407        }
408    }
409
410    /// Set the system instructions sent with the request when sensitive content
411    /// telemetry has been explicitly enabled.
412    pub fn system_instructions(
413        mut self,
414        system_instructions: Option<&'a str>,
415        record_content: bool,
416    ) -> Self {
417        self.system_instructions = system_instructions_json(system_instructions, record_content);
418        self
419    }
420
421    /// Build a canonical completion span or enrich Rig's current completion-parent span.
422    pub fn build(self) -> tracing::Span {
423        let current = tracing::Span::current();
424        if let Some(metadata) = current.metadata() {
425            let verdict = classify_completion_parent(metadata);
426            warn_once_on_completion_parent_verdict(verdict, metadata);
427            if verdict == CompletionParentVerdict::Adopt {
428                current.record("gen_ai.operation.name", self.operation.as_str());
429                current.record("gen_ai.provider.name", self.provider);
430                current.record("gen_ai.request.model", self.request_model);
431                if let Some(system_instructions) = self.system_instructions.as_deref() {
432                    current.record("gen_ai.system_instructions", system_instructions);
433                }
434                return current;
435            }
436        }
437
438        let operation = self.operation.as_str();
439        let system_instructions = self.system_instructions.as_deref();
440        match self.operation {
441            CompletionOperation::Chat => new_completion_span!(
442                "chat",
443                self.provider,
444                self.request_model,
445                operation,
446                system_instructions
447            ),
448            CompletionOperation::ChatStreaming => new_completion_span!(
449                "chat_streaming",
450                self.provider,
451                self.request_model,
452                operation,
453                system_instructions
454            ),
455            CompletionOperation::GenerateContent => new_completion_span!(
456                "generate_content",
457                self.provider,
458                self.request_model,
459                operation,
460                system_instructions
461            ),
462            CompletionOperation::Interactions => new_completion_span!(
463                "interactions",
464                self.provider,
465                self.request_model,
466                operation,
467                system_instructions
468            ),
469            CompletionOperation::InteractionsStreaming => new_completion_span!(
470                "interactions_streaming",
471                self.provider,
472                self.request_model,
473                operation,
474                system_instructions
475            ),
476        }
477    }
478}
479
480#[derive(Serialize)]
481struct TelemetryChatMessage {
482    role: &'static str,
483    parts: Vec<TelemetryPart>,
484}
485
486#[derive(Serialize)]
487struct TelemetryOutputMessage {
488    role: &'static str,
489    parts: Vec<TelemetryPart>,
490    finish_reason: &'static str,
491}
492
493#[derive(Serialize)]
494#[serde(tag = "type", rename_all = "snake_case")]
495enum TelemetryPart {
496    Text {
497        content: String,
498    },
499    ToolCall {
500        #[serde(skip_serializing_if = "Option::is_none")]
501        id: Option<String>,
502        name: String,
503        arguments: serde_json::Value,
504    },
505    ToolCallResponse {
506        #[serde(skip_serializing_if = "Option::is_none")]
507        id: Option<String>,
508        response: serde_json::Value,
509    },
510    Reasoning {
511        content: String,
512    },
513    Uri {
514        #[serde(skip_serializing_if = "Option::is_none")]
515        mime_type: Option<String>,
516        modality: &'static str,
517        uri: String,
518    },
519    File {
520        #[serde(skip_serializing_if = "Option::is_none")]
521        mime_type: Option<String>,
522        modality: &'static str,
523        file_id: String,
524    },
525    Blob {
526        #[serde(skip_serializing_if = "Option::is_none")]
527        mime_type: Option<String>,
528        modality: &'static str,
529        content: String,
530    },
531}
532
533fn media_part<T>(
534    data: &DocumentSourceKind,
535    media_type: Option<&T>,
536    modality: &'static str,
537) -> Option<TelemetryPart>
538where
539    T: MimeType,
540{
541    let mime_type = media_type.map(|media_type| media_type.to_mime_type().to_string());
542    match data {
543        DocumentSourceKind::Url(uri) => Some(TelemetryPart::Uri {
544            mime_type,
545            modality,
546            uri: uri.clone(),
547        }),
548        DocumentSourceKind::FileId(file_id) => Some(TelemetryPart::File {
549            mime_type,
550            modality,
551            file_id: file_id.clone(),
552        }),
553        DocumentSourceKind::Base64(content) => Some(TelemetryPart::Blob {
554            mime_type,
555            modality,
556            content: content.clone(),
557        }),
558        DocumentSourceKind::Raw(content) => Some(TelemetryPart::Blob {
559            mime_type,
560            modality,
561            content: base64::engine::general_purpose::STANDARD.encode(content),
562        }),
563        DocumentSourceKind::String(content) => Some(TelemetryPart::Text {
564            content: content.clone(),
565        }),
566        DocumentSourceKind::Unknown => None,
567    }
568}
569
570fn image_part(image: &Image) -> Option<TelemetryPart> {
571    media_part(&image.data, image.media_type.as_ref(), "image")
572}
573
574fn reasoning_parts(reasoning: &Reasoning) -> Vec<TelemetryPart> {
575    reasoning
576        .content
577        .iter()
578        .map(|content| {
579            let content = match content {
580                ReasoningContent::Text { text, .. } | ReasoningContent::Summary(text) => text,
581                ReasoningContent::Encrypted(content) => content,
582                ReasoningContent::Redacted { data } => data,
583            };
584            TelemetryPart::Reasoning {
585                content: content.clone(),
586            }
587        })
588        .collect()
589}
590
591fn tool_result_response(result: &ToolResult) -> serde_json::Value {
592    let mut content = result
593        .content
594        .iter()
595        .filter_map(|content| match content {
596            ToolResultContent::Text(text) => Some(serde_json::Value::String(text.text.clone())),
597            ToolResultContent::Json { value } => Some(value.clone()),
598            ToolResultContent::Image(image) => {
599                image_part(image).and_then(|part| serde_json::to_value(part).ok())
600            }
601        })
602        .collect::<Vec<_>>();
603
604    if content.len() == 1 {
605        content.pop().unwrap_or(serde_json::Value::Null)
606    } else {
607        serde_json::Value::Array(content)
608    }
609}
610
611fn user_parts(content: &[UserContent]) -> Vec<TelemetryPart> {
612    content
613        .iter()
614        .filter_map(|content| match content {
615            UserContent::Text(text) => Some(TelemetryPart::Text {
616                content: text.text.clone(),
617            }),
618            UserContent::ToolResult(result) => Some(TelemetryPart::ToolCallResponse {
619                id: Some(result.call.as_str().to_owned()),
620                response: tool_result_response(result),
621            }),
622            UserContent::Image(image) => image_part(image),
623            UserContent::Audio(audio) => {
624                media_part(&audio.data, audio.media_type.as_ref(), "audio")
625            }
626            UserContent::Video(video) => {
627                media_part(&video.data, video.media_type.as_ref(), "video")
628            }
629            UserContent::Document(document) => {
630                media_part(&document.data, document.media_type.as_ref(), "document")
631            }
632        })
633        .collect()
634}
635
636fn assistant_parts(content: &[AssistantContent]) -> Vec<TelemetryPart> {
637    content
638        .iter()
639        .flat_map(|content| match content {
640            AssistantContent::Text(text) => vec![TelemetryPart::Text {
641                content: text.text.clone(),
642            }],
643            AssistantContent::ToolCall(tool_call) => vec![TelemetryPart::ToolCall {
644                id: Some(tool_call.id.as_str().to_owned()),
645                name: tool_call.function.name.clone(),
646                arguments: tool_call.function.arguments.clone(),
647            }],
648            AssistantContent::Reasoning(reasoning) => reasoning_parts(reasoning),
649            AssistantContent::Image(image) => image_part(image).into_iter().collect(),
650        })
651        .collect()
652}
653
654fn input_messages(messages: &[Message]) -> Vec<TelemetryChatMessage> {
655    messages
656        .iter()
657        .map(|message| match message {
658            Message::System { content } => TelemetryChatMessage {
659                role: "system",
660                parts: vec![TelemetryPart::Text {
661                    content: content.clone(),
662                }],
663            },
664            Message::User { content } => TelemetryChatMessage {
665                role: "user",
666                parts: user_parts(content),
667            },
668            Message::Assistant { content, .. } => TelemetryChatMessage {
669                role: "assistant",
670                parts: assistant_parts(content),
671            },
672        })
673        .collect()
674}
675
676fn output_messages(content: &[AssistantContent]) -> Vec<TelemetryOutputMessage> {
677    let finish_reason = if content
678        .iter()
679        .any(|content| matches!(content, AssistantContent::ToolCall(_)))
680    {
681        "tool_call"
682    } else {
683        // Rig's normalized assistant content does not retain provider finish
684        // reasons such as length or content filtering. Avoid claiming a clean
685        // stop when the actual reason is unavailable.
686        "unknown"
687    };
688    vec![TelemetryOutputMessage {
689        role: "assistant",
690        parts: assistant_parts(content),
691        finish_reason,
692    }]
693}
694
695/// Serializes system instructions using the normalized GenAI telemetry shape.
696pub fn system_instructions_json(instructions: Option<&str>, enabled: bool) -> Option<String> {
697    if !enabled {
698        return None;
699    }
700
701    instructions.and_then(|instructions| {
702        serde_json::to_string(&vec![TelemetryPart::Text {
703            content: instructions.to_string(),
704        }])
705        .ok()
706    })
707}
708
709/// Records serialized model input messages on `gen_ai.input.messages` when
710/// content telemetry is explicitly enabled.
711///
712/// Message content can contain prompts, retrieved context, tool results, and
713/// other sensitive or high-cardinality data. Keep this disabled unless the
714/// caller has explicitly opted in for debugging/observability.
715pub fn record_model_input(span: &tracing::Span, messages: &[Message], enabled: bool) {
716    if !enabled || span.is_disabled() {
717        return;
718    }
719
720    if let Ok(messages) = serde_json::to_string(&input_messages(messages)) {
721        span.record("gen_ai.input.messages", messages);
722    }
723}
724
725/// Records serialized model output messages on `gen_ai.output.messages` when
726/// content telemetry is explicitly enabled.
727///
728/// Message content can contain model responses, tool calls, and other sensitive
729/// or high-cardinality data. Keep this disabled unless the caller has explicitly
730/// opted in for debugging/observability.
731pub fn record_model_output(span: &tracing::Span, content: &[AssistantContent], enabled: bool) {
732    if !enabled || span.is_disabled() {
733        return;
734    }
735
736    let messages = output_messages(content);
737    if let Ok(messages) = serde_json::to_string(&messages) {
738        span.record("gen_ai.output.messages", messages);
739    }
740}
741
742/// Provider response metadata used to populate GenAI telemetry spans.
743pub trait ProviderResponseExt {
744    /// Provider-native usage type.
745    type Usage: Serialize;
746
747    /// Returns the provider response ID, if supplied.
748    fn get_response_id(&self) -> Option<String>;
749
750    /// Returns the provider response model name, if supplied.
751    fn get_response_model_name(&self) -> Option<String>;
752
753    /// Returns the primary text response, when available.
754    fn get_text_response(&self) -> Option<String>;
755
756    /// Returns provider-native usage metrics, if supplied.
757    fn get_usage(&self) -> Option<Self::Usage>;
758}
759
760/// A trait designed specifically to be used with Spans for the purpose of recording telemetry.
761/// Implemented for [`tracing::Span`] to record GenAI semantic convention fields.
762pub trait SpanCombinator {
763    /// Record Rig-normalized token usage fields on the span.
764    fn record_token_usage(&self, usage: &Usage);
765
766    /// Record provider response metadata such as response ID and model name.
767    fn record_response_metadata<R>(&self, response: &R)
768    where
769        R: ProviderResponseExt;
770}
771
772impl SpanCombinator for tracing::Span {
773    fn record_token_usage(&self, usage: &Usage) {
774        if self.is_disabled() {
775            return;
776        }
777
778        // Zero-valued usage is the documented sentinel for missing provider
779        // usage metrics; leave the span fields unset.
780        if usage.has_values() {
781            self.record("gen_ai.usage.input_tokens", usage.input_tokens);
782            self.record("gen_ai.usage.output_tokens", usage.output_tokens);
783            self.record(
784                "gen_ai.usage.cache_read.input_tokens",
785                usage.cached_input_tokens,
786            );
787            self.record(
788                "gen_ai.usage.cache_creation.input_tokens",
789                usage.cache_creation_input_tokens,
790            );
791            self.record(
792                "gen_ai.usage.tool_use_prompt_tokens",
793                usage.tool_use_prompt_tokens,
794            );
795            self.record("gen_ai.usage.reasoning_tokens", usage.reasoning_tokens);
796        }
797    }
798
799    fn record_response_metadata<R>(&self, response: &R)
800    where
801        R: ProviderResponseExt,
802    {
803        if self.is_disabled() {
804            return;
805        }
806
807        if let Some(id) = response.get_response_id() {
808            self.record("gen_ai.response.id", id);
809        }
810
811        if let Some(model_name) = response.get_response_model_name() {
812            self.record("gen_ai.response.model", model_name);
813        }
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use crate::completion::{AssistantContent, Message, Usage};
821    use serde_json::json;
822    use std::sync::{Arc, Mutex};
823    use tracing::field::{Field, Visit};
824    use tracing::{Id, Subscriber};
825    use tracing_subscriber::layer::{Context, SubscriberExt};
826    use tracing_subscriber::{Layer, Registry, registry::LookupSpan};
827
828    #[test]
829    fn content_attributes_follow_gen_ai_semantic_convention_json_shapes() {
830        assert_eq!(
831            system_instructions_json(Some("follow policy"), true).as_deref(),
832            Some(r#"[{"type":"text","content":"follow policy"}]"#)
833        );
834        assert_eq!(system_instructions_json(Some("secret"), false), None);
835
836        let input = input_messages(&[
837            Message::system("follow policy"),
838            Message::user("hello"),
839            Message::tool_result("call_1", "weather", "sunny"),
840        ]);
841        assert_eq!(
842            serde_json::to_value(input).expect("semantic-convention input DTOs serialize"),
843            json!([
844                {
845                    "role": "system",
846                    "parts": [{"type": "text", "content": "follow policy"}]
847                },
848                {
849                    "role": "user",
850                    "parts": [{"type": "text", "content": "hello"}]
851                },
852                {
853                    "role": "user",
854                    "parts": [{
855                        "type": "tool_call_response",
856                        "id": "call_1",
857                        "response": "sunny"
858                    }]
859                }
860            ])
861        );
862
863        let output = vec![AssistantContent::tool_call(
864            "call_1",
865            "weather",
866            json!({"city": "Paris"}),
867        )];
868        assert_eq!(
869            serde_json::to_value(output_messages(&output))
870                .expect("semantic-convention output DTOs serialize"),
871            json!([{
872                "role": "assistant",
873                "parts": [{
874                    "type": "tool_call",
875                    "id": "call_1",
876                    "name": "weather",
877                    "arguments": {"city": "Paris"}
878                }],
879                "finish_reason": "tool_call"
880            }])
881        );
882
883        let text_output = vec![AssistantContent::text("done")];
884        assert_eq!(
885            serde_json::to_value(output_messages(&text_output))
886                .expect("semantic-convention text output DTOs serialize"),
887            json!([{
888                "role": "assistant",
889                "parts": [{"type": "text", "content": "done"}],
890                "finish_reason": "unknown"
891            }])
892        );
893    }
894
895    #[derive(Clone, Default)]
896    struct CapturedFields(Arc<Mutex<Vec<(String, u64)>>>);
897
898    impl CapturedFields {
899        fn push(&self, name: &str, value: u64) {
900            if let Ok(mut fields) = self.0.lock() {
901                fields.push((name.to_string(), value));
902            }
903        }
904
905        fn contains(&self, name: &str, value: u64) -> bool {
906            self.0.lock().is_ok_and(|fields| {
907                fields
908                    .iter()
909                    .any(|field| field == &(name.to_string(), value))
910            })
911        }
912    }
913
914    struct FieldCaptureLayer {
915        fields: CapturedFields,
916    }
917
918    impl<S> Layer<S> for FieldCaptureLayer
919    where
920        S: Subscriber,
921        S: for<'lookup> LookupSpan<'lookup>,
922    {
923        fn on_record(&self, _span: &Id, values: &tracing::span::Record<'_>, _ctx: Context<'_, S>) {
924            values.record(&mut FieldCaptureVisitor {
925                fields: self.fields.clone(),
926            });
927        }
928    }
929
930    struct FieldCaptureVisitor {
931        fields: CapturedFields,
932    }
933
934    impl Visit for FieldCaptureVisitor {
935        fn record_u64(&mut self, field: &Field, value: u64) {
936            self.fields.push(field.name(), value);
937        }
938
939        fn record_debug(&mut self, _field: &Field, _value: &dyn std::fmt::Debug) {}
940    }
941
942    /// WARN-level events, rendered as `field=value` pairs joined with the
943    /// event's message, in emission order.
944    #[derive(Clone, Default)]
945    struct CapturedWarnings(Arc<Mutex<Vec<String>>>);
946
947    impl CapturedWarnings {
948        fn push(&self, rendered: String) {
949            if let Ok(mut events) = self.0.lock() {
950                events.push(rendered);
951            }
952        }
953
954        /// Drains, so a test can assert on one phase and then assert that a
955        /// later phase added nothing. A cloning read would make the second
956        /// assertion see the first phase's events and quietly fail.
957        fn take(&self) -> Vec<String> {
958            self.0
959                .lock()
960                .map(|mut events| std::mem::take(&mut *events))
961                .unwrap_or_default()
962        }
963    }
964
965    struct WarningCaptureLayer {
966        warnings: CapturedWarnings,
967    }
968
969    impl<S> Layer<S> for WarningCaptureLayer
970    where
971        S: Subscriber,
972        S: for<'lookup> LookupSpan<'lookup>,
973    {
974        fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
975            if *event.metadata().level() != tracing::Level::WARN {
976                return;
977            }
978            let mut visitor = WarningCaptureVisitor::default();
979            event.record(&mut visitor);
980            self.warnings.push(visitor.rendered);
981        }
982    }
983
984    #[derive(Default)]
985    struct WarningCaptureVisitor {
986        rendered: String,
987    }
988
989    impl Visit for WarningCaptureVisitor {
990        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
991            use std::fmt::Write;
992
993            // `missing_fields = ?vec` arrives here; the message itself arrives
994            // as the reserved `message` field. Both matter to the assertions,
995            // so render every field rather than special-casing.
996            let _ = write!(&mut self.rendered, " {}={value:?}", field.name());
997        }
998
999        fn record_str(&mut self, field: &Field, value: &str) {
1000            use std::fmt::Write;
1001
1002            let _ = write!(&mut self.rendered, " {}={value}", field.name());
1003        }
1004    }
1005
1006    #[derive(Clone, Default)]
1007    struct CapturedSpan(Arc<Mutex<Option<CapturedSpanData>>>);
1008
1009    struct CapturedSpanData {
1010        name: String,
1011        target: String,
1012        parent_name: Option<String>,
1013        fields: Vec<String>,
1014        initial_values: Vec<(String, String)>,
1015        recorded_values: Vec<(String, String)>,
1016    }
1017
1018    struct SpanCaptureLayer {
1019        span: CapturedSpan,
1020    }
1021
1022    #[derive(Default)]
1023    struct StringFieldVisitor {
1024        values: Vec<(String, String)>,
1025    }
1026
1027    impl Visit for StringFieldVisitor {
1028        fn record_str(&mut self, field: &Field, value: &str) {
1029            self.values
1030                .push((field.name().to_owned(), value.to_owned()));
1031        }
1032
1033        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1034            self.values
1035                .push((field.name().to_owned(), format!("{value:?}")));
1036        }
1037    }
1038
1039    impl<S> Layer<S> for SpanCaptureLayer
1040    where
1041        S: Subscriber,
1042        S: for<'lookup> LookupSpan<'lookup>,
1043    {
1044        fn on_new_span(
1045            &self,
1046            attrs: &tracing::span::Attributes<'_>,
1047            _id: &Id,
1048            ctx: Context<'_, S>,
1049        ) {
1050            if let Ok(mut captured) = self.span.0.lock() {
1051                let mut visitor = StringFieldVisitor::default();
1052                attrs.record(&mut visitor);
1053                let parent_name = if let Some(parent) = attrs.parent() {
1054                    ctx.span(parent)
1055                        .map(|span| span.metadata().name().to_owned())
1056                } else if attrs.is_contextual() {
1057                    ctx.lookup_current()
1058                        .map(|span| span.metadata().name().to_owned())
1059                } else {
1060                    None
1061                };
1062                *captured = Some(CapturedSpanData {
1063                    name: attrs.metadata().name().to_owned(),
1064                    target: attrs.metadata().target().to_owned(),
1065                    parent_name,
1066                    fields: attrs
1067                        .metadata()
1068                        .fields()
1069                        .iter()
1070                        .map(|field| field.name().to_owned())
1071                        .collect(),
1072                    initial_values: visitor.values,
1073                    recorded_values: Vec::new(),
1074                });
1075            }
1076        }
1077
1078        fn on_record(&self, _span: &Id, values: &tracing::span::Record<'_>, _ctx: Context<'_, S>) {
1079            if let Ok(mut captured) = self.span.0.lock()
1080                && let Some(captured) = captured.as_mut()
1081            {
1082                let mut visitor = StringFieldVisitor::default();
1083                values.record(&mut visitor);
1084                captured.recorded_values.extend(visitor.values);
1085            }
1086        }
1087    }
1088
1089    fn contains_string(values: &[(String, String)], field: &str, value: &str) -> bool {
1090        values
1091            .iter()
1092            .any(|candidate| candidate == &(field.to_owned(), value.to_owned()))
1093    }
1094
1095    #[test]
1096    fn completion_span_uses_canonical_names_fields_and_initial_attributes() {
1097        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1098
1099        for (operation, expected_name) in [
1100            (CompletionOperation::Chat, "chat"),
1101            (CompletionOperation::ChatStreaming, "chat_streaming"),
1102            (CompletionOperation::GenerateContent, "generate_content"),
1103            (CompletionOperation::Interactions, "interactions"),
1104            (
1105                CompletionOperation::InteractionsStreaming,
1106                "interactions_streaming",
1107            ),
1108        ] {
1109            let captured = CapturedSpan::default();
1110            let subscriber = Registry::default().with(SpanCaptureLayer {
1111                span: captured.clone(),
1112            });
1113            tracing::subscriber::with_default(subscriber, || {
1114                let span = CompletionSpanBuilder::new("openai", "gpt-5", operation)
1115                    .system_instructions(Some("system prompt"), true)
1116                    .build();
1117                assert!(!span.is_disabled());
1118            });
1119
1120            let Ok(captured) = captured.0.lock() else {
1121                panic!("captured span lock poisoned");
1122            };
1123            let Some(span) = captured.as_ref() else {
1124                panic!("completion span was not created");
1125            };
1126            assert_eq!(span.name, expected_name);
1127            assert_eq!(span.target, "rig::completions");
1128            assert_eq!(span.parent_name, None);
1129            for (field, value) in [
1130                ("gen_ai.operation.name", expected_name),
1131                ("gen_ai.provider.name", "openai"),
1132                ("gen_ai.request.model", "gpt-5"),
1133                (
1134                    "gen_ai.system_instructions",
1135                    r#"[{"type":"text","content":"system prompt"}]"#,
1136                ),
1137            ] {
1138                assert!(
1139                    contains_string(&span.initial_values, field, value),
1140                    "missing initial {field}={value}"
1141                );
1142            }
1143            assert!(span.recorded_values.is_empty());
1144            assert!(
1145                !span
1146                    .initial_values
1147                    .iter()
1148                    .any(|(field, _)| field == "gen_ai.response.model")
1149            );
1150            for field in COMPLETION_PARENT_REQUIRED_FIELDS {
1151                assert!(
1152                    span.fields.iter().any(|candidate| candidate == field),
1153                    "missing {field}"
1154                );
1155            }
1156        }
1157    }
1158
1159    /// The default arm parents on the ambient span, and an explicit `parent:`
1160    /// overrides it.
1161    ///
1162    /// A regression to `parent: None` in the default arm would root every
1163    /// completion-parent span, detaching it from the surrounding trace. No
1164    /// field-set assertion in this module can see that — the fields are
1165    /// identical either way — while an operator sees completion spans floating
1166    /// as roots instead of nesting under the agent span.
1167    #[test]
1168    fn completion_parent_span_macro_honours_its_parent_argument() {
1169        /// `SpanCaptureLayer` has no target filter and keeps only the most
1170        /// recent span, so read it immediately after the span under test is
1171        /// created, and confirm the target before trusting the parent.
1172        fn captured_parent(captured: &CapturedSpan) -> Option<String> {
1173            let Ok(captured) = captured.0.lock() else {
1174                panic!("captured span lock poisoned");
1175            };
1176            let Some(span) = captured.as_ref() else {
1177                panic!("completion-parent span was not captured");
1178            };
1179            assert_eq!(span.target, "third_party_runtime");
1180            span.parent_name.clone()
1181        }
1182
1183        let captured = CapturedSpan::default();
1184        let subscriber = Registry::default().with(SpanCaptureLayer {
1185            span: captured.clone(),
1186        });
1187        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1188        tracing::subscriber::with_default(subscriber, || {
1189            let ambient = tracing::info_span!(target: "application", "ambient");
1190
1191            // Default arm: nests under whatever span is current.
1192            ambient.in_scope(|| {
1193                let _default_arm = completion_parent_span!(
1194                    target: "third_party_runtime",
1195                    name: "chat",
1196                    operation: Empty,
1197                    system_instructions: Option::<&str>::None,
1198                );
1199            });
1200            assert_eq!(captured_parent(&captured).as_deref(), Some("ambient"));
1201
1202            // Explicit arm: the caller's parent wins over the ambient span, so
1203            // this one is a root despite `ambient` being current.
1204            ambient.in_scope(|| {
1205                let _explicit_arm = completion_parent_span!(
1206                    target: "third_party_runtime",
1207                    parent: None,
1208                    name: "chat",
1209                    operation: Empty,
1210                    system_instructions: Option::<&str>::None,
1211                );
1212            });
1213            assert_eq!(captured_parent(&captured), None);
1214        });
1215    }
1216
1217    #[test]
1218    fn unrelated_ambient_span_is_parent_not_adopted() {
1219        let captured = CapturedSpan::default();
1220        let subscriber = Registry::default().with(SpanCaptureLayer {
1221            span: captured.clone(),
1222        });
1223        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1224        tracing::subscriber::with_default(subscriber, || {
1225            let ambient = tracing::info_span!(target: "application", "ambient");
1226            let _guard = ambient.enter();
1227            let span =
1228                CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1229            assert_ne!(span.id(), ambient.id());
1230        });
1231
1232        let Ok(captured) = captured.0.lock() else {
1233            panic!("captured span lock poisoned");
1234        };
1235        let Some(span) = captured.as_ref() else {
1236            panic!("completion span was not captured");
1237        };
1238        assert_eq!(span.target, "rig::completions");
1239        assert_eq!(span.parent_name.as_deref(), Some("ambient"));
1240    }
1241
1242    #[test]
1243    fn marker_span_missing_required_fields_is_not_adopted() {
1244        // A span that carries the marker but omits required canonical fields
1245        // (here: everything past `gen_ai.request.model`) must NOT be adopted.
1246        // Adopting it would silently drop the response/usage/content telemetry
1247        // that `Span::record` no-ops on for undeclared fields. Instead the
1248        // builder creates a fresh `rig::completions` child so nothing is lost.
1249        let captured = CapturedSpan::default();
1250        let subscriber = Registry::default().with(SpanCaptureLayer {
1251            span: captured.clone(),
1252        });
1253        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1254        tracing::subscriber::with_default(subscriber, || {
1255            // Deliberately hand-written (not `completion_parent_span!`): the
1256            // point is a marker span that fails to declare required fields.
1257            let partial_marker = tracing::info_span!(
1258                target: "third_party_runtime",
1259                "chat",
1260                rig.completion_parent = true,
1261                gen_ai.operation.name = tracing::field::Empty,
1262                gen_ai.provider.name = tracing::field::Empty,
1263                gen_ai.request.model = tracing::field::Empty,
1264            );
1265            // Premise check: the hand-written marker literal above must still
1266            // match the constant — if the marker is ever renamed this fails
1267            // first, pointing at the stale literal, so the test cannot keep
1268            // passing for the wrong reason (no marker at all, rather than the
1269            // marker with fields missing).
1270            let Some(metadata) = partial_marker.metadata() else {
1271                panic!("partial marker span was disabled");
1272            };
1273            assert!(
1274                metadata
1275                    .fields()
1276                    .field(COMPLETION_PARENT_MARKER_FIELD)
1277                    .is_some(),
1278                "hand-written marker literal is stale; update it to {COMPLETION_PARENT_MARKER_FIELD}"
1279            );
1280            let _guard = partial_marker.enter();
1281            let span =
1282                CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1283            assert_ne!(span.id(), partial_marker.id());
1284        });
1285
1286        let Ok(captured) = captured.0.lock() else {
1287            panic!("captured span lock poisoned");
1288        };
1289        let Some(span) = captured.as_ref() else {
1290            panic!("completion span was not captured");
1291        };
1292        // A canonical child span is created and parented under the marker span,
1293        // and it carries the completion fields the marker span could not absorb.
1294        assert_eq!(span.target, "rig::completions");
1295        assert_eq!(span.parent_name.as_deref(), Some("chat"));
1296        for (field, value) in [
1297            ("gen_ai.operation.name", "chat"),
1298            ("gen_ai.provider.name", "openai"),
1299            ("gen_ai.request.model", "gpt-5"),
1300        ] {
1301            assert!(contains_string(&span.initial_values, field, value));
1302        }
1303    }
1304
1305    #[test]
1306    fn completion_parent_span_macro_matches_the_contract_exactly() {
1307        use std::collections::HashSet;
1308
1309        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1310        tracing::subscriber::with_default(Registry::default(), || {
1311            let span = completion_parent_span!(
1312                target: "contract_test",
1313                name: "chat",
1314                operation: "chat",
1315                system_instructions: Option::<&str>::None,
1316            );
1317            let Some(metadata) = span.metadata() else {
1318                panic!("contract span was disabled");
1319            };
1320            let declared: HashSet<&str> =
1321                metadata.fields().iter().map(|field| field.name()).collect();
1322            let expected: HashSet<&str> = COMPLETION_PARENT_REQUIRED_FIELDS
1323                .iter()
1324                .copied()
1325                .chain([COMPLETION_PARENT_MARKER_FIELD])
1326                .collect();
1327            // Exact equality in both directions: a field added to the macro
1328            // but not the constant (or vice versa) is drift, not a superset.
1329            assert_eq!(declared, expected);
1330            // Duplicate field names collapse in a `HashSet`, so also pin the
1331            // count: set equality alone cannot catch a field declared twice.
1332            assert_eq!(metadata.fields().len(), expected.len());
1333            assert_eq!(
1334                classify_completion_parent(metadata),
1335                CompletionParentVerdict::Adopt
1336            );
1337
1338            // The explicit-`parent:` arm declares the identical field set.
1339            let span = completion_parent_span!(
1340                target: "contract_test",
1341                parent: None,
1342                name: "chat",
1343                operation: "chat",
1344                system_instructions: Option::<&str>::None,
1345            );
1346            let Some(metadata) = span.metadata() else {
1347                panic!("contract span with explicit parent was disabled");
1348            };
1349            let declared: HashSet<&str> =
1350                metadata.fields().iter().map(|field| field.name()).collect();
1351            assert_eq!(declared, expected);
1352            assert_eq!(metadata.fields().len(), expected.len());
1353            assert_eq!(
1354                classify_completion_parent(metadata),
1355                CompletionParentVerdict::Adopt
1356            );
1357
1358            // Runtime-specific extra fields are additive on top of the contract.
1359            let span = completion_parent_span!(
1360                target: "contract_test",
1361                name: "chat",
1362                operation: "chat",
1363                system_instructions: Option::<&str>::None,
1364                gen_ai.agent.name = "assistant",
1365            );
1366            let Some(metadata) = span.metadata() else {
1367                panic!("contract span with extras was disabled");
1368            };
1369            let declared: HashSet<&str> =
1370                metadata.fields().iter().map(|field| field.name()).collect();
1371            let expected: HashSet<&str> =
1372                expected.into_iter().chain(["gen_ai.agent.name"]).collect();
1373            assert_eq!(declared, expected);
1374            assert_eq!(metadata.fields().len(), expected.len());
1375            assert_eq!(
1376                classify_completion_parent(metadata),
1377                CompletionParentVerdict::Adopt
1378            );
1379        });
1380    }
1381
1382    #[test]
1383    fn canonical_completion_span_declares_exactly_the_required_fields() {
1384        use std::collections::HashSet;
1385
1386        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1387        tracing::subscriber::with_default(Registry::default(), || {
1388            let span =
1389                CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1390            let Some(metadata) = span.metadata() else {
1391                panic!("completion span was disabled");
1392            };
1393            let declared: HashSet<&str> =
1394                metadata.fields().iter().map(|field| field.name()).collect();
1395            let expected: HashSet<&str> =
1396                COMPLETION_PARENT_REQUIRED_FIELDS.iter().copied().collect();
1397            // The adoption checklist and the span the builder itself creates
1398            // must be the same set, or an adopted parent could not absorb
1399            // every field the builder records.
1400            assert_eq!(declared, expected);
1401            // Duplicate field names collapse in a `HashSet`, so also pin the
1402            // count: set equality alone cannot catch a field declared twice.
1403            assert_eq!(metadata.fields().len(), expected.len());
1404        });
1405    }
1406
1407    #[test]
1408    fn completion_parent_required_fields_are_pinned() {
1409        // Changing this list is a contract change. An adopted parent declares
1410        // its fields statically, so a runtime whose span was hand-written
1411        // against the old list stops being adopted once the list moves — it
1412        // degrades gracefully (fresh child span, one-time warning naming what
1413        // is missing), but it does degrade. Confirm that is intended, note it
1414        // in the CHANGELOG, then update this snapshot.
1415        //
1416        // This is the only test that notices. Every other contract test
1417        // compares the three forms of the contract to each other, so a
1418        // *coherent* change — a field added to both this constant and the
1419        // macro — leaves them all agreeing, and green.
1420        assert_eq!(COMPLETION_PARENT_MARKER_FIELD, "rig.completion_parent");
1421        assert_eq!(
1422            COMPLETION_PARENT_REQUIRED_FIELDS,
1423            &[
1424                "gen_ai.operation.name",
1425                "gen_ai.provider.name",
1426                "gen_ai.request.model",
1427                "gen_ai.system_instructions",
1428                "gen_ai.response.id",
1429                "gen_ai.response.model",
1430                "gen_ai.usage.input_tokens",
1431                "gen_ai.usage.output_tokens",
1432                "gen_ai.usage.cache_read.input_tokens",
1433                "gen_ai.usage.cache_creation.input_tokens",
1434                "gen_ai.usage.tool_use_prompt_tokens",
1435                "gen_ai.usage.reasoning_tokens",
1436                "gen_ai.input.messages",
1437                "gen_ai.output.messages",
1438            ]
1439        );
1440    }
1441
1442    /// Every row of the adoption decision table, asserted against the pure
1443    /// classifier so no global warn-once state is involved.
1444    #[test]
1445    fn classify_completion_parent_covers_the_decision_table() {
1446        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1447        tracing::subscriber::with_default(Registry::default(), || {
1448            let verdict = |span: &tracing::Span| {
1449                let Some(metadata) = span.metadata() else {
1450                    panic!("classifier fixture span was disabled");
1451                };
1452                classify_completion_parent(metadata)
1453            };
1454
1455            // Marker + full contract.
1456            let conforming = completion_parent_span!(
1457                target: "classifier_test",
1458                name: "chat",
1459                operation: tracing::field::Empty,
1460                system_instructions: tracing::field::Empty,
1461            );
1462            assert_eq!(verdict(&conforming), CompletionParentVerdict::Adopt);
1463
1464            // Marker, incomplete contract.
1465            let partial = tracing::info_span!(
1466                target: "classifier_test",
1467                "chat",
1468                rig.completion_parent = true,
1469                gen_ai.operation.name = tracing::field::Empty,
1470            );
1471            assert_eq!(
1472                verdict(&partial),
1473                CompletionParentVerdict::RejectMissingFields
1474            );
1475            let Some(partial_metadata) = partial.metadata() else {
1476                panic!("classifier fixture span was disabled");
1477            };
1478            // The names only get computed on the warning path, so pin them here.
1479            assert_eq!(
1480                missing_required_fields(partial_metadata),
1481                COMPLETION_PARENT_REQUIRED_FIELDS
1482                    .iter()
1483                    .copied()
1484                    .filter(|name| *name != "gen_ai.operation.name")
1485                    .collect::<Vec<_>>()
1486            );
1487
1488            // An ordinary ambient span.
1489            let ambient = tracing::info_span!(target: "application", "ambient");
1490            assert_eq!(verdict(&ambient), CompletionParentVerdict::NotAParent);
1491
1492            // Marker detection is an exact field-name match, never a prefix: a
1493            // runtime field that merely starts with the marker name must not
1494            // make its span a rejected parent and warn at a runtime that never
1495            // opted in.
1496            let lookalike = tracing::info_span!(
1497                target: "application",
1498                "ambient",
1499                rig.completion_parent.id = "abc",
1500                rig.completion_parent_id = "abc",
1501            );
1502            assert_eq!(verdict(&lookalike), CompletionParentVerdict::NotAParent);
1503        });
1504    }
1505
1506    /// The near-miss diagnostic is the only thing that makes a rejected parent
1507    /// visible to an operator — otherwise the sole symptom is a duplicated span
1508    /// layer in dashboards — so its message, its `missing_fields` payload, and
1509    /// its once-per-callsite budget all need pinning.
1510    #[test]
1511    fn near_miss_completion_parent_warns_once_per_callsite() {
1512        let warnings = CapturedWarnings::default();
1513        let subscriber = Registry::default().with(WarningCaptureLayer {
1514            warnings: warnings.clone(),
1515        });
1516        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1517        // The warn budget is process-global; claim a clean one rather than
1518        // relying on this test's fixture span owning a callsite no other test
1519        // touches.
1520        reset_near_miss_warnings();
1521        tracing::subscriber::with_default(subscriber, || {
1522            // The `warn!` callsite lives in `warn_once_on_completion_parent_verdict`
1523            // and is shared with every other near-miss test, so its interest may
1524            // already be cached as `never` from a run under a different
1525            // subscriber. Same hazard `test_utils::scoped_tracing_subscriber_guard`
1526            // documents; same fix used in
1527            // `agent::prompt_request::streaming`'s scoped-subscriber tests.
1528            tracing::callsite::rebuild_interest_cache();
1529
1530            let near_miss = tracing::info_span!(
1531                target: "third_party_runtime",
1532                "chat",
1533                rig.completion_parent = true,
1534                gen_ai.operation.name = tracing::field::Empty,
1535            );
1536            let _guard = near_miss.enter();
1537            CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1538            // Second completion under the *same* span callsite: the budget is
1539            // per callsite, so this one must stay silent.
1540            CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1541        });
1542
1543        let captured = warnings.take();
1544        assert_eq!(
1545            captured.len(),
1546            1,
1547            "a near-miss callsite warns exactly once, got: {captured:?}"
1548        );
1549        let Some(message) = captured.first() else {
1550            panic!("near miss did not warn");
1551        };
1552        assert!(
1553            message.contains("gen_ai.provider.name"),
1554            "warning must name the missing fields, got: {message}"
1555        );
1556        assert!(
1557            message.contains("completion_parent_span!"),
1558            "warning must point at the supported fix, got: {message}"
1559        );
1560    }
1561
1562    /// The property that justifies keying the budget on the callsite rather
1563    /// than a single process-wide flag: two runtimes each declaring a broken
1564    /// parent are both reported. A global flag would report whichever ran first
1565    /// and stay silent about the other — and every other test in this module
1566    /// passes under that behaviour, so this is the only one that pins it.
1567    #[test]
1568    fn distinct_near_miss_callsites_each_warn() {
1569        let warnings = CapturedWarnings::default();
1570        let subscriber = Registry::default().with(WarningCaptureLayer {
1571            warnings: warnings.clone(),
1572        });
1573        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1574        reset_near_miss_warnings();
1575        tracing::subscriber::with_default(subscriber, || {
1576            tracing::callsite::rebuild_interest_cache();
1577
1578            // Two separate `info_span!` invocations, and they must stay
1579            // separate: the callsite *is* the dedup key, so extracting these
1580            // into a shared helper collapses them into one and this test would
1581            // assert 1, not 2. `reset_near_miss_warnings` cannot protect this
1582            // test the way it protects the others — distinct callsites are the
1583            // thing under test.
1584            let first = tracing::info_span!(
1585                target: "runtime_a",
1586                "chat",
1587                rig.completion_parent = true,
1588                gen_ai.operation.name = tracing::field::Empty,
1589            );
1590            first.in_scope(|| {
1591                CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1592            });
1593
1594            let second = tracing::info_span!(
1595                target: "runtime_b",
1596                "chat",
1597                rig.completion_parent = true,
1598                gen_ai.operation.name = tracing::field::Empty,
1599            );
1600            second.in_scope(|| {
1601                CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1602            });
1603        });
1604
1605        let captured = warnings.take();
1606        assert_eq!(
1607            captured.len(),
1608            2,
1609            "each offending callsite warns once, got: {captured:?}"
1610        );
1611    }
1612
1613    /// The happy path must stay quiet: a conforming parent is adopted silently,
1614    /// so the diagnostic above cannot become background noise on every
1615    /// completion.
1616    #[test]
1617    fn conforming_completion_parent_never_warns() {
1618        let warnings = CapturedWarnings::default();
1619        let subscriber = Registry::default().with(WarningCaptureLayer {
1620            warnings: warnings.clone(),
1621        });
1622        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1623        // Claim a clean budget: the control below must be able to warn even if
1624        // another test already reported this fixture's callsite.
1625        reset_near_miss_warnings();
1626        tracing::subscriber::with_default(subscriber, || {
1627            tracing::callsite::rebuild_interest_cache();
1628
1629            // Control. Asserting an absence proves nothing unless the warning
1630            // pipe is known live in *this* subscriber: without this, the
1631            // assertions below pass just as happily when the diagnostic has
1632            // been deleted, or when callsite interest is stuck at `never`.
1633            let near_miss = tracing::info_span!(
1634                target: "third_party_runtime",
1635                "chat",
1636                rig.completion_parent = true,
1637                gen_ai.operation.name = tracing::field::Empty,
1638            );
1639            near_miss.in_scope(|| {
1640                CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1641            });
1642            assert_eq!(
1643                warnings.take().len(),
1644                1,
1645                "control: a near miss must warn, or this test cannot detect silence"
1646            );
1647
1648            let conforming = completion_parent_span!(
1649                target: "third_party_runtime",
1650                name: "chat",
1651                operation: Empty,
1652                system_instructions: Option::<&str>::None,
1653            );
1654            let _guard = conforming.enter();
1655            CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1656
1657            // An ordinary ambient span is not a parent at all, and must not warn
1658            // either — a runtime that never opted in should never hear about
1659            // this contract.
1660            drop(_guard);
1661            let ambient = tracing::info_span!(target: "application", "ambient");
1662            let _ambient_guard = ambient.enter();
1663            CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1664        });
1665
1666        // The control drained the buffer, so anything here was emitted by the
1667        // conforming or ambient span.
1668        let captured = warnings.take();
1669        assert!(
1670            captured.is_empty(),
1671            "adoption and non-participation are both silent, got: {captured:?}"
1672        );
1673    }
1674
1675    #[test]
1676    fn agent_chat_span_is_adopted_and_enriched() {
1677        let captured = CapturedSpan::default();
1678        let subscriber = Registry::default().with(SpanCaptureLayer {
1679            span: captured.clone(),
1680        });
1681        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1682        tracing::subscriber::with_default(subscriber, || {
1683            let completion_parent = completion_parent_span!(
1684                target: "rig::agent_chat",
1685                name: "chat_streaming",
1686                operation: tracing::field::Empty,
1687                system_instructions: tracing::field::Empty,
1688            );
1689            let _guard = completion_parent.enter();
1690            let span = CompletionSpanBuilder::new(
1691                "anthropic",
1692                "claude-sonnet",
1693                CompletionOperation::ChatStreaming,
1694            )
1695            .system_instructions(Some("provider system"), true)
1696            .build();
1697            assert_eq!(span.id(), completion_parent.id());
1698        });
1699
1700        let Ok(captured) = captured.0.lock() else {
1701            panic!("captured span lock poisoned");
1702        };
1703        let Some(span) = captured.as_ref() else {
1704            panic!("completion-parent span was not captured");
1705        };
1706        assert_eq!(span.target, "rig::agent_chat");
1707        for (field, value) in [
1708            ("gen_ai.operation.name", "chat_streaming"),
1709            ("gen_ai.provider.name", "anthropic"),
1710            ("gen_ai.request.model", "claude-sonnet"),
1711            (
1712                "gen_ai.system_instructions",
1713                r#"[{"type":"text","content":"provider system"}]"#,
1714            ),
1715        ] {
1716            assert!(contains_string(&span.recorded_values, field, value));
1717        }
1718    }
1719
1720    #[test]
1721    fn neutral_completion_parent_span_is_adopted_and_enriched() {
1722        let captured = CapturedSpan::default();
1723        let subscriber = Registry::default().with(SpanCaptureLayer {
1724            span: captured.clone(),
1725        });
1726        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1727        tracing::subscriber::with_default(subscriber, || {
1728            let completion_parent = completion_parent_span!(
1729                target: "test_runtime",
1730                name: "chat",
1731                operation: tracing::field::Empty,
1732                system_instructions: tracing::field::Empty,
1733            );
1734            let _guard = completion_parent.enter();
1735            let span = CompletionSpanBuilder::new(
1736                "neutral-provider",
1737                "neutral-model",
1738                CompletionOperation::Chat,
1739            )
1740            .build();
1741            assert_eq!(span.id(), completion_parent.id());
1742        });
1743
1744        let Ok(captured) = captured.0.lock() else {
1745            panic!("captured span lock poisoned");
1746        };
1747        let Some(span) = captured.as_ref() else {
1748            panic!("neutral completion-parent span was not captured");
1749        };
1750        assert_eq!(span.target, "test_runtime");
1751        for (field, value) in [
1752            ("gen_ai.operation.name", "chat"),
1753            ("gen_ai.provider.name", "neutral-provider"),
1754            ("gen_ai.request.model", "neutral-model"),
1755        ] {
1756            assert!(contains_string(&span.recorded_values, field, value));
1757        }
1758    }
1759
1760    #[test]
1761    fn absent_provider_system_does_not_overwrite_agent_instructions() {
1762        let captured = CapturedSpan::default();
1763        let subscriber = Registry::default().with(SpanCaptureLayer {
1764            span: captured.clone(),
1765        });
1766        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1767        tracing::subscriber::with_default(subscriber, || {
1768            let completion_parent = completion_parent_span!(
1769                target: "test_runtime",
1770                name: "chat",
1771                operation: tracing::field::Empty,
1772                system_instructions: "effective agent instructions",
1773            );
1774            let _guard = completion_parent.enter();
1775            CompletionSpanBuilder::new("openai", "gpt-5", CompletionOperation::Chat).build();
1776        });
1777
1778        let Ok(captured) = captured.0.lock() else {
1779            panic!("captured span lock poisoned");
1780        };
1781        let Some(span) = captured.as_ref() else {
1782            panic!("completion-parent span was not captured");
1783        };
1784        assert!(contains_string(
1785            &span.initial_values,
1786            "gen_ai.system_instructions",
1787            "effective agent instructions"
1788        ));
1789        assert!(
1790            !span
1791                .recorded_values
1792                .iter()
1793                .any(|(field, _)| field == "gen_ai.system_instructions")
1794        );
1795    }
1796
1797    #[test]
1798    fn record_token_usage_records_tool_use_prompt_tokens() {
1799        let fields = CapturedFields::default();
1800        let subscriber = Registry::default().with(FieldCaptureLayer {
1801            fields: fields.clone(),
1802        });
1803        let usage = Usage {
1804            input_tokens: 1,
1805            output_tokens: 2,
1806            total_tokens: 15,
1807            cached_input_tokens: 3,
1808            cache_creation_input_tokens: 4,
1809            tool_use_prompt_tokens: 12,
1810            reasoning_tokens: 5,
1811        };
1812
1813        // Scoped-subscriber tests must not run concurrently; see
1814        // `test_utils::scoped_tracing_subscriber_guard`.
1815        let _isolation = crate::test_utils::scoped_tracing_subscriber_guard_blocking();
1816        tracing::subscriber::with_default(subscriber, || {
1817            let span = tracing::info_span!(
1818                "usage_recording",
1819                gen_ai.usage.input_tokens = tracing::field::Empty,
1820                gen_ai.usage.output_tokens = tracing::field::Empty,
1821                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
1822                gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
1823                gen_ai.usage.tool_use_prompt_tokens = tracing::field::Empty,
1824                gen_ai.usage.reasoning_tokens = tracing::field::Empty,
1825            );
1826
1827            span.record_token_usage(&usage);
1828        });
1829
1830        assert!(fields.contains("gen_ai.usage.tool_use_prompt_tokens", 12));
1831    }
1832}