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}