1use 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#[doc(hidden)]
19pub use tracing as __tracing;
20
21pub use tracing::field::Empty;
31
32#[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 { $($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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum CompletionOperation {
95 Chat,
97 ChatStreaming,
99 GenerateContent,
101 Interactions,
103 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
119pub const COMPLETION_PARENT_MARKER_FIELD: &str = "rig.completion_parent";
141
142pub 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#[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 (
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
260pub use crate::completion_parent_span;
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272enum CompletionParentVerdict {
273 Adopt,
275 RejectMissingFields,
279 NotAParent,
282}
283
284fn 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
297fn classify_completion_parent(metadata: &tracing::Metadata<'_>) -> CompletionParentVerdict {
300 let fields = metadata.fields();
301 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
317static NEAR_MISS_WARNED: LazyLock<Mutex<HashSet<Identifier>>> =
326 LazyLock::new(|| Mutex::new(HashSet::new()));
327
328#[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
344fn 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 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
385pub 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 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 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 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 "unknown"
687 };
688 vec![TelemetryOutputMessage {
689 role: "assistant",
690 parts: assistant_parts(content),
691 finish_reason,
692 }]
693}
694
695pub 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
709pub 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
725pub 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
742pub trait ProviderResponseExt {
744 type Usage: Serialize;
746
747 fn get_response_id(&self) -> Option<String>;
749
750 fn get_response_model_name(&self) -> Option<String>;
752
753 fn get_text_response(&self) -> Option<String>;
755
756 fn get_usage(&self) -> Option<Self::Usage>;
758}
759
760pub trait SpanCombinator {
763 fn record_token_usage(&self, usage: &Usage);
765
766 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 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 #[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 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 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 #[test]
1168 fn completion_parent_span_macro_honours_its_parent_argument() {
1169 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 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 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 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 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 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 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 assert_eq!(declared, expected);
1330 assert_eq!(metadata.fields().len(), expected.len());
1333 assert_eq!(
1334 classify_completion_parent(metadata),
1335 CompletionParentVerdict::Adopt
1336 );
1337
1338 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 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 assert_eq!(declared, expected);
1401 assert_eq!(metadata.fields().len(), expected.len());
1404 });
1405 }
1406
1407 #[test]
1408 fn completion_parent_required_fields_are_pinned() {
1409 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 #[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 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 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 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 let ambient = tracing::info_span!(target: "application", "ambient");
1490 assert_eq!(verdict(&ambient), CompletionParentVerdict::NotAParent);
1491
1492 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 #[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 reset_near_miss_warnings();
1521 tracing::subscriber::with_default(subscriber, || {
1522 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 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 #[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 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 #[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 reset_near_miss_warnings();
1626 tracing::subscriber::with_default(subscriber, || {
1627 tracing::callsite::rebuild_interest_cache();
1628
1629 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 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 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 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}