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