use std::fmt;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use futures_core::Stream;
use serde::Serialize;
use tracing::{Instrument, Span};
use web_time::Instant;
use super::call_scope::{self, Scope, Summary};
use crate::chat::stream::StreamCollector;
use crate::{
ChatProvider, ChatRequest, ChatResponse, ChatStream, ContentBlock, ContentPart,
DecisionProvider, DecisionRequest, DecisionResponse, EmbeddingProvider, EmbeddingRequest,
EmbeddingResponse, Error, FinishReason, MediaSource, Message, ProviderIdentity, ResponseFormat,
Result, StreamEvent, SystemPrompt, ToolResultContent, Usage, UserContent, Warning,
};
#[doc = concat!(
"[observability guide]: https://github.com/sagikazarmark/allama/blob/v",
env!("CARGO_PKG_VERSION"),
"/docs/guides/observability/README.md",
)]
#[derive(Clone)]
pub struct TracingProvider<T> {
inner: T,
content_config: TracingContentConfig,
span_hook: Option<SpanHook>,
}
type SpanHook = Arc<dyn Fn(&Span, TracedRequest<'_>) + Send + Sync>;
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum TracedRequest<'a> {
Chat(&'a ChatRequest),
Embedding(&'a EmbeddingRequest),
Decision(&'a DecisionRequest),
}
impl<T> fmt::Debug for TracingProvider<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TracingProvider")
.field("content_config", &self.content_config)
.finish_non_exhaustive()
}
}
impl<T> TracingProvider<T> {
#[must_use]
pub fn new(inner: T) -> Self {
Self {
inner,
content_config: TracingContentConfig::default(),
span_hook: None,
}
}
#[must_use]
pub fn on_span(
mut self,
hook: impl Fn(&Span, TracedRequest<'_>) + Send + Sync + 'static,
) -> Self {
self.span_hook = Some(Arc::new(hook));
self
}
fn run_span_hook(&self, span: &Span, request: TracedRequest<'_>) {
if let Some(hook) = &self.span_hook {
hook(span, request);
}
}
#[must_use]
pub fn with_content_capture(mut self, config: TracingContentConfig) -> Self {
self.content_config = config;
self
}
#[must_use]
pub fn inner(&self) -> &T {
&self.inner
}
#[must_use]
pub fn into_inner(self) -> T {
self.inner
}
}
#[must_use]
pub fn otel_genai_provider_name(provider_name: &str) -> &str {
super::semconv_provider_name(provider_name)
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct TracingContentConfig {
pub capture_input_messages: bool,
pub capture_output_messages: bool,
pub max_payload_chars: usize,
pub capture_media_data: bool,
pub capture_media_urls: bool,
}
impl Default for TracingContentConfig {
fn default() -> Self {
Self {
capture_input_messages: false,
capture_output_messages: false,
max_payload_chars: 4096,
capture_media_data: false,
capture_media_urls: false,
}
}
}
impl TracingContentConfig {
#[must_use]
pub fn all() -> Self {
Self {
capture_input_messages: true,
capture_output_messages: true,
..Self::default()
}
}
}
impl<T> ChatProvider for TracingProvider<T>
where
T: ChatProvider,
{
type Stream = ChatStream;
async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse> {
let addressed = self.inner.provider_name();
let span = chat_span(addressed, request, &self.content_config);
self.run_span_hook(&span, TracedRequest::Chat(request));
let scope = Scope::new();
let call = CallRecord::new(span.clone(), Arc::clone(&scope), addressed);
let result = call_scope::within(&scope, self.inner.chat(request))
.instrument(span.clone())
.await;
match &result {
Ok(response) => {
record_response(&span, response, &self.content_config);
call.complete(response.provider.as_deref());
}
Err(err) => call.fail(err),
}
result
}
async fn chat_stream(&self, request: &ChatRequest) -> Result<Self::Stream> {
let addressed = self.inner.provider_name();
let span = chat_span(addressed, request, &self.content_config);
span.record("gen_ai.request.stream", true);
self.run_span_hook(&span, TracedRequest::Chat(request));
let issued_at = Instant::now();
let scope = Scope::new();
let call = CallRecord::new(span.clone(), Arc::clone(&scope), addressed);
let inner = match call_scope::within(&scope, self.inner.chat_stream(request))
.instrument(span.clone())
.await
{
Ok(stream) => stream,
Err(err) => {
call.fail(&err);
return Err(err);
}
};
let stream = TracingStream::new(
inner,
span,
self.content_config,
issued_at,
scope,
addressed.to_owned(),
);
call.hand_over();
Ok(Box::pin(stream) as ChatStream)
}
super::forward_chat_questions!(inner);
}
impl<T> EmbeddingProvider for TracingProvider<T>
where
T: EmbeddingProvider,
{
async fn embed(&self, request: &EmbeddingRequest) -> Result<EmbeddingResponse> {
let addressed = self.inner.provider_name();
let span = tracing::info_span!(
"embeddings",
"otel.name" = %format_args!("embeddings {}", request.model),
"otel.kind" = "client",
"otel.status_code" = tracing::field::Empty,
"otel.status_description" = tracing::field::Empty,
"gen_ai.operation.name" = "embeddings",
"gen_ai.provider.name" = otel_genai_provider_name(addressed),
"gen_ai.request.model" = %request.model,
"allama.request.provider" = otel_genai_provider_name(addressed),
"server.address" = tracing::field::Empty,
"server.port" = tracing::field::Empty,
"gen_ai.embeddings.dimension.count" = tracing::field::Empty,
"allama.embeddings.input_count" = otel_count(request.inputs.len()),
"gen_ai.usage.input_tokens" = tracing::field::Empty,
"gen_ai.response.model" = tracing::field::Empty,
"allama.response.provider" = tracing::field::Empty,
"allama.attempts" = tracing::field::Empty,
"allama.retry.total_delay_ms" = tracing::field::Empty,
"allama.retry.last_delay_ms" = tracing::field::Empty,
"allama.retry.server_directive" = tracing::field::Empty,
"allama.retry.budget_denied" = tracing::field::Empty,
"allama.retry.give_up_reason" = tracing::field::Empty,
"allama.fallback.used" = tracing::field::Empty,
"allama.fallback.attempts" = tracing::field::Empty,
"allama.fallback.error_type" = tracing::field::Empty,
"allama.response.cancelled" = tracing::field::Empty,
"error.type" = tracing::field::Empty,
);
if let Some(dimensions) = request.dimensions {
span.record("gen_ai.embeddings.dimension.count", i64::from(dimensions));
}
self.run_span_hook(&span, TracedRequest::Embedding(request));
let scope = Scope::new();
let call = CallRecord::new(span.clone(), Arc::clone(&scope), addressed);
let result = call_scope::within(&scope, self.inner.embed(request))
.instrument(span.clone())
.await;
match &result {
Ok(response) => {
if let Some(input) = response.usage.as_ref().and_then(|u| u.input_tokens) {
record_int(&span, "gen_ai.usage.input_tokens", input);
}
if let Some(model) = &response.model {
span.record("gen_ai.response.model", model.as_str());
}
record_warnings(&span, &response.warnings);
call.complete(response.provider.as_deref());
}
Err(err) => call.fail(err),
}
result
}
super::forward_embedding_questions!(inner);
}
impl<T> DecisionProvider for TracingProvider<T>
where
T: DecisionProvider,
{
async fn decide(&self, request: &DecisionRequest) -> Result<DecisionResponse> {
let addressed = self.inner.provider_name();
let span = tracing::info_span!(
"decision",
"otel.name" = %format_args!("decision {}", request.model),
"otel.kind" = "client",
"otel.status_code" = tracing::field::Empty,
"otel.status_description" = tracing::field::Empty,
"gen_ai.operation.name" = "decision",
"gen_ai.provider.name" = otel_genai_provider_name(addressed),
"gen_ai.request.model" = %request.model,
"allama.request.provider" = otel_genai_provider_name(addressed),
"server.address" = tracing::field::Empty,
"server.port" = tracing::field::Empty,
"gen_ai.conversation.id" = tracing::field::Empty,
"allama.decision.question_count" = otel_count(request.questions.len()),
"allama.response.provider" = tracing::field::Empty,
"allama.response.request_id" = tracing::field::Empty,
"gen_ai.response.id" = tracing::field::Empty,
"gen_ai.usage.input_tokens" = tracing::field::Empty,
"gen_ai.usage.output_tokens" = tracing::field::Empty,
"gen_ai.response.model" = tracing::field::Empty,
"allama.response.warnings" = tracing::field::Empty,
"allama.attempts" = tracing::field::Empty,
"allama.retry.total_delay_ms" = tracing::field::Empty,
"allama.retry.last_delay_ms" = tracing::field::Empty,
"allama.retry.server_directive" = tracing::field::Empty,
"allama.retry.budget_denied" = tracing::field::Empty,
"allama.retry.give_up_reason" = tracing::field::Empty,
"allama.fallback.used" = tracing::field::Empty,
"allama.fallback.attempts" = tracing::field::Empty,
"allama.fallback.error_type" = tracing::field::Empty,
"allama.response.cancelled" = tracing::field::Empty,
"error.type" = tracing::field::Empty,
);
self.run_span_hook(&span, TracedRequest::Decision(request));
let scope = Scope::new();
let call = CallRecord::new(span.clone(), Arc::clone(&scope), addressed);
let result = call_scope::within(&scope, self.inner.decide(request))
.instrument(span.clone())
.await;
match &result {
Ok(response) => {
if let Some(usage) = &response.usage {
if let Some(input) = usage.input_tokens {
record_int(&span, "gen_ai.usage.input_tokens", input);
}
if let Some(output) = usage.output_tokens {
record_int(&span, "gen_ai.usage.output_tokens", output);
}
}
if let Some(request_id) = &response.request_id {
span.record("allama.response.request_id", request_id.as_str());
}
if let Some(id) = &response.id {
span.record("gen_ai.response.id", id.as_str());
}
if let Some(model) = &response.model {
span.record("gen_ai.response.model", model.as_str());
}
record_warnings(&span, &response.warnings);
call.complete(response.provider.as_deref());
}
Err(err) => call.fail(err),
}
result
}
super::forward_decision_questions!(inner);
}
impl<T: ProviderIdentity> ProviderIdentity for TracingProvider<T> {
super::forward_provider_identity!(inner);
}
struct CallRecord<'a> {
span: Span,
scope: Arc<Scope>,
addressed: &'a str,
armed: bool,
}
impl<'a> CallRecord<'a> {
fn new(span: Span, scope: Arc<Scope>, addressed: &'a str) -> Self {
Self {
span,
scope,
addressed,
armed: true,
}
}
fn complete(mut self, reported: Option<&str>) {
self.armed = false;
record_outcome(
&self.span,
&self.scope.summary(),
reported,
Ending::Completed,
self.addressed,
);
}
fn fail(mut self, err: &Error) {
self.armed = false;
record_error(&self.span, err);
let failed_on = err
.context()
.and_then(|context| context.provider.as_deref());
record_outcome(
&self.span,
&self.scope.summary(),
None,
Ending::Failed(failed_on),
self.addressed,
);
}
fn hand_over(mut self) {
self.armed = false;
}
}
impl Drop for CallRecord<'_> {
fn drop(&mut self) {
if self.armed {
record_cancelled(&self.span);
record_outcome(
&self.span,
&self.scope.summary(),
None,
Ending::Dropped,
self.addressed,
);
}
}
}
struct TracingStream<S> {
inner: Pin<Box<S>>,
span: Span,
content_config: TracingContentConfig,
issued_at: Instant,
collector: StreamCollector,
first_chunk_recorded: bool,
first_output_recorded: bool,
ended: bool,
failed: bool,
failed_on: Option<String>,
finalized: bool,
scope: Arc<Scope>,
addressed: String,
}
impl<S> TracingStream<S> {
fn new(
inner: S,
span: Span,
content_config: TracingContentConfig,
issued_at: Instant,
scope: Arc<Scope>,
addressed: String,
) -> Self {
let collector = if content_config.capture_output_messages {
StreamCollector::new()
} else {
StreamCollector::metadata_only()
};
Self {
inner: Box::pin(inner),
span,
content_config,
issued_at,
collector,
first_chunk_recorded: false,
first_output_recorded: false,
ended: false,
failed: false,
failed_on: None,
finalized: false,
scope,
addressed,
}
}
fn observe(&mut self, event: &StreamEvent) {
if !self.first_chunk_recorded {
self.first_chunk_recorded = true;
self.span.record(
"gen_ai.response.time_to_first_chunk",
self.issued_at.elapsed().as_secs_f64(),
);
}
if !self.first_output_recorded && event.is_output() {
self.first_output_recorded = true;
self.span.record(
"allama.response.time_to_first_output",
self.issued_at.elapsed().as_secs_f64(),
);
}
match event {
StreamEvent::Warning { warning } => record_warning_event(&self.span, warning),
StreamEvent::ResponseStop => self.ended = true,
_ => {}
}
let _ = self.collector.push_ref(event);
}
fn finalize_once(&mut self) {
if self.finalized {
return;
}
self.finalized = true;
let collector = std::mem::take(&mut self.collector);
let mut reported = None;
if let Ok(collected) = collector.finish_partial() {
let config = TracingContentConfig {
capture_input_messages: false,
..self.content_config
};
record_response_fields(&self.span, &collected.response);
record_warning_count(&self.span, collected.response.warnings.len());
record_output_messages(&self.span, &collected.response, &config);
reported = collected.response.provider;
}
let ending = if self.failed {
Ending::Failed(self.failed_on.as_deref())
} else if self.ended {
Ending::Completed
} else {
Ending::Dropped
};
record_outcome(
&self.span,
&self.scope.summary(),
reported.as_deref(),
ending,
&self.addressed,
);
}
}
impl<S> Drop for TracingStream<S> {
fn drop(&mut self) {
if !self.ended {
record_cancelled(&self.span);
}
self.finalize_once();
}
}
impl<S> Stream for TracingStream<S>
where
S: Stream<Item = Result<StreamEvent>> + Send,
{
type Item = Result<StreamEvent>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
let span = this.span.clone();
let _guard = span.enter();
let poll = {
let _scope = call_scope::enter(&this.scope);
this.inner.as_mut().poll_next(cx)
};
match &poll {
Poll::Ready(Some(Ok(event))) => this.observe(event),
Poll::Ready(Some(Err(err))) => {
if !this.failed {
record_error(&this.span, err);
this.failed_on = err.context().and_then(|context| context.provider.clone());
}
this.failed = true;
this.ended = true;
}
Poll::Ready(None) => {
this.ended = true;
this.finalize_once();
}
Poll::Pending => {}
}
poll
}
}
fn chat_span(provider_name: &str, request: &ChatRequest, cfg: &TracingContentConfig) -> Span {
let span = tracing::info_span!(
"chat",
"otel.name" = %format_args!("chat {}", request.model),
"otel.kind" = "client",
"otel.status_code" = tracing::field::Empty,
"otel.status_description" = tracing::field::Empty,
"gen_ai.operation.name" = "chat",
"gen_ai.provider.name" = otel_genai_provider_name(provider_name),
"gen_ai.request.model" = %request.model,
"allama.request.provider" = otel_genai_provider_name(provider_name),
"server.address" = tracing::field::Empty,
"server.port" = tracing::field::Empty,
"gen_ai.conversation.id" = tracing::field::Empty,
"gen_ai.request.max_tokens" = tracing::field::Empty,
"gen_ai.request.temperature" = tracing::field::Empty,
"gen_ai.request.top_p" = tracing::field::Empty,
"gen_ai.request.top_k" = tracing::field::Empty,
"gen_ai.request.frequency_penalty" = tracing::field::Empty,
"gen_ai.request.presence_penalty" = tracing::field::Empty,
"gen_ai.request.stop_sequences" = tracing::field::Empty,
"gen_ai.request.seed" = tracing::field::Empty,
"gen_ai.request.stream" = tracing::field::Empty,
"gen_ai.output.type" = tracing::field::Empty,
"gen_ai.usage.input_tokens" = tracing::field::Empty,
"gen_ai.usage.output_tokens" = tracing::field::Empty,
"gen_ai.usage.cache_read.input_tokens" = tracing::field::Empty,
"gen_ai.usage.cache_creation.input_tokens" = tracing::field::Empty,
"gen_ai.usage.reasoning.output_tokens" = tracing::field::Empty,
"gen_ai.response.finish_reasons" = tracing::field::Empty,
"gen_ai.response.id" = tracing::field::Empty,
"gen_ai.response.model" = tracing::field::Empty,
"allama.response.provider" = tracing::field::Empty,
"allama.response.request_id" = tracing::field::Empty,
"allama.response.warnings" = tracing::field::Empty,
"allama.attempts" = tracing::field::Empty,
"allama.retry.total_delay_ms" = tracing::field::Empty,
"allama.retry.last_delay_ms" = tracing::field::Empty,
"allama.retry.server_directive" = tracing::field::Empty,
"allama.retry.budget_denied" = tracing::field::Empty,
"allama.retry.give_up_reason" = tracing::field::Empty,
"allama.fallback.used" = tracing::field::Empty,
"allama.fallback.attempts" = tracing::field::Empty,
"allama.fallback.error_type" = tracing::field::Empty,
"gen_ai.response.time_to_first_chunk" = tracing::field::Empty,
"allama.response.time_to_first_output" = tracing::field::Empty,
"allama.response.cancelled" = tracing::field::Empty,
"gen_ai.system_instructions" = tracing::field::Empty,
"gen_ai.input.messages" = tracing::field::Empty,
"gen_ai.output.messages" = tracing::field::Empty,
"error.type" = tracing::field::Empty,
);
if let Some(v) = request.max_output_tokens {
span.record("gen_ai.request.max_tokens", i64::from(v));
}
if let Some(v) = request.temperature {
span.record("gen_ai.request.temperature", v);
}
if let Some(v) = request.top_p {
span.record("gen_ai.request.top_p", v);
}
if let Some(v) = request.top_k {
span.record("gen_ai.request.top_k", f64::from(v));
}
if let Some(v) = request.frequency_penalty {
span.record("gen_ai.request.frequency_penalty", v);
}
if let Some(v) = request.presence_penalty {
span.record("gen_ai.request.presence_penalty", v);
}
if !request.stop.is_empty() {
let stop = &request.stop;
span.record(
"gen_ai.request.stop_sequences",
serde_json::to_string(stop).unwrap_or_default().as_str(),
);
}
if let Some(seed) = request.seed {
record_int(&span, "gen_ai.request.seed", seed);
}
if let Some(format) = &request.response_format {
span.record(
"gen_ai.output.type",
match format {
ResponseFormat::Json | ResponseFormat::JsonSchema { .. } => "json",
_ => "text",
},
);
}
if cfg.capture_input_messages {
if !request.system.is_empty() {
let instructions = otel_system_instructions(&request.system);
if let Some(payload) = serialize_redacted(&instructions, cfg.max_payload_chars) {
span.record("gen_ai.system_instructions", payload.as_str());
}
}
let messages: Vec<_> = request
.messages
.iter()
.map(|message| otel_message(message, cfg))
.collect();
if let Some(payload) = serialize_redacted(&messages, cfg.max_payload_chars) {
span.record("gen_ai.input.messages", payload.as_str());
}
}
span
}
fn record_response(span: &Span, response: &ChatResponse, cfg: &TracingContentConfig) {
record_response_fields(span, response);
record_warnings(span, &response.warnings);
record_output_messages(span, response, cfg);
}
fn record_response_fields(span: &Span, response: &ChatResponse) {
if let Some(usage) = &response.usage {
record_usage(span, usage);
}
if let Some(reason) = &response.finish_reason {
record_finish_reason(span, reason);
}
if let Some(id) = &response.id {
span.record("gen_ai.response.id", id.as_str());
}
if let Some(model) = &response.model {
span.record("gen_ai.response.model", model.as_str());
}
if let Some(request_id) = &response.request_id {
span.record("allama.response.request_id", request_id.as_str());
}
}
fn record_outcome(
span: &Span,
summary: &Summary,
reported: Option<&str>,
ending: Ending<'_>,
addressed: &str,
) {
let ended_by = match ending {
Ending::Completed => None,
Ending::Failed(provider) => provider,
Ending::Dropped => summary.tried.as_deref(),
};
let provider = reported
.or(summary.served_by.as_deref())
.or(ended_by)
.unwrap_or(addressed);
span.record(
"allama.response.provider",
otel_genai_provider_name(provider),
);
record_summary(span, summary);
}
#[derive(Clone, Copy)]
enum Ending<'a> {
Completed,
Failed(Option<&'a str>),
Dropped,
}
fn record_cancelled(span: &Span) {
if !std::thread::panicking() {
span.record("allama.response.cancelled", true);
}
}
fn record_summary(span: &Span, summary: &Summary) {
span.record("allama.attempts", otel_count(summary.call_attempts()));
if let Some((address, port)) = &summary.server {
span.record("server.address", address.as_ref());
if let Some(port) = port {
span.record("server.port", i64::from(*port));
}
}
if let Some(last_delay) = summary.last_delay {
span.record(
"allama.retry.total_delay_ms",
otel_millis(summary.total_delay),
);
span.record("allama.retry.last_delay_ms", otel_millis(last_delay));
}
if let Some(directive) = summary.server_directive {
span.record("allama.retry.server_directive", directive);
}
if summary.budget_denied {
span.record("allama.retry.budget_denied", true);
}
if let Some(reason) = summary.give_up {
span.record("allama.retry.give_up_reason", reason.as_str());
}
if summary.failed_over {
span.record("allama.fallback.used", true);
span.record(
"allama.fallback.attempts",
otel_count(summary.failed_providers),
);
if let Some(error_type) = summary.failover_error {
span.record("allama.fallback.error_type", error_type);
}
}
}
fn record_warnings(span: &Span, warnings: &[Warning]) {
record_warning_count(span, warnings.len());
for warning in warnings {
record_warning_event(span, warning);
}
}
fn record_warning_count(span: &Span, count: usize) {
if count > 0 {
span.record("allama.response.warnings", otel_count(count));
}
}
fn record_warning_event(span: &Span, warning: &Warning) {
if crate::warning::was_emitted(warning) {
return;
}
tracing::event!(
parent: span,
tracing::Level::WARN,
allama.warning.kind = warning.kind.as_str(),
allama.warning.provider = warning.provider.as_deref().unwrap_or(""),
"{}",
warning.message
);
}
fn record_output_messages(span: &Span, response: &ChatResponse, cfg: &TracingContentConfig) {
if !cfg.capture_output_messages {
return;
}
let mut message = serde_json::Map::new();
message.insert("role".into(), "assistant".into());
message.insert(
"parts".into(),
response
.content
.iter()
.map(|block| otel_block(block, cfg))
.collect(),
);
if let Some(reason) = &response.finish_reason {
message.insert("finish_reason".into(), reason.as_str().into());
}
let output = serde_json::Value::Array(vec![serde_json::Value::Object(message)]);
if let Some(payload) = serialize_redacted(&output, cfg.max_payload_chars) {
span.record("gen_ai.output.messages", payload.as_str());
}
}
fn otel_system_instructions(system: &[SystemPrompt]) -> serde_json::Value {
system.iter().map(|p| text_part(&p.content)).collect()
}
fn otel_message(message: &Message, cfg: &TracingContentConfig) -> serde_json::Value {
let (role, parts) = match message {
Message::User(m) => (
"user",
match &m.content {
UserContent::Text(text) => vec![text_part(text)],
UserContent::Parts(parts) => {
parts.iter().map(|part| otel_part(part, cfg)).collect()
}
},
),
Message::Assistant(m) => (
"assistant",
m.content
.iter()
.map(|block| otel_block(block, cfg))
.collect(),
),
Message::Tool(m) => {
let mut part = serde_json::Map::new();
part.insert("type".into(), "tool_call_response".into());
part.insert("id".into(), m.tool_call_id.clone().into());
part.insert("allama.tool_name".into(), m.name.clone().into());
part.insert(
"response".into(),
match &m.content {
ToolResultContent::Text(text) => text.clone().into(),
ToolResultContent::Object(object) => serde_json::Value::Object(object.clone()),
ToolResultContent::Parts(parts) => {
parts.iter().map(|part| otel_part(part, cfg)).collect()
}
},
);
if m.is_error {
part.insert("is_error".into(), true.into());
}
("tool", vec![serde_json::Value::Object(part)])
}
};
let mut map = serde_json::Map::new();
map.insert("role".into(), role.into());
map.insert("parts".into(), serde_json::Value::Array(parts));
serde_json::Value::Object(map)
}
fn otel_part(part: &ContentPart, cfg: &TracingContentConfig) -> serde_json::Value {
match part {
ContentPart::Text(text) => text_part(&text.text),
ContentPart::Image(image) => {
let mut value = media_part("image", &image.source, cfg);
if let (Some(detail), serde_json::Value::Object(map)) = (image.detail, &mut value) {
map.insert("detail".into(), detail.as_str().into());
}
value
}
ContentPart::File(file) => media_part("file", &file.source, cfg),
ContentPart::Other(other) => generic_part(&other.type_name, &other.data),
}
}
fn otel_block(block: &ContentBlock, cfg: &TracingContentConfig) -> serde_json::Value {
match block {
ContentBlock::Text(text) => text_part(&text.text),
ContentBlock::Image(image) => media_part("image", &image.source, cfg),
ContentBlock::ToolCall(call) => serde_json::json!({
"type": "tool_call",
"id": call.id,
"name": call.name,
"arguments": serde_json::from_str::<serde_json::Value>(&call.arguments)
.unwrap_or_else(|_| call.arguments.clone().into()),
}),
ContentBlock::Reasoning(reasoning) => {
serde_json::json!({"type": "reasoning", "content": reasoning.text})
}
ContentBlock::Refusal(refusal) => {
serde_json::json!({"type": "refusal", "content": refusal.text})
}
ContentBlock::ProviderToolCall(call) => serde_json::json!({
"type": "server_tool_call",
"id": call.id,
"name": call.name,
"arguments": serde_json::from_str::<serde_json::Value>(&call.arguments)
.unwrap_or_else(|_| call.arguments.clone().into()),
}),
ContentBlock::ProviderToolResult(result) => serde_json::json!({
"type": "server_tool_call_response",
"id": result.call_id,
"response": result.output,
}),
ContentBlock::Other(other) => generic_part(&other.type_name, &other.data),
}
}
fn text_part(text: &str) -> serde_json::Value {
serde_json::json!({"type": "text", "content": text})
}
fn media_part(
modality: &str,
source: &MediaSource,
cfg: &TracingContentConfig,
) -> serde_json::Value {
match source {
MediaSource::Url { url } if cfg.capture_media_urls => {
serde_json::json!({"type": "uri", "modality": modality, "uri": url})
}
MediaSource::Url { url } => {
serde_json::json!({"type": "uri", "modality": modality, "uri": without_query(url)})
}
MediaSource::Base64 {
media_type, data, ..
} if cfg.capture_media_data => serde_json::json!({
"type": "blob", "modality": modality, "mime_type": media_type, "content": data,
}),
MediaSource::Base64 {
media_type, data, ..
} => serde_json::json!({
"type": "blob",
"modality": modality,
"mime_type": media_type,
"allama.omitted_content_bytes": base64_decoded_len(data),
}),
MediaSource::ProviderFile { origin, id, .. } => serde_json::json!({
"type": "file", "modality": modality, "file_id": id, "allama.origin": origin,
}),
}
}
fn without_query(url: &str) -> &str {
url.find(['?', '#']).map_or(url, |end| &url[..end])
}
fn base64_decoded_len(data: &str) -> usize {
let digits = data
.bytes()
.filter(|byte| *byte != b'=' && !byte.is_ascii_whitespace())
.count();
digits / 4 * 3 + (digits % 4 * 3) / 4
}
fn generic_part(type_name: &str, data: &crate::ExtraMap) -> serde_json::Value {
let mut map = data.clone();
map.insert("type".into(), type_name.into());
serde_json::Value::Object(map)
}
fn serialize_redacted<T: Serialize>(value: &T, max_chars: usize) -> Option<String> {
let mut json = serde_json::to_value(value).ok()?;
redact(&mut json);
let text = serde_json::to_string(&json).ok()?;
if text.chars().count() > max_chars {
let truncated: String = text.chars().take(max_chars).collect();
return Some(format!("{truncated}...[TRUNCATED]"));
}
Some(text)
}
fn redact(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for (key, val) in map.iter_mut() {
if is_sensitive_key(key) {
*val = "[REDACTED]".into();
} else {
redact(val);
}
}
}
serde_json::Value::Array(items) => items.iter_mut().for_each(redact),
_ => {}
}
}
fn is_sensitive_key(key: &str) -> bool {
const EXACT: [&str; 8] = [
"accesstoken",
"apikey",
"authorization",
"clientsecret",
"password",
"refreshtoken",
"secret",
"token",
];
const FRAGMENTS: [&str; 3] = ["apikey", "secret", "token"];
let key: String = key
.chars()
.filter(|c| !matches!(c, '-' | '_'))
.map(|c| c.to_ascii_lowercase())
.collect();
EXACT.contains(&key.as_str()) || FRAGMENTS.iter().any(|fragment| key.contains(fragment))
}
fn record_error(span: &Span, err: &Error) {
span.record("error.type", err.kind_name());
span.record("otel.status_code", "ERROR");
span.record("otel.status_description", status_description(err).as_str());
}
fn status_description(err: &Error) -> String {
let log = err.as_log().to_string();
let kind = format!("kind={}", err.kind_name());
match log.strip_prefix(kind.as_str()) {
Some(rest) => rest.trim_start().to_owned(),
None => log,
}
}
fn record_usage(span: &Span, usage: &Usage) {
if let Some(v) = usage.input_tokens {
record_int(span, "gen_ai.usage.input_tokens", v);
}
if let Some(v) = usage.output_tokens {
record_int(span, "gen_ai.usage.output_tokens", v);
}
if let Some(v) = usage.cache_read_input_tokens {
record_int(span, "gen_ai.usage.cache_read.input_tokens", v);
}
if let Some(v) = usage.cache_write_input_tokens {
record_int(span, "gen_ai.usage.cache_creation.input_tokens", v);
}
if let Some(v) = usage.reasoning_tokens {
record_int(span, "gen_ai.usage.reasoning.output_tokens", v);
}
}
fn record_int(span: &Span, field: &str, value: u64) {
match i64::try_from(value) {
Ok(value) => span.record(field, value),
Err(_) => span.record(field, value.to_string().as_str()),
};
}
fn otel_count(count: usize) -> i64 {
i64::try_from(count).unwrap_or(i64::MAX)
}
fn otel_millis(duration: std::time::Duration) -> i64 {
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
}
fn record_finish_reason(span: &Span, reason: &FinishReason) {
let reasons = serde_json::to_string(&[reason.as_str()]).unwrap_or_default();
span.record("gen_ai.response.finish_reasons", reasons.as_str());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ChatStreamExt;
use crate::mock::{ChatResponseBuilder, MockProvider};
#[tokio::test]
async fn passes_responses_through() {
let provider = TracingProvider::new(MockProvider::with_text("hi"))
.with_content_capture(TracingContentConfig::all());
let response = provider
.chat(&ChatRequest::new("m").user("x"))
.await
.unwrap();
assert_eq!(response.text().as_deref(), Some("hi"));
}
#[tokio::test]
async fn streams_pass_through() {
let provider = TracingProvider::new(MockProvider::with_response(
ChatResponseBuilder::new().text("streamed").build(),
));
let response = provider
.chat_stream(&ChatRequest::new("m").user("x"))
.await
.unwrap()
.collect_response()
.await
.unwrap();
assert_eq!(response.text().as_deref(), Some("streamed"));
}
async fn buffered_content(config: TracingContentConfig) -> Vec<ContentBlock> {
let response = ChatResponseBuilder::new()
.text("streamed text")
.usage(3, 2)
.model("m-1")
.build();
let event_count = response.clone().into_stream_events().count();
let mut stream = TracingStream::new(
response.into_stream(),
Span::none(),
config,
Instant::now(),
Scope::new(),
"mock".to_owned(),
);
for _ in 0..event_count {
let event = futures_util::StreamExt::next(&mut stream).await;
event.unwrap().unwrap();
}
let collected = std::mem::take(&mut stream.collector)
.finish_partial()
.unwrap();
assert_eq!(
collected.response.usage,
Some(Usage::new().input_tokens(3).output_tokens(2))
);
assert_eq!(collected.response.model.as_deref(), Some("m-1"));
collected.response.content
}
#[tokio::test]
async fn streams_buffer_content_only_when_output_is_captured() {
assert!(
buffered_content(TracingContentConfig::default())
.await
.is_empty()
);
let captured = buffered_content(TracingContentConfig::all()).await;
assert_eq!(captured.len(), 1);
}
#[test]
fn redaction_and_truncation() {
let value = serde_json::json!({"api_key": "secret", "nested": [{"token": "t"}], "text": "x".repeat(20)});
let out = serialize_redacted(&value, 30).unwrap();
assert!(out.contains("[REDACTED]"));
assert!(out.ends_with("[TRUNCATED]"));
}
#[test]
fn sensitive_keys_match_in_any_spelling() {
for key in [
"api_key",
"apiKey",
"API-Key",
"x-api-key",
"Authorization",
"password",
"Password",
"secret",
"client_secret",
"clientSecret",
"token",
"access_token",
"refreshToken",
"bearer_token",
"x-gateway-token",
"aws_secret_access_key",
] {
assert!(is_sensitive_key(key), "{key}");
}
for key in ["author", "keywords", "city", "content", "passcode", "auth"] {
assert!(!is_sensitive_key(key), "{key}");
}
}
#[test]
fn redaction_matches_nested_keys_in_any_spelling() {
let value = serde_json::json!({
"apiKey": "k1",
"headers": {"X-Api-Key": "k2", "Authorization": "Bearer k3"},
"accounts": [{"clientSecret": "k4", "refresh-token": "k5"}],
"city": "Lisbon",
});
let out = serialize_redacted(&value, 4096).unwrap();
for secret in ["k1", "k2", "k3", "k4", "k5"] {
assert!(!out.contains(secret), "{secret} leaked: {out}");
}
assert!(out.contains("Lisbon"), "{out}");
}
#[test]
fn text_is_recorded_verbatim() {
let request = ChatRequest::new("m")
.system("token: s1")
.user("my api_key is s2");
let config = TracingContentConfig::all();
let messages: Vec<_> = request
.messages
.iter()
.map(|message| otel_message(message, &config))
.collect();
let out = serialize_redacted(&messages, 4096).unwrap();
assert!(out.contains("my api_key is s2"), "{out}");
let instructions = otel_system_instructions(&request.system);
let out = serialize_redacted(&instructions, 4096).unwrap();
assert!(out.contains("token: s1"), "{out}");
}
fn request_with_inline_image() -> ChatRequest {
let data = "A".repeat(4_000);
ChatRequest::new("m").message(Message::user_parts(vec![
ContentPart::text("What is in this picture?"),
ContentPart::image_base64("image/png", data),
ContentPart::text("Answer in one word."),
]))
}
fn input_messages(request: &ChatRequest, config: &TracingContentConfig) -> String {
let messages: Vec<_> = request
.messages
.iter()
.map(|message| otel_message(message, config))
.collect();
serialize_redacted(&messages, config.max_payload_chars).unwrap()
}
#[test]
fn inline_media_data_is_left_out_by_default() {
let out = input_messages(&request_with_inline_image(), &TracingContentConfig::all());
assert!(!out.contains("AAAA"), "{out}");
assert!(
out.contains(
r#"{"allama.omitted_content_bytes":3000,"mime_type":"image/png","modality":"image","type":"blob"}"#
),
"{out}"
);
assert!(out.contains("Answer in one word."), "{out}");
assert!(!out.ends_with("[TRUNCATED]"), "{out}");
}
#[test]
fn inline_media_data_is_captured_when_asked() {
let mut config = TracingContentConfig::all();
config.capture_media_data = true;
config.max_payload_chars = 8_192;
let out = input_messages(&request_with_inline_image(), &config);
assert!(out.contains(&"A".repeat(4_000)), "{out}");
assert!(!out.contains("allama.omitted_content_bytes"), "{out}");
}
#[test]
fn media_by_url_keeps_its_uri() {
let request =
ChatRequest::new("m").message(Message::user_parts(vec![ContentPart::image_url(
"https://example.com/cat.png",
)]));
let out = input_messages(&request, &TracingContentConfig::all());
assert!(out.contains("https://example.com/cat.png"), "{out}");
}
#[test]
fn media_urls_lose_their_query_unless_asked() {
let presigned =
"https://bucket.example/cat.png?X-Amz-Signature=SECRET&X-Amz-Expires=60#frag";
let request = ChatRequest::new("m")
.message(Message::user_parts(vec![ContentPart::image_url(presigned)]));
let out = input_messages(&request, &TracingContentConfig::all());
assert!(
out.contains(r#""uri":"https://bucket.example/cat.png""#),
"{out}"
);
assert!(!out.contains("SECRET") && !out.contains("frag"), "{out}");
let mut config = TracingContentConfig::all();
config.capture_media_urls = true;
let out = input_messages(&request, &config);
assert!(out.contains("X-Amz-Signature=SECRET"), "{out}");
}
#[test]
fn output_media_data_is_left_out_by_default() {
let block = ContentBlock::Image(crate::Image::base64("image/png", "aGVsbG8="));
let part = otel_block(&block, &TracingContentConfig::all());
assert_eq!(part["allama.omitted_content_bytes"], 5);
assert!(part.get("content").is_none(), "{part}");
}
#[test]
fn base64_decoded_len_counts_bytes() {
assert_eq!(base64_decoded_len(""), 0);
assert_eq!(base64_decoded_len("aGk="), 2);
assert_eq!(base64_decoded_len("aGVsbG8="), 5);
assert_eq!(base64_decoded_len("aGVs\nbG8h"), 6);
}
#[test]
fn status_description_is_the_redacted_log_without_the_kind() {
let err = Error::rate_limited("HTTP 429")
.authored()
.with_provider("openai")
.with_status(429)
.with_upstream_message("SENTINEL-UPSTREAM")
.with_body("SENTINEL-BODY");
assert_eq!(
status_description(&err),
r#"provider=openai status=429 body_len=13 message="HTTP 429""#
);
let fallback = Error::overloaded("busy").with_fallback_from(Error::timeout("slow"));
assert_eq!(
status_description(&fallback),
"fallback_from=[kind=timeout]"
);
}
#[test]
fn provider_name_mapping() {
for (name, expected) in [
("azure", "azure.ai.openai"),
("gemini", "gcp.gemini"),
("google-ai", "gcp.gemini"),
("google-vertex", "gcp.vertex_ai"),
("mistral", "mistral_ai"),
("xai", "x_ai"),
("openai", "openai"),
("custom-gateway", "custom-gateway"),
] {
assert_eq!(otel_genai_provider_name(name), expected, "{name}");
}
}
}