use std::borrow::Cow;
use opentelemetry::global::{self, BoxedSpan, BoxedTracer};
use opentelemetry::trace::{
Span, SpanContext, SpanId, SpanKind, Status, TraceFlags, TraceId, TraceState, Tracer,
};
use opentelemetry::{InstrumentationScope, KeyValue};
use super::types::CaptureDecision;
const TRACER_NAME: &str = env!("CARGO_PKG_NAME");
const TRACER_VERSION: &str = env!("CARGO_PKG_VERSION");
fn tracer() -> BoxedTracer {
let scope = InstrumentationScope::builder(TRACER_NAME)
.with_version(TRACER_VERSION)
.build();
global::tracer_with_scope(scope)
}
#[must_use]
pub fn start_internal_span(name: impl Into<Cow<'static, str>>, attrs: Vec<KeyValue>) -> BoxedSpan {
let t = tracer();
t.span_builder(name)
.with_kind(SpanKind::Internal)
.with_attributes(attrs)
.start(&t)
}
#[must_use]
pub fn start_client_span(name: impl Into<Cow<'static, str>>, attrs: Vec<KeyValue>) -> BoxedSpan {
let t = tracer();
t.span_builder(name)
.with_kind(SpanKind::Client)
.with_attributes(attrs)
.start(&t)
}
pub fn set_span_error(span: &mut BoxedSpan, error_type: &str, message: &str) {
span.set_attribute(KeyValue::new(
super::attrs::ERROR_TYPE,
error_type.to_string(),
));
span.set_status(Status::error(message.to_string()));
}
pub fn add_event(span: &mut BoxedSpan, name: impl Into<Cow<'static, str>>, attrs: Vec<KeyValue>) {
if !span.is_recording() {
return;
}
span.add_event(name, attrs);
}
pub fn add_link(span: &mut BoxedSpan, target: SpanContext, attrs: Vec<KeyValue>) {
if !span.is_recording() {
return;
}
if !target.is_valid() {
return;
}
span.add_link(target, attrs);
}
pub fn link_to_replay_origin(
span: &mut BoxedSpan,
original_trace_id: &str,
original_span_id: &str,
attempt_index: u32,
) {
let Some(target) =
span_context_from_hex(original_trace_id, original_span_id, TraceFlags::SAMPLED)
else {
return;
};
add_link(
span,
target,
vec![
KeyValue::new(
super::attrs::AGENT_REPLAY_ORIGINAL_TRACE_ID,
original_trace_id.to_string(),
),
KeyValue::new(
super::attrs::AGENT_REPLAY_ORIGINAL_SPAN_ID,
original_span_id.to_string(),
),
super::attrs::kv_i64(
super::attrs::AGENT_REPLAY_ATTEMPT_INDEX,
i64::from(attempt_index),
),
],
);
}
pub fn link_to_parent_turn(span: &mut BoxedSpan, parent_trace_id: &str, parent_span_id: &str) {
let Some(target) = span_context_from_hex(parent_trace_id, parent_span_id, TraceFlags::SAMPLED)
else {
return;
};
add_link(span, target, vec![]);
}
#[must_use]
pub fn remote_span_context(trace_hex: &str, span_hex: &str) -> Option<SpanContext> {
span_context_from_hex(trace_hex, span_hex, TraceFlags::SAMPLED)
}
#[must_use]
pub fn remote_span_context_with_sampling(
trace_hex: &str,
span_hex: &str,
sampled: bool,
) -> Option<SpanContext> {
span_context_from_hex(trace_hex, span_hex, sampled_flags(sampled))
}
const fn sampled_flags(sampled: bool) -> TraceFlags {
if sampled {
TraceFlags::SAMPLED
} else {
TraceFlags::NOT_SAMPLED
}
}
fn span_context_from_hex(
trace_hex: &str,
span_hex: &str,
flags: TraceFlags,
) -> Option<SpanContext> {
let trace_id = TraceId::from_hex(trace_hex).ok()?;
let span_id = SpanId::from_hex(span_hex).ok()?;
let ctx = SpanContext::new(trace_id, span_id, flags, true, TraceState::default());
if !ctx.is_valid() {
return None;
}
Some(ctx)
}
pub fn record_payload_on_span(
span: &mut BoxedSpan,
result: &super::types::CaptureResult,
system_json: Option<&serde_json::Value>,
input_json: &serde_json::Value,
output_json: &serde_json::Value,
) {
use super::attrs;
if !span.is_recording() {
return;
}
apply_capture_decision(
span,
&result.system_instructions,
system_json,
attrs::GEN_AI_SYSTEM_INSTRUCTIONS,
attrs::SDK_OTEL_SYSTEM_INSTRUCTIONS_REF,
);
apply_capture_decision(
span,
&result.input_messages,
Some(input_json),
attrs::GEN_AI_INPUT_MESSAGES,
attrs::SDK_OTEL_INPUT_MESSAGES_REF,
);
apply_capture_decision(
span,
&result.output_messages,
Some(output_json),
attrs::GEN_AI_OUTPUT_MESSAGES,
attrs::SDK_OTEL_OUTPUT_MESSAGES_REF,
);
}
fn apply_capture_decision(
span: &mut BoxedSpan,
decision: &CaptureDecision,
json_value: Option<&serde_json::Value>,
inline_attr: &'static str,
ref_attr: &'static str,
) {
match decision {
CaptureDecision::Inline => {
if let Some(val) = json_value {
span.set_attribute(KeyValue::new(inline_attr, val.to_string()));
}
}
CaptureDecision::Reference(r) => {
span.set_attribute(KeyValue::new(ref_attr, r.clone()));
}
CaptureDecision::Omit => {}
}
}
#[cfg(test)]
mod tests {
use super::{remote_span_context, remote_span_context_with_sampling};
use anyhow::Context as _;
const TRACE_HEX: &str = "4bf92f3577b34da6a3ce929d0e0e4736";
const SPAN_HEX: &str = "00f067aa0ba902b7";
#[test]
fn remote_span_context_honours_real_sampled_bit() -> anyhow::Result<()> {
let kept =
remote_span_context_with_sampling(TRACE_HEX, SPAN_HEX, true).context("sampled ctx")?;
assert!(kept.is_sampled(), "sampled=true must produce a sampled ctx");
assert!(kept.is_remote());
let dropped = remote_span_context_with_sampling(TRACE_HEX, SPAN_HEX, false)
.context("unsampled ctx")?;
assert!(
!dropped.is_sampled(),
"sampled=false must NOT force the SAMPLED flag (ratio sampling respected)"
);
assert!(dropped.is_remote());
Ok(())
}
#[test]
fn legacy_remote_span_context_stays_sampled() -> anyhow::Result<()> {
let ctx = remote_span_context(TRACE_HEX, SPAN_HEX).context("ctx")?;
assert!(ctx.is_sampled());
Ok(())
}
#[test]
fn remote_span_context_rejects_zero_ids() {
assert!(
remote_span_context_with_sampling(&"0".repeat(32), &"0".repeat(16), true).is_none()
);
assert!(remote_span_context_with_sampling("not-hex", SPAN_HEX, false).is_none());
}
}