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}