Skip to main content

agent_framework_core/
observability.rs

1//! Lightweight OpenTelemetry GenAI-style span instrumentation, built on the
2//! [`tracing`] crate, plus optional GenAI metrics behind the `otel-metrics`
3//! feature.
4//!
5//! This is a dependency-light port of the Python `observability.py`
6//! instrumentation. It emits `tracing` spans that follow the OpenTelemetry
7//! GenAI semantic conventions, so that an OTel bridge (e.g.
8//! `tracing-opentelemetry`) can export them without any additional glue:
9//!
10//! * span names: `chat {model}`, `invoke_agent {agent}`, `execute_tool {tool}`
11//!   — the human-readable name is carried in the `otel.name` field (the static
12//!   `tracing` metadata name is the bare operation, since `tracing` requires a
13//!   literal span name).
14//! * chat-span attributes: `gen_ai.operation.name`, the provider tag as
15//!   either `gen_ai.provider.name` or `gen_ai.system` (whichever the active
16//!   GenAI semantic-convention version defines — see
17//!   [`ObservabilityConfig::use_latest_experimental_gen_ai_semconv`]),
18//!   `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.response.id`,
19//!   `gen_ai.response.finish_reasons`, `gen_ai.usage.{input,output}_tokens`,
20//!   the request parameters (`gen_ai.request.{temperature,top_p,max_tokens,
21//!   seed,frequency_penalty,presence_penalty,stop_sequences}`,
22//!   `gen_ai.conversation.id`), `error.type` plus the `tracing-opentelemetry`
23//!   "special fields" `otel.status_code` / `otel.status_message` — and, only
24//!   when content capture is explicitly enabled, `gen_ai.input.messages` /
25//!   `gen_ai.output.messages`, `gen_ai.system_instructions`, and
26//!   `gen_ai.tool.definitions`.
27//! * tool-span attributes: `gen_ai.tool.name`, `gen_ai.tool.call.id`,
28//!   `gen_ai.tool.description`, `gen_ai.tool.type`, and (content-capture-gated)
29//!   `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result`.
30//!
31//! The main entry point is [`ObservableChatClient`], a [`ChatClient`] decorator.
32//! Tool execution inside [`FunctionInvokingChatClient`] and the
33//! [`Agent`](crate::agent::Agent) run paths are instrumented directly by
34//! those types using the span constructors here.
35//!
36//! ## Metrics (`otel-metrics` feature)
37//!
38//! With the `otel-metrics` feature enabled, [`ObservableChatClient`] also
39//! records two histograms through the `opentelemetry` **API** crate only
40//! (mirroring `observability.py:788-803`, bucket boundaries at `:65-96`):
41//! [`metrics::TOKEN_USAGE_METRIC`] (`gen_ai.client.token.usage`, unit
42//! `"tokens"`) and [`metrics::OPERATION_DURATION_METRIC`]
43//! (`gen_ai.client.operation.duration`, unit `"s"`). A third histogram,
44//! [`metrics::FUNCTION_INVOCATION_DURATION_METRIC`]
45//! (`agent_framework.function.invocation.duration`), is defined for tool-call
46//! timing; see [`metrics::record_function_invocation_duration`] for why it
47//! isn't wired to a call site yet. This crate never depends on an OTel SDK:
48//! without an application-installed `MeterProvider` (via
49//! [`opentelemetry::global::set_meter_provider`]) the instruments are no-ops,
50//! so the feature is safe to enable unconditionally.
51//!
52//! ## Wiring to a real OTel backend
53//!
54//! Neither the `tracing` spans nor the `otel-metrics` histograms are exported
55//! anywhere by this crate — that stays the application's job. A minimal
56//! bridge, using `tracing-opentelemetry` for spans and `opentelemetry_sdk` for
57//! metrics:
58//!
59//! ```ignore
60//! use opentelemetry_sdk::trace::SdkTracerProvider;
61//! use opentelemetry_sdk::metrics::SdkMeterProvider;
62//! use tracing_subscriber::layer::SubscriberExt;
63//!
64//! let tracer_provider = SdkTracerProvider::builder()
65//!     // .with_batch_exporter(otlp_span_exporter) / .with_simple_exporter(...) …
66//!     .build();
67//! let meter_provider = SdkMeterProvider::builder()
68//!     // .with_reader(periodic_reader_wrapping_your_metric_exporter) …
69//!     .build();
70//! opentelemetry::global::set_meter_provider(meter_provider); // powers `otel-metrics`
71//!
72//! let tracer = tracer_provider.tracer("agent_framework");
73//! let subscriber = tracing_subscriber::registry()
74//!     .with(tracing_opentelemetry::layer().with_tracer(tracer));
75//! tracing::subscriber::set_global_default(subscriber).unwrap();
76//! ```
77//!
78//! Without any of this, spans are still emitted to whatever plain `tracing`
79//! subscriber you do have (e.g. for structured logging), and `otel-metrics`
80//! instruments silently drop their measurements — zero required setup either
81//! way.
82//!
83//! ## Environment configuration
84//!
85//! [`ObservabilityConfig::from_env`] reads `ENABLE_SENSITIVE_DATA` (mirrors
86//! Python's `enable_sensitive_data`, `observability.py:347-394`) and
87//! `OTEL_SEMCONV_STABILITY_OPT_IN` (which GenAI semantic-convention version to
88//! emit — see
89//! [`ObservabilityConfig::use_latest_experimental_gen_ai_semconv`]) for use
90//! with [`ObservableChatClient::from_env`]. Unlike Python, there is no
91//! `ENABLE_OTEL` equivalent to read: this crate's spans are plain `tracing`
92//! spans, already effectively free without a subscriber attached, so there is
93//! no separate "enable observability" switch.
94//!
95//! [`FunctionInvokingChatClient`]: crate::client::FunctionInvokingChatClient
96
97use async_trait::async_trait;
98use futures::StreamExt;
99use tracing::field::Empty;
100use tracing::{Instrument, Span};
101
102use crate::client::{ChatClient, ChatStream};
103use crate::error::{Error, Result};
104use crate::tools::ToolDefinition;
105use crate::types::{ChatOptions, ChatResponse, Message};
106
107/// The token recognized in `OTEL_SEMCONV_STABILITY_OPT_IN` that selects the
108/// GenAI semantic conventions **above** the v1.36.0 baseline. Mirrors
109/// upstream's `GEN_AI_LATEST_EXPERIMENTAL_OPT_IN`.
110pub const GEN_AI_LATEST_EXPERIMENTAL_OPT_IN: &str = "gen_ai_latest_experimental";
111
112/// OpenTelemetry GenAI semantic-convention attribute keys.
113pub mod attr {
114    pub const OPERATION: &str = "gen_ai.operation.name";
115    /// The provider/system tag, e.g. `"openai"`. Supplied by the client.
116    pub const SYSTEM: &str = "gen_ai.system";
117    /// Semantic-convention replacement for [`SYSTEM`], introduced above
118    /// v1.36.0. Exactly one of the two is emitted, selected by
119    /// [`super::ObservabilityConfig::use_latest_experimental_gen_ai_semconv`].
120    pub const PROVIDER_NAME: &str = "gen_ai.provider.name";
121    pub const REQUEST_MODEL: &str = "gen_ai.request.model";
122    pub const RESPONSE_MODEL: &str = "gen_ai.response.model";
123    pub const RESPONSE_ID: &str = "gen_ai.response.id";
124    pub const FINISH_REASONS: &str = "gen_ai.response.finish_reasons";
125    pub const INPUT_TOKENS: &str = "gen_ai.usage.input_tokens";
126    pub const OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens";
127    /// Input tokens written to a provider-managed cache.
128    pub const CACHE_CREATION_INPUT_TOKENS: &str = "gen_ai.usage.cache_creation.input_tokens";
129    /// Input tokens served from a provider-managed cache.
130    pub const CACHE_READ_INPUT_TOKENS: &str = "gen_ai.usage.cache_read.input_tokens";
131    /// Output tokens spent on reasoning.
132    pub const REASONING_OUTPUT_TOKENS: &str = "gen_ai.usage.reasoning.output_tokens";
133    /// Low-cardinality prompt name (e.g. for a named/templated prompt).
134    pub const PROMPT_NAME: &str = "gen_ai.prompt.name";
135    pub const REQUEST_TEMPERATURE: &str = "gen_ai.request.temperature";
136    pub const REQUEST_TOP_P: &str = "gen_ai.request.top_p";
137    pub const REQUEST_MAX_TOKENS: &str = "gen_ai.request.max_tokens";
138    pub const REQUEST_SEED: &str = "gen_ai.request.seed";
139    pub const REQUEST_FREQUENCY_PENALTY: &str = "gen_ai.request.frequency_penalty";
140    pub const REQUEST_PRESENCE_PENALTY: &str = "gen_ai.request.presence_penalty";
141    pub const REQUEST_STOP_SEQUENCES: &str = "gen_ai.request.stop_sequences";
142    pub const CONVERSATION_ID: &str = "gen_ai.conversation.id";
143    /// Content-capture-gated: the system/instructions prompt, JSON-encoded as
144    /// `[{"type":"text","content":...}]` (mirrors
145    /// `observability.py:1444-1448`).
146    pub const SYSTEM_INSTRUCTIONS: &str = "gen_ai.system_instructions";
147    /// Content-capture-gated: the request's tool list, JSON-encoded (mirrors
148    /// `_tools_to_dict`, `observability.py:1388-1391`).
149    pub const TOOL_DEFINITIONS: &str = "gen_ai.tool.definitions";
150    pub const ERROR_TYPE: &str = "error.type";
151    pub const TOOL_NAME: &str = "gen_ai.tool.name";
152    pub const TOOL_CALL_ID: &str = "gen_ai.tool.call.id";
153    pub const TOOL_DESCRIPTION: &str = "gen_ai.tool.description";
154    pub const TOOL_TYPE: &str = "gen_ai.tool.type";
155    /// Content-capture-gated tool-call arguments (JSON-encoded).
156    pub const TOOL_CALL_ARGUMENTS: &str = "gen_ai.tool.call.arguments";
157    /// Content-capture-gated tool-call result (JSON-encoded).
158    pub const TOOL_CALL_RESULT: &str = "gen_ai.tool.call.result";
159    pub const AGENT_NAME: &str = "gen_ai.agent.name";
160    pub const AGENT_ID: &str = "gen_ai.agent.id";
161    pub const INPUT_MESSAGES: &str = "gen_ai.input.messages";
162    pub const OUTPUT_MESSAGES: &str = "gen_ai.output.messages";
163    /// The human-readable span name override consumed by OTel bridges.
164    pub const OTEL_NAME: &str = "otel.name";
165    /// `tracing-opentelemetry` special field: sets the OTel span status code
166    /// (`"ERROR"` or `"OK"`) when a bridge is attached.
167    pub const OTEL_STATUS_CODE: &str = "otel.status_code";
168    /// `tracing-opentelemetry` special field: sets the OTel span status
169    /// description. Used here to carry the exception message, mirroring
170    /// Python's `capture_exception` (`observability.py:1407-1411`, which
171    /// calls `set_status(description=repr(exception))`).
172    pub const OTEL_STATUS_MESSAGE: &str = "otel.status_message";
173}
174
175/// OpenTelemetry GenAI operation names.
176pub mod op {
177    pub const CHAT: &str = "chat";
178    pub const INVOKE_AGENT: &str = "invoke_agent";
179    pub const EXECUTE_TOOL: &str = "execute_tool";
180    pub const EMBEDDINGS: &str = "embeddings";
181}
182
183/// The `error.type` value for a framework [`Error`]: its variant discriminant.
184pub fn error_type(err: &Error) -> String {
185    // The Display of the thiserror variants is prefixed with a stable label,
186    // e.g. "tool error: …"; take the label as a compact type tag.
187    match err {
188        Error::AgentInitialization(_) => "agent_initialization",
189        Error::AgentExecution(_) => "agent_execution",
190        Error::Serialization(_) => "serialization",
191        Error::Content(_) => "content",
192        Error::Tool(_) => "tool",
193        Error::MiddlewareFailure(_) => "middleware_failure",
194        Error::Service(_) => "service",
195        Error::ServiceStatus { .. } => "service",
196        Error::ServiceInvalidAuth { .. } => "service_invalid_auth",
197        Error::ServiceInvalidRequest { .. } => "service_invalid_request",
198        Error::ServiceContentFilter { .. } => "service_content_filter",
199        Error::Workflow(_) => "workflow",
200        Error::AdditionItemMismatch(_) => "addition_item_mismatch",
201        Error::Configuration(_) => "configuration",
202        Error::Json(_) => "json",
203        Error::Other(_) => "other",
204    }
205    .to_string()
206}
207
208/// Build a `chat {model}` span for a chat-completion request.
209///
210/// Only the fields that are cheap/always-known at call time are set here
211/// (`gen_ai.system` / `gen_ai.provider.name`, `gen_ai.request.model`); the
212/// rest of the request/response/error attribute set is filled in afterward
213/// via [`record_request`], [`record_response`], and [`record_error`] — mirrors
214/// `_get_span_attributes` (`observability.py:1345-1404`).
215pub fn chat_span(system: &str, model: &str, latest_semconv: bool) -> Span {
216    let span = tracing::info_span!(
217        "chat",
218        otel.name = Empty,
219        gen_ai.operation.name = op::CHAT,
220        gen_ai.system = Empty,
221        gen_ai.provider.name = Empty,
222        gen_ai.request.model = model,
223        gen_ai.response.model = Empty,
224        gen_ai.response.id = Empty,
225        gen_ai.response.finish_reasons = Empty,
226        gen_ai.usage.input_tokens = Empty,
227        gen_ai.usage.output_tokens = Empty,
228        gen_ai.usage.cache_creation.input_tokens = Empty,
229        gen_ai.usage.cache_read.input_tokens = Empty,
230        gen_ai.usage.reasoning.output_tokens = Empty,
231        gen_ai.request.temperature = Empty,
232        gen_ai.request.top_p = Empty,
233        gen_ai.request.max_tokens = Empty,
234        gen_ai.request.seed = Empty,
235        gen_ai.request.frequency_penalty = Empty,
236        gen_ai.request.presence_penalty = Empty,
237        gen_ai.request.stop_sequences = Empty,
238        gen_ai.conversation.id = Empty,
239        gen_ai.system_instructions = Empty,
240        gen_ai.tool.definitions = Empty,
241        error.type = Empty,
242        otel.status_code = Empty,
243        otel.status_message = Empty,
244        gen_ai.input.messages = Empty,
245        gen_ai.output.messages = Empty,
246    );
247    // `gen_ai.system` was renamed to `gen_ai.provider.name` above v1.36.0;
248    // emit the one the active convention defines. A field left `Empty` is
249    // never exported, so the other name simply does not appear on the span.
250    if latest_semconv {
251        span.record(attr::PROVIDER_NAME, system);
252    } else {
253        span.record(attr::SYSTEM, system);
254    }
255    span.record(attr::OTEL_NAME, format!("{} {}", op::CHAT, model).as_str());
256    span
257}
258
259/// Build an `invoke_agent {agent}` span for an agent run.
260pub fn agent_span(agent_name: &str, agent_id: &str) -> Span {
261    let span = tracing::info_span!(
262        "invoke_agent",
263        otel.name = Empty,
264        gen_ai.operation.name = op::INVOKE_AGENT,
265        gen_ai.agent.name = agent_name,
266        gen_ai.agent.id = agent_id,
267        gen_ai.usage.input_tokens = Empty,
268        gen_ai.usage.output_tokens = Empty,
269        error.type = Empty,
270        // Declared for forward-compatibility with `record_error`; the
271        // current `agent.rs` error path only records `error.type` directly,
272        // so these stay unset until that call site migrates.
273        otel.status_code = Empty,
274        otel.status_message = Empty,
275    );
276    span.record(
277        attr::OTEL_NAME,
278        format!("{} {}", op::INVOKE_AGENT, agent_name).as_str(),
279    );
280    span
281}
282
283/// Build an `execute_tool {tool}` span with the full OTel GenAI tool
284/// attribute set: name, call id, description, a fixed `"function"` tool type
285/// (the only kind executed through this in-process loop — mirrors Python's
286/// `get_function_span_attributes`, `observability.py:1284-1302`), and
287/// placeholders for the content-capture-gated call arguments/result (fill
288/// with [`record_tool_arguments`] / [`record_tool_result`]) and the
289/// error/status fields (fill with [`record_error`]).
290///
291/// [`tool_span`] is the source-compatible two-argument form used today by
292/// `client.rs`'s `FunctionInvokingChatClient`; it delegates here with
293/// `description = None`. New call sites — and that eventual migration —
294/// should call this directly to get a real tool description and light up the
295/// content-capture-gated attributes.
296pub fn tool_span_ex(tool_name: &str, call_id: &str, description: Option<&str>) -> Span {
297    let span = tracing::info_span!(
298        "execute_tool",
299        otel.name = Empty,
300        gen_ai.operation.name = op::EXECUTE_TOOL,
301        gen_ai.tool.name = tool_name,
302        gen_ai.tool.call.id = call_id,
303        gen_ai.tool.description = Empty,
304        gen_ai.tool.type = "function",
305        gen_ai.tool.call.arguments = Empty,
306        gen_ai.tool.call.result = Empty,
307        error.type = Empty,
308        otel.status_code = Empty,
309        otel.status_message = Empty,
310    );
311    if let Some(description) = description {
312        if !description.is_empty() {
313            span.record(attr::TOOL_DESCRIPTION, description);
314        }
315    }
316    span.record(
317        attr::OTEL_NAME,
318        format!("{} {}", op::EXECUTE_TOOL, tool_name).as_str(),
319    );
320    span
321}
322
323/// Build an `execute_tool {tool}` span (source-compatible two-argument form).
324///
325/// Delegates to [`tool_span_ex`] with no description. Prefer `tool_span_ex`
326/// for new call sites.
327pub fn tool_span(tool_name: &str, call_id: &str) -> Span {
328    tool_span_ex(tool_name, call_id, None)
329}
330
331/// Record tool-call arguments onto a tool span, gated by content capture
332/// (mirrors the `SENSITIVE_DATA_ENABLED`-gated `gen_ai.tool.call.arguments`
333/// capture in Python's `AIFunction.invoke`, `_tools.py:751-759`).
334pub fn record_tool_arguments(
335    span: &Span,
336    arguments: &serde_json::Value,
337    config: &ObservabilityConfig,
338) {
339    if !config.emit_tool_call_attributes() {
340        return;
341    }
342    span.record(attr::TOOL_CALL_ARGUMENTS, arguments.to_string().as_str());
343}
344
345/// Record a tool-call result onto a tool span, gated by content capture
346/// (mirrors the `gen_ai.tool.call.result` capture in Python's
347/// `AIFunction.invoke`, `_tools.py:779-787`).
348pub fn record_tool_result(span: &Span, result: &serde_json::Value, config: &ObservabilityConfig) {
349    if !config.emit_tool_call_attributes() {
350        return;
351    }
352    span.record(attr::TOOL_CALL_RESULT, result.to_string().as_str());
353}
354
355/// Record an error onto a span following the existing `error.type` pattern:
356/// `error.type` (the framework [`Error`] variant tag), plus the
357/// `tracing-opentelemetry` "special fields" `otel.status_code` (`"ERROR"`)
358/// and `otel.status_message` (the exception's `Display` text) so that a
359/// bridge sets real OTel span status. This mirrors Python's
360/// `capture_exception` (`record_exception` + `set_status`,
361/// `observability.py:1407-1411`) as far as bare `tracing` fields allow —
362/// there is no span-events API here without also taking on an SDK
363/// dependency.
364pub fn record_error(span: &Span, err: &Error) {
365    span.record(attr::ERROR_TYPE, error_type(err).as_str());
366    span.record(attr::OTEL_STATUS_CODE, "ERROR");
367    span.record(attr::OTEL_STATUS_MESSAGE, err.to_string().as_str());
368}
369
370/// Record the response-side attributes (finish reason, usage, id, model) onto
371/// `span`, mirroring `_get_response_attributes` (`observability.py:1488-1512`).
372pub fn record_response(span: &Span, response: &ChatResponse, config: &ObservabilityConfig) {
373    let capture_content = config.enable_sensitive_data;
374    if let Some(reason) = &response.finish_reason {
375        span.record(attr::FINISH_REASONS, reason.as_str());
376    }
377    if let Some(id) = &response.response_id {
378        span.record(attr::RESPONSE_ID, id.as_str());
379    }
380    if let Some(model) = &response.model {
381        span.record(attr::RESPONSE_MODEL, model.as_str());
382    }
383    if let Some(usage) = &response.usage_details {
384        if let Some(input) = usage.input_token_count {
385            span.record(attr::INPUT_TOKENS, input);
386        }
387        if let Some(output) = usage.output_token_count {
388            span.record(attr::OUTPUT_TOKENS, output);
389        }
390        // The cache and reasoning token counts were introduced above the
391        // v1.36.0 baseline, so a consumer pinned to the baseline must not see
392        // them (upstream #7673).
393        if config.use_latest_experimental_gen_ai_semconv() {
394            if let Some(v) = usage.cache_creation_input_token_count {
395                span.record(attr::CACHE_CREATION_INPUT_TOKENS, v);
396            }
397            if let Some(v) = usage.cache_read_input_token_count {
398                span.record(attr::CACHE_READ_INPUT_TOKENS, v);
399            }
400            if let Some(v) = usage.reasoning_output_token_count {
401                span.record(attr::REASONING_OUTPUT_TOKENS, v);
402            }
403        }
404    }
405    if capture_content {
406        span.record(
407            attr::OUTPUT_MESSAGES,
408            messages_json(&response.messages).as_str(),
409        );
410    }
411}
412
413/// Record request-side attributes onto a `chat` span from [`ChatOptions`],
414/// mirroring `_get_span_attributes` (`observability.py:1345-1404`). System
415/// instructions and the serialized tool list are additionally gated by
416/// `capture_content` (mirrors Python's `SENSITIVE_DATA_ENABLED` gate).
417pub fn record_request(span: &Span, options: &ChatOptions, config: &ObservabilityConfig) {
418    let capture_content = config.enable_sensitive_data;
419    if let Some(v) = options.temperature {
420        span.record(attr::REQUEST_TEMPERATURE, f64::from(v));
421    }
422    if let Some(v) = options.top_p {
423        span.record(attr::REQUEST_TOP_P, f64::from(v));
424    }
425    if let Some(v) = options.max_tokens {
426        span.record(attr::REQUEST_MAX_TOKENS, u64::from(v));
427    }
428    if let Some(v) = options.seed {
429        span.record(attr::REQUEST_SEED, v);
430    }
431    if let Some(v) = options.frequency_penalty {
432        span.record(attr::REQUEST_FREQUENCY_PENALTY, f64::from(v));
433    }
434    if let Some(v) = options.presence_penalty {
435        span.record(attr::REQUEST_PRESENCE_PENALTY, f64::from(v));
436    }
437    if let Some(stop) = &options.stop {
438        if !stop.is_empty() {
439            span.record(
440                attr::REQUEST_STOP_SEQUENCES,
441                serde_json::to_string(stop).unwrap_or_default().as_str(),
442            );
443        }
444    }
445    if let Some(id) = &options.conversation_id {
446        span.record(attr::CONVERSATION_ID, id.as_str());
447    }
448    if capture_content {
449        if let Some(instructions) = &options.instructions {
450            if !instructions.is_empty() {
451                span.record(
452                    attr::SYSTEM_INSTRUCTIONS,
453                    system_instructions_json(instructions).as_str(),
454                );
455            }
456        }
457        // `gen_ai.tool.definitions` is also an above-baseline attribute, so it
458        // needs the semconv version as well as content capture.
459        if !options.tools.is_empty() && config.use_latest_experimental_gen_ai_semconv() {
460            span.record(
461                attr::TOOL_DEFINITIONS,
462                tool_definitions_json(&options.tools).as_str(),
463            );
464        }
465    }
466}
467
468/// JSON-encode a system/instructions prompt as
469/// `[{"type":"text","content":...}]`, mirroring
470/// `observability.py:1444-1448`.
471fn system_instructions_json(instructions: &str) -> String {
472    serde_json::json!([{ "type": "text", "content": instructions }]).to_string()
473}
474
475/// JSON-encode a tool list for `gen_ai.tool.definitions`, mirroring the
476/// shape produced by `_tools_to_dict` (`_tools.py:827-857`) closely enough to
477/// be useful without depending on any provider-specific wire format.
478fn tool_definitions_json(tools: &[ToolDefinition]) -> String {
479    let list: Vec<serde_json::Value> = tools
480        .iter()
481        .map(|t| {
482            serde_json::json!({
483                "name": t.name,
484                "description": t.description,
485                "parameters": t.parameters,
486            })
487        })
488        .collect();
489    serde_json::to_string(&list).unwrap_or_default()
490}
491
492/// Serialize messages to a compact JSON string for content-capture attributes.
493fn messages_json(messages: &[Message]) -> String {
494    serde_json::to_string(messages).unwrap_or_default()
495}
496
497/// Per-stream bookkeeping threaded through [`ObservableChatClient`]'s
498/// `get_streaming_response` `unfold` state, so the finalization arm can
499/// record response attributes and, with `otel-metrics`, the completion
500/// histograms. `system` / `request_model` / `start` are only needed for the
501/// latter, so they (and the work to populate them) are compiled out
502/// entirely when the feature is off.
503struct StreamTelemetry {
504    config: ObservabilityConfig,
505    #[cfg(feature = "otel-metrics")]
506    system: String,
507    #[cfg(feature = "otel-metrics")]
508    request_model: String,
509    #[cfg(feature = "otel-metrics")]
510    start: std::time::Instant,
511}
512
513/// A [`ChatClient`] decorator that emits a `chat` span per request following the
514/// OpenTelemetry GenAI semantic conventions.
515///
516/// Message-content capture (`gen_ai.input.messages` / `gen_ai.output.messages`,
517/// plus `gen_ai.system_instructions` / `gen_ai.tool.definitions`) is **off by
518/// default**; enable it with [`ObservableChatClient::with_content_capture`],
519/// mirroring Python's `enable_sensitive_data` flag — or construct via
520/// [`ObservableChatClient::from_env`] to read that flag from
521/// `ENABLE_SENSITIVE_DATA`.
522pub struct ObservableChatClient<C: ChatClient> {
523    inner: C,
524    system: String,
525    config: ObservabilityConfig,
526}
527
528impl<C: ChatClient> ObservableChatClient<C> {
529    /// Wrap `inner`, tagging spans with the given provider/system name (the
530    /// `gen_ai.system` / `gen_ai.provider.name` attributes), e.g. `"openai"`
531    /// or `"anthropic"`.
532    pub fn new(inner: C, system: impl Into<String>) -> Self {
533        Self {
534            inner,
535            system: system.into(),
536            // Content capture stays off unless asked for; the semconv opt-in
537            // still comes from the environment, since it describes the
538            // *consumer* of the telemetry rather than this call site.
539            config: ObservabilityConfig {
540                enable_sensitive_data: false,
541                ..ObservabilityConfig::from_env()
542            },
543        }
544    }
545
546    /// Wrap `inner` using [`ObservabilityConfig::from_env`] to decide whether
547    /// content capture is enabled (`ENABLE_SENSITIVE_DATA`). Equivalent to:
548    ///
549    /// ```ignore
550    /// ObservableChatClient::new(inner, system)
551    ///     .with_content_capture(ObservabilityConfig::from_env().enable_sensitive_data)
552    /// ```
553    pub fn from_env(inner: C, system: impl Into<String>) -> Self {
554        let config = ObservabilityConfig::from_env();
555        Self::new(inner, system).with_observability_config(config)
556    }
557
558    /// Enable or disable capturing message content on spans (default: off).
559    pub fn with_content_capture(mut self, capture: bool) -> Self {
560        self.config.enable_sensitive_data = capture;
561        self
562    }
563
564    /// Replace the whole [`ObservabilityConfig`] — content capture *and* the
565    /// GenAI semantic-convention version this client emits for.
566    pub fn with_observability_config(mut self, config: ObservabilityConfig) -> Self {
567        self.config = config;
568        self
569    }
570
571    /// A reference to the wrapped client.
572    pub fn inner(&self) -> &C {
573        &self.inner
574    }
575
576    fn model_for(&self, options: &ChatOptions) -> String {
577        options
578            .model
579            .clone()
580            .or_else(|| self.inner.model().map(str::to_string))
581            .unwrap_or_default()
582    }
583}
584
585#[async_trait]
586impl<C: ChatClient> ChatClient for ObservableChatClient<C> {
587    async fn get_response(
588        &self,
589        messages: Vec<Message>,
590        options: ChatOptions,
591    ) -> Result<ChatResponse> {
592        let request_model = self.model_for(&options);
593        let span = chat_span(
594            &self.system,
595            &request_model,
596            self.config.use_latest_experimental_gen_ai_semconv(),
597        );
598        record_request(&span, &options, &self.config);
599        if self.config.enable_sensitive_data {
600            span.record(attr::INPUT_MESSAGES, messages_json(&messages).as_str());
601        }
602        let config = self.config.clone();
603        #[cfg(feature = "otel-metrics")]
604        let start = std::time::Instant::now();
605        async move {
606            let result = self.inner.get_response(messages, options).await;
607            let span = Span::current();
608            match &result {
609                Ok(response) => {
610                    record_response(&span, response, &config);
611                    #[cfg(feature = "otel-metrics")]
612                    {
613                        let (input_tokens, output_tokens) = response
614                            .usage_details
615                            .as_ref()
616                            .map(|u| (u.input_token_count, u.output_token_count))
617                            .unwrap_or((None, None));
618                        metrics::record_chat_completion(
619                            &self.system,
620                            &request_model,
621                            response.model.as_deref(),
622                            input_tokens,
623                            output_tokens,
624                            start.elapsed(),
625                            config.use_latest_experimental_gen_ai_semconv(),
626                        );
627                    }
628                }
629                Err(err) => {
630                    record_error(&span, err);
631                }
632            }
633            result
634        }
635        .instrument(span)
636        .await
637    }
638
639    async fn get_streaming_response(
640        &self,
641        messages: Vec<Message>,
642        options: ChatOptions,
643    ) -> Result<ChatStream> {
644        let request_model = self.model_for(&options);
645        let span = chat_span(
646            &self.system,
647            &request_model,
648            self.config.use_latest_experimental_gen_ai_semconv(),
649        );
650        record_request(&span, &options, &self.config);
651        if self.config.enable_sensitive_data {
652            span.record(attr::INPUT_MESSAGES, messages_json(&messages).as_str());
653        }
654        let config = self.config.clone();
655        // Instrument the initiation future with the span (never hold an
656        // `enter()` guard across an await); attribute recording happens as
657        // the stream drains and completes.
658        let inner = self
659            .inner
660            .get_streaming_response(messages, options)
661            .instrument(span.clone())
662            .await;
663
664        let inner = match inner {
665            Ok(s) => s,
666            Err(err) => {
667                record_error(&span, &err);
668                return Err(err);
669            }
670        };
671
672        // Aggregate updates to recover finish reason / usage from the final
673        // chunks, recording them onto the span (and, with `otel-metrics`, the
674        // completion histograms) when the stream ends.
675        let telemetry = StreamTelemetry {
676            config,
677            #[cfg(feature = "otel-metrics")]
678            system: self.system.clone(),
679            #[cfg(feature = "otel-metrics")]
680            request_model: request_model.clone(),
681            #[cfg(feature = "otel-metrics")]
682            start: std::time::Instant::now(),
683        };
684        let state = (inner, ChatResponse::default(), Some(span), false, telemetry);
685        let stream = futures::stream::unfold(
686            state,
687            |(mut inner, mut agg, mut span, done, telemetry)| async move {
688                if done {
689                    return None;
690                }
691                match inner.next().await {
692                    Some(Ok(update)) => {
693                        agg.absorb_update(update.clone());
694                        Some((Ok(update), (inner, agg, span, false, telemetry)))
695                    }
696                    Some(Err(err)) => {
697                        if let Some(span) = &span {
698                            record_error(span, &err);
699                        }
700                        Some((Err(err), (inner, agg, span.take(), true, telemetry)))
701                    }
702                    None => {
703                        if let Some(span) = span.take() {
704                            agg.finalize();
705                            record_response(&span, &agg, &telemetry.config);
706                            #[cfg(feature = "otel-metrics")]
707                            {
708                                let (input_tokens, output_tokens) = agg
709                                    .usage_details
710                                    .as_ref()
711                                    .map(|u| (u.input_token_count, u.output_token_count))
712                                    .unwrap_or((None, None));
713                                metrics::record_chat_completion(
714                                    &telemetry.system,
715                                    &telemetry.request_model,
716                                    agg.model.as_deref(),
717                                    input_tokens,
718                                    output_tokens,
719                                    telemetry.start.elapsed(),
720                                    telemetry.config.use_latest_experimental_gen_ai_semconv(),
721                                );
722                            }
723                        }
724                        None
725                    }
726                }
727            },
728        );
729        Ok(stream.boxed())
730    }
731
732    fn model(&self) -> Option<&str> {
733        self.inner.model()
734    }
735}
736
737/// Observability configuration read from the process environment, mirroring
738/// (a subset of) Python's `ObservabilitySettings` (`observability.py:347-394`).
739///
740/// This port intentionally does **not** read `ENABLE_OTEL`: Python uses that
741/// flag to skip constructing spans entirely when no heavier OTel SDK is
742/// configured. This crate's spans are plain [`tracing`] spans, which are
743/// already effectively free when no subscriber is attached, so there is no
744/// separate "enable" switch — attach a subscriber (optionally bridged to
745/// OTel; see the [module docs](self)) to turn observability on.
746///
747/// This crate does **not** build an OTel `Resource`, exporter, or
748/// `MeterProvider` — see the [module docs](self) for wiring those yourself.
749#[derive(Debug, Clone, Default)]
750pub struct ObservabilityConfig {
751    /// Whether message / system-instructions / tool-definition / tool-call
752    /// content capture is enabled. Reads `ENABLE_SENSITIVE_DATA` (mirrors
753    /// Python's `enable_sensitive_data` — "Warning: Sensitive events should
754    /// only be enabled on test and development environments.").
755    pub enable_sensitive_data: bool,
756    /// The raw `OTEL_SEMCONV_STABILITY_OPT_IN` value: a comma-separated
757    /// opt-in list in OpenTelemetry's standard format. Only the
758    /// [`GEN_AI_LATEST_EXPERIMENTAL_OPT_IN`] token is consulted here — see
759    /// [`use_latest_experimental_gen_ai_semconv`](Self::use_latest_experimental_gen_ai_semconv).
760    pub otel_semconv_stability_opt_in: Option<String>,
761}
762
763impl ObservabilityConfig {
764    /// Read configuration from the process environment. Unset or
765    /// unrecognized values default to `false` (matching Python's default).
766    /// Recognized truthy values (case-insensitive, surrounding whitespace
767    /// ignored): `"1"`, `"true"`, `"yes"`, `"on"`.
768    pub fn from_env() -> Self {
769        Self {
770            enable_sensitive_data: env_flag("ENABLE_SENSITIVE_DATA"),
771            otel_semconv_stability_opt_in: std::env::var("OTEL_SEMCONV_STABILITY_OPT_IN").ok(),
772        }
773    }
774
775    /// Whether to emit the GenAI semantic conventions **above** the v1.36.0
776    /// baseline.
777    ///
778    /// v1.36.0 is the OTel-recommended baseline; every version above it is
779    /// "latest" here. Computed from
780    /// [`otel_semconv_stability_opt_in`](Self::otel_semconv_stability_opt_in):
781    /// unset defaults to `true` (opted in), which — as upstream documents —
782    /// deliberately differs from OpenTelemetry's own default of retaining the
783    /// baseline. Set the variable to a list that omits
784    /// [`GEN_AI_LATEST_EXPERIMENTAL_OPT_IN`] to get the baseline instead.
785    ///
786    /// Two things change when this is `false`:
787    ///
788    /// - the provider is reported as `gen_ai.system` rather than
789    ///   `gen_ai.provider.name` (the rename landed above v1.36.0), and
790    /// - the attributes introduced above the baseline
791    ///   (`gen_ai.usage.cache_creation.input_tokens`,
792    ///   `gen_ai.usage.cache_read.input_tokens`,
793    ///   `gen_ai.usage.reasoning.output_tokens`, `gen_ai.tool.definitions`,
794    ///   `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`) are not
795    ///   emitted at all.
796    pub fn use_latest_experimental_gen_ai_semconv(&self) -> bool {
797        match &self.otel_semconv_stability_opt_in {
798            None => true,
799            Some(value) => value
800                .split(',')
801                .any(|token| token.trim() == GEN_AI_LATEST_EXPERIMENTAL_OPT_IN),
802        }
803    }
804
805    /// Whether to emit `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result`
806    /// on `execute_tool` spans. These carry tool content *and* were introduced
807    /// above v1.36.0, so they need both content capture and the semconv
808    /// version that defines them.
809    pub fn emit_tool_call_attributes(&self) -> bool {
810        self.enable_sensitive_data && self.use_latest_experimental_gen_ai_semconv()
811    }
812
813    /// The `OTEL_SERVICE_NAME` passthrough, defaulting to `"agent_framework"`
814    /// (mirrors Python's `_create_resource`, `observability.py:336-344`).
815    /// This crate does not build a `Resource` from it — it's exposed so an
816    /// app wiring its own OTel `Resource` (see the [module docs](self)) can
817    /// stay consistent with whatever this reads. Reads the environment fresh
818    /// on every call rather than caching it at [`from_env`](Self::from_env)
819    /// time.
820    pub fn otel_service_name(&self) -> String {
821        std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "agent_framework".to_string())
822    }
823}
824
825/// Parse a boolean-ish environment variable, matching Python's
826/// pydantic-settings-style truthy strings. Missing or unrecognized values are
827/// `false`.
828fn env_flag(key: &str) -> bool {
829    std::env::var(key).is_ok_and(|v| {
830        matches!(
831            v.trim().to_ascii_lowercase().as_str(),
832            "1" | "true" | "yes" | "on"
833        )
834    })
835}
836
837/// Metrics instruments behind the `otel-metrics` feature (see the crate's
838/// `[features]` table). Uses the `opentelemetry` **API** crate only
839/// (`"metrics"` feature, no default features): instrument creation and
840/// `.record()` calls are inert until an application installs a real
841/// `MeterProvider` via [`opentelemetry::global::set_meter_provider`] — the
842/// API's own default global provider is a no-op — so enabling this feature
843/// never *requires* an application to also wire up an OTel SDK. See the
844/// [module docs](super) for how to do that when you do want real metrics out
845/// the other end.
846///
847/// Mirrors upstream's two chat-client histograms (`observability.py:788-803`,
848/// bucket boundaries at `:65-96`) and the function-invocation-duration
849/// histogram (`_tools.py`'s `_default_histogram`).
850#[cfg(feature = "otel-metrics")]
851pub mod metrics {
852    use std::sync::OnceLock;
853    use std::time::Duration;
854
855    use opentelemetry::metrics::Histogram;
856    use opentelemetry::KeyValue;
857
858    use super::{attr, op};
859
860    /// Bucket boundaries for `gen_ai.client.token.usage`, matching upstream's
861    /// `TOKEN_USAGE_BUCKET_BOUNDARIES` (`observability.py:65-80`).
862    pub const TOKEN_USAGE_BUCKET_BOUNDARIES: &[f64] = &[
863        1.0,
864        4.0,
865        16.0,
866        64.0,
867        256.0,
868        1024.0,
869        4096.0,
870        16384.0,
871        65536.0,
872        262_144.0,
873        1_048_576.0,
874        4_194_304.0,
875        16_777_216.0,
876        67_108_864.0,
877    ];
878
879    /// Bucket boundaries for `gen_ai.client.operation.duration` and
880    /// `agent_framework.function.invocation.duration`, matching upstream's
881    /// `OPERATION_DURATION_BUCKET_BOUNDARIES` (`observability.py:81-96`).
882    pub const OPERATION_DURATION_BUCKET_BOUNDARIES: &[f64] = &[
883        0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92,
884    ];
885
886    /// `Meters.LLM_TOKEN_USAGE`: the token-usage histogram name.
887    pub const TOKEN_USAGE_METRIC: &str = "gen_ai.client.token.usage";
888    /// `Meters.LLM_OPERATION_DURATION`: the operation-duration histogram name.
889    pub const OPERATION_DURATION_METRIC: &str = "gen_ai.client.operation.duration";
890    /// `OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION`: the
891    /// function-invocation-duration histogram name.
892    pub const FUNCTION_INVOCATION_DURATION_METRIC: &str =
893        "agent_framework.function.invocation.duration";
894
895    const TOKEN_TYPE: &str = "gen_ai.token.type";
896    const TOKEN_TYPE_INPUT: &str = "input";
897    const TOKEN_TYPE_OUTPUT: &str = "output";
898    const FUNCTION_NAME: &str = "agent_framework.function.name";
899
900    struct Metrics {
901        token_usage: Histogram<u64>,
902        operation_duration: Histogram<f64>,
903        function_invocation_duration: Histogram<f64>,
904    }
905
906    static METRICS: OnceLock<Metrics> = OnceLock::new();
907
908    /// The lazily-initialized instrument set, bound to whichever
909    /// [`opentelemetry::global`] meter provider is installed the first time
910    /// this is called in this process — matches `global::meter`'s own
911    /// "bound at call time" semantics, so install your provider before
912    /// running any instrumented code.
913    fn instruments() -> &'static Metrics {
914        METRICS.get_or_init(|| {
915            let meter = opentelemetry::global::meter("agent_framework");
916            Metrics {
917                token_usage: meter
918                    .u64_histogram(TOKEN_USAGE_METRIC)
919                    .with_unit("tokens")
920                    .with_description("Captures the token usage of chat clients")
921                    .with_boundaries(TOKEN_USAGE_BUCKET_BOUNDARIES.to_vec())
922                    .build(),
923                operation_duration: meter
924                    .f64_histogram(OPERATION_DURATION_METRIC)
925                    .with_unit("s")
926                    .with_description("Captures the duration of chat client operations")
927                    .with_boundaries(OPERATION_DURATION_BUCKET_BOUNDARIES.to_vec())
928                    .build(),
929                function_invocation_duration: meter
930                    .f64_histogram(FUNCTION_INVOCATION_DURATION_METRIC)
931                    .with_unit("s")
932                    .with_description("Measures the duration of a function's execution")
933                    .with_boundaries(OPERATION_DURATION_BUCKET_BOUNDARIES.to_vec())
934                    .build(),
935            }
936        })
937    }
938
939    /// Record one completed chat call's histograms, mirroring
940    /// `_capture_response` (`observability.py:1525-1543`): the token-usage
941    /// histogram records once per token type present on the response; the
942    /// operation-duration histogram always records. Only called from the
943    /// success path of [`super::ObservableChatClient`] — like upstream, a
944    /// failed call records neither histogram.
945    pub(super) fn record_chat_completion(
946        provider: &str,
947        request_model: &str,
948        response_model: Option<&str>,
949        input_tokens: Option<u64>,
950        output_tokens: Option<u64>,
951        duration: Duration,
952        latest_semconv: bool,
953    ) {
954        let m = instruments();
955        // Same rename as on the spans: `gen_ai.system` below the v1.36.0
956        // baseline, `gen_ai.provider.name` above it.
957        let provider_key = if latest_semconv {
958            attr::PROVIDER_NAME
959        } else {
960            attr::SYSTEM
961        };
962        let mut base = vec![
963            KeyValue::new(attr::OPERATION, op::CHAT),
964            KeyValue::new(provider_key, provider.to_string()),
965            KeyValue::new(attr::REQUEST_MODEL, request_model.to_string()),
966        ];
967        if let Some(model) = response_model {
968            base.push(KeyValue::new(attr::RESPONSE_MODEL, model.to_string()));
969        }
970        if let Some(input) = input_tokens {
971            let mut attrs = base.clone();
972            attrs.push(KeyValue::new(TOKEN_TYPE, TOKEN_TYPE_INPUT));
973            m.token_usage.record(input, &attrs);
974        }
975        if let Some(output) = output_tokens {
976            let mut attrs = base.clone();
977            attrs.push(KeyValue::new(TOKEN_TYPE, TOKEN_TYPE_OUTPUT));
978            m.token_usage.record(output, &attrs);
979        }
980        m.operation_duration.record(duration.as_secs_f64(), &base);
981    }
982
983    /// Record the function-invocation-duration histogram for one tool call.
984    ///
985    /// Not yet called anywhere in this crate: the timing measurement belongs
986    /// around `exec.invoke(...)` in `client.rs`'s
987    /// `FunctionInvokingChatClient::execute_tool_call`, which is out of scope
988    /// here (see the observability task's final report for the exact
989    /// follow-up). The instrument and its recording logic are complete and
990    /// tested on their own so that call site only needs to wrap its
991    /// invocation with a timer and call this — plus, ideally, switch
992    /// `tool_span` to [`super::tool_span_ex`] and add
993    /// [`super::record_tool_arguments`] / [`super::record_tool_result`] calls
994    /// at the same time.
995    pub fn record_function_invocation_duration(
996        tool_name: &str,
997        duration: Duration,
998        error_type: Option<&str>,
999    ) {
1000        let m = instruments();
1001        let mut attrs = vec![KeyValue::new(FUNCTION_NAME, tool_name.to_string())];
1002        if let Some(err) = error_type {
1003            attrs.push(KeyValue::new(attr::ERROR_TYPE, err.to_string()));
1004        }
1005        m.function_invocation_duration
1006            .record(duration.as_secs_f64(), &attrs);
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013
1014    // -- Attribute string values (cross-language wire contract) ----------
1015
1016    #[test]
1017    fn cache_reasoning_and_embedding_attrs_match_upstream() {
1018        // These strings are the OTel GenAI attribute keys upstream emits;
1019        // they must match exactly for cross-tool/cross-language consistency.
1020        assert_eq!(
1021            attr::CACHE_CREATION_INPUT_TOKENS,
1022            "gen_ai.usage.cache_creation.input_tokens"
1023        );
1024        assert_eq!(
1025            attr::CACHE_READ_INPUT_TOKENS,
1026            "gen_ai.usage.cache_read.input_tokens"
1027        );
1028        assert_eq!(
1029            attr::REASONING_OUTPUT_TOKENS,
1030            "gen_ai.usage.reasoning.output_tokens"
1031        );
1032        assert_eq!(attr::PROMPT_NAME, "gen_ai.prompt.name");
1033        assert_eq!(op::EMBEDDINGS, "embeddings");
1034    }
1035
1036    // -- ObservabilityConfig::from_env -----------------------------------
1037
1038    /// Guards env var mutation: tests run on multiple threads within a
1039    /// crate, and env vars are process-global (same pattern as
1040    /// `agent-framework-copilotstudio`'s / `agent-framework-mem0`'s
1041    /// `ENV_MUTEX`).
1042    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1043
1044    fn clear_env() {
1045        // SAFETY: serialized by ENV_MUTEX; no other test in this crate
1046        // touches these variables (confirmed via a repo-wide grep before
1047        // adding this).
1048        unsafe {
1049            std::env::remove_var("ENABLE_SENSITIVE_DATA");
1050            std::env::remove_var("OTEL_SERVICE_NAME");
1051        }
1052    }
1053
1054    #[test]
1055    fn from_env_defaults_to_disabled_when_unset() {
1056        let _guard = ENV_MUTEX.lock().unwrap();
1057        clear_env();
1058        let config = ObservabilityConfig::from_env();
1059        assert!(!config.enable_sensitive_data);
1060        assert_eq!(config.otel_service_name(), "agent_framework");
1061    }
1062
1063    #[test]
1064    fn from_env_reads_truthy_enable_sensitive_data() {
1065        let _guard = ENV_MUTEX.lock().unwrap();
1066        for value in ["1", "true", "TRUE", "  yes  ", "on"] {
1067            clear_env();
1068            // SAFETY: serialized by ENV_MUTEX.
1069            unsafe { std::env::set_var("ENABLE_SENSITIVE_DATA", value) };
1070            let config = ObservabilityConfig::from_env();
1071            assert!(
1072                config.enable_sensitive_data,
1073                "expected {value:?} to be truthy"
1074            );
1075        }
1076        clear_env();
1077    }
1078
1079    #[test]
1080    fn from_env_rejects_unrecognized_values() {
1081        let _guard = ENV_MUTEX.lock().unwrap();
1082        clear_env();
1083        // SAFETY: serialized by ENV_MUTEX.
1084        unsafe { std::env::set_var("ENABLE_SENSITIVE_DATA", "nope") };
1085        let config = ObservabilityConfig::from_env();
1086        clear_env();
1087        assert!(!config.enable_sensitive_data);
1088    }
1089
1090    #[test]
1091    fn otel_service_name_reads_env_override() {
1092        let _guard = ENV_MUTEX.lock().unwrap();
1093        clear_env();
1094        // SAFETY: serialized by ENV_MUTEX.
1095        unsafe { std::env::set_var("OTEL_SERVICE_NAME", "my-service") };
1096        let config = ObservabilityConfig::from_env();
1097        let name = config.otel_service_name();
1098        clear_env();
1099        assert_eq!(name, "my-service");
1100    }
1101
1102    // -- error_type --------------------------------------------------------
1103
1104    #[test]
1105    fn error_type_gives_the_granular_service_errors_distinct_tags() {
1106        // The three newer variants get their own `error.type` tags, distinct
1107        // from both each other and the generic "service"/"service" tags that
1108        // `Error::Service`/`Error::ServiceStatus` share — that granularity is
1109        // the whole point of adding them.
1110        assert_eq!(
1111            error_type(&Error::service_invalid_auth("x")),
1112            "service_invalid_auth"
1113        );
1114        assert_eq!(
1115            error_type(&Error::service_invalid_request("x")),
1116            "service_invalid_request"
1117        );
1118        assert_eq!(
1119            error_type(&Error::service_content_filter("x")),
1120            "service_content_filter"
1121        );
1122        assert_eq!(error_type(&Error::service("x")), "service");
1123        assert_eq!(
1124            error_type(&Error::service_status(500, "x", None)),
1125            "service"
1126        );
1127    }
1128}