use async_trait::async_trait;
use futures::StreamExt;
use tracing::field::Empty;
use tracing::{Instrument, Span};
use crate::client::{ChatClient, ChatStream};
use crate::error::{Error, Result};
use crate::tools::ToolDefinition;
use crate::types::{ChatOptions, ChatResponse, Message};
pub const GEN_AI_LATEST_EXPERIMENTAL_OPT_IN: &str = "gen_ai_latest_experimental";
pub mod attr {
pub const OPERATION: &str = "gen_ai.operation.name";
pub const SYSTEM: &str = "gen_ai.system";
pub const PROVIDER_NAME: &str = "gen_ai.provider.name";
pub const REQUEST_MODEL: &str = "gen_ai.request.model";
pub const RESPONSE_MODEL: &str = "gen_ai.response.model";
pub const RESPONSE_ID: &str = "gen_ai.response.id";
pub const FINISH_REASONS: &str = "gen_ai.response.finish_reasons";
pub const INPUT_TOKENS: &str = "gen_ai.usage.input_tokens";
pub const OUTPUT_TOKENS: &str = "gen_ai.usage.output_tokens";
pub const CACHE_CREATION_INPUT_TOKENS: &str = "gen_ai.usage.cache_creation.input_tokens";
pub const CACHE_READ_INPUT_TOKENS: &str = "gen_ai.usage.cache_read.input_tokens";
pub const REASONING_OUTPUT_TOKENS: &str = "gen_ai.usage.reasoning.output_tokens";
pub const PROMPT_NAME: &str = "gen_ai.prompt.name";
pub const REQUEST_TEMPERATURE: &str = "gen_ai.request.temperature";
pub const REQUEST_TOP_P: &str = "gen_ai.request.top_p";
pub const REQUEST_MAX_TOKENS: &str = "gen_ai.request.max_tokens";
pub const REQUEST_SEED: &str = "gen_ai.request.seed";
pub const REQUEST_FREQUENCY_PENALTY: &str = "gen_ai.request.frequency_penalty";
pub const REQUEST_PRESENCE_PENALTY: &str = "gen_ai.request.presence_penalty";
pub const REQUEST_STOP_SEQUENCES: &str = "gen_ai.request.stop_sequences";
pub const CONVERSATION_ID: &str = "gen_ai.conversation.id";
pub const SYSTEM_INSTRUCTIONS: &str = "gen_ai.system_instructions";
pub const TOOL_DEFINITIONS: &str = "gen_ai.tool.definitions";
pub const ERROR_TYPE: &str = "error.type";
pub const TOOL_NAME: &str = "gen_ai.tool.name";
pub const TOOL_CALL_ID: &str = "gen_ai.tool.call.id";
pub const TOOL_DESCRIPTION: &str = "gen_ai.tool.description";
pub const TOOL_TYPE: &str = "gen_ai.tool.type";
pub const TOOL_CALL_ARGUMENTS: &str = "gen_ai.tool.call.arguments";
pub const TOOL_CALL_RESULT: &str = "gen_ai.tool.call.result";
pub const AGENT_NAME: &str = "gen_ai.agent.name";
pub const AGENT_ID: &str = "gen_ai.agent.id";
pub const INPUT_MESSAGES: &str = "gen_ai.input.messages";
pub const OUTPUT_MESSAGES: &str = "gen_ai.output.messages";
pub const OTEL_NAME: &str = "otel.name";
pub const OTEL_STATUS_CODE: &str = "otel.status_code";
pub const OTEL_STATUS_MESSAGE: &str = "otel.status_message";
}
pub mod op {
pub const CHAT: &str = "chat";
pub const INVOKE_AGENT: &str = "invoke_agent";
pub const EXECUTE_TOOL: &str = "execute_tool";
pub const EMBEDDINGS: &str = "embeddings";
}
pub fn error_type(err: &Error) -> String {
match err {
Error::AgentInitialization(_) => "agent_initialization",
Error::AgentExecution(_) => "agent_execution",
Error::Serialization(_) => "serialization",
Error::Content(_) => "content",
Error::Tool(_) => "tool",
Error::MiddlewareFailure(_) => "middleware_failure",
Error::Service(_) => "service",
Error::ServiceStatus { .. } => "service",
Error::ServiceInvalidAuth { .. } => "service_invalid_auth",
Error::ServiceInvalidRequest { .. } => "service_invalid_request",
Error::ServiceContentFilter { .. } => "service_content_filter",
Error::Workflow(_) => "workflow",
Error::AdditionItemMismatch(_) => "addition_item_mismatch",
Error::Configuration(_) => "configuration",
Error::Json(_) => "json",
Error::Other(_) => "other",
}
.to_string()
}
pub fn chat_span(system: &str, model: &str, latest_semconv: bool) -> Span {
let span = tracing::info_span!(
"chat",
otel.name = Empty,
gen_ai.operation.name = op::CHAT,
gen_ai.system = Empty,
gen_ai.provider.name = Empty,
gen_ai.request.model = model,
gen_ai.response.model = Empty,
gen_ai.response.id = Empty,
gen_ai.response.finish_reasons = Empty,
gen_ai.usage.input_tokens = Empty,
gen_ai.usage.output_tokens = Empty,
gen_ai.usage.cache_creation.input_tokens = Empty,
gen_ai.usage.cache_read.input_tokens = Empty,
gen_ai.usage.reasoning.output_tokens = Empty,
gen_ai.request.temperature = Empty,
gen_ai.request.top_p = Empty,
gen_ai.request.max_tokens = Empty,
gen_ai.request.seed = Empty,
gen_ai.request.frequency_penalty = Empty,
gen_ai.request.presence_penalty = Empty,
gen_ai.request.stop_sequences = Empty,
gen_ai.conversation.id = Empty,
gen_ai.system_instructions = Empty,
gen_ai.tool.definitions = Empty,
error.type = Empty,
otel.status_code = Empty,
otel.status_message = Empty,
gen_ai.input.messages = Empty,
gen_ai.output.messages = Empty,
);
if latest_semconv {
span.record(attr::PROVIDER_NAME, system);
} else {
span.record(attr::SYSTEM, system);
}
span.record(attr::OTEL_NAME, format!("{} {}", op::CHAT, model).as_str());
span
}
pub fn agent_span(agent_name: &str, agent_id: &str) -> Span {
let span = tracing::info_span!(
"invoke_agent",
otel.name = Empty,
gen_ai.operation.name = op::INVOKE_AGENT,
gen_ai.agent.name = agent_name,
gen_ai.agent.id = agent_id,
gen_ai.usage.input_tokens = Empty,
gen_ai.usage.output_tokens = Empty,
error.type = Empty,
otel.status_code = Empty,
otel.status_message = Empty,
);
span.record(
attr::OTEL_NAME,
format!("{} {}", op::INVOKE_AGENT, agent_name).as_str(),
);
span
}
pub fn tool_span_ex(tool_name: &str, call_id: &str, description: Option<&str>) -> Span {
let span = tracing::info_span!(
"execute_tool",
otel.name = Empty,
gen_ai.operation.name = op::EXECUTE_TOOL,
gen_ai.tool.name = tool_name,
gen_ai.tool.call.id = call_id,
gen_ai.tool.description = Empty,
gen_ai.tool.type = "function",
gen_ai.tool.call.arguments = Empty,
gen_ai.tool.call.result = Empty,
error.type = Empty,
otel.status_code = Empty,
otel.status_message = Empty,
);
if let Some(description) = description {
if !description.is_empty() {
span.record(attr::TOOL_DESCRIPTION, description);
}
}
span.record(
attr::OTEL_NAME,
format!("{} {}", op::EXECUTE_TOOL, tool_name).as_str(),
);
span
}
pub fn tool_span(tool_name: &str, call_id: &str) -> Span {
tool_span_ex(tool_name, call_id, None)
}
pub fn record_tool_arguments(
span: &Span,
arguments: &serde_json::Value,
config: &ObservabilityConfig,
) {
if !config.emit_tool_call_attributes() {
return;
}
span.record(attr::TOOL_CALL_ARGUMENTS, arguments.to_string().as_str());
}
pub fn record_tool_result(span: &Span, result: &serde_json::Value, config: &ObservabilityConfig) {
if !config.emit_tool_call_attributes() {
return;
}
span.record(attr::TOOL_CALL_RESULT, result.to_string().as_str());
}
pub fn record_error(span: &Span, err: &Error) {
span.record(attr::ERROR_TYPE, error_type(err).as_str());
span.record(attr::OTEL_STATUS_CODE, "ERROR");
span.record(attr::OTEL_STATUS_MESSAGE, err.to_string().as_str());
}
pub fn record_response(span: &Span, response: &ChatResponse, config: &ObservabilityConfig) {
let capture_content = config.enable_sensitive_data;
if let Some(reason) = &response.finish_reason {
span.record(attr::FINISH_REASONS, reason.as_str());
}
if let Some(id) = &response.response_id {
span.record(attr::RESPONSE_ID, id.as_str());
}
if let Some(model) = &response.model {
span.record(attr::RESPONSE_MODEL, model.as_str());
}
if let Some(usage) = &response.usage_details {
if let Some(input) = usage.input_token_count {
span.record(attr::INPUT_TOKENS, input);
}
if let Some(output) = usage.output_token_count {
span.record(attr::OUTPUT_TOKENS, output);
}
if config.use_latest_experimental_gen_ai_semconv() {
if let Some(v) = usage.cache_creation_input_token_count {
span.record(attr::CACHE_CREATION_INPUT_TOKENS, v);
}
if let Some(v) = usage.cache_read_input_token_count {
span.record(attr::CACHE_READ_INPUT_TOKENS, v);
}
if let Some(v) = usage.reasoning_output_token_count {
span.record(attr::REASONING_OUTPUT_TOKENS, v);
}
}
}
if capture_content {
span.record(
attr::OUTPUT_MESSAGES,
messages_json(&response.messages).as_str(),
);
}
}
pub fn record_request(span: &Span, options: &ChatOptions, config: &ObservabilityConfig) {
let capture_content = config.enable_sensitive_data;
if let Some(v) = options.temperature {
span.record(attr::REQUEST_TEMPERATURE, f64::from(v));
}
if let Some(v) = options.top_p {
span.record(attr::REQUEST_TOP_P, f64::from(v));
}
if let Some(v) = options.max_tokens {
span.record(attr::REQUEST_MAX_TOKENS, u64::from(v));
}
if let Some(v) = options.seed {
span.record(attr::REQUEST_SEED, v);
}
if let Some(v) = options.frequency_penalty {
span.record(attr::REQUEST_FREQUENCY_PENALTY, f64::from(v));
}
if let Some(v) = options.presence_penalty {
span.record(attr::REQUEST_PRESENCE_PENALTY, f64::from(v));
}
if let Some(stop) = &options.stop {
if !stop.is_empty() {
span.record(
attr::REQUEST_STOP_SEQUENCES,
serde_json::to_string(stop).unwrap_or_default().as_str(),
);
}
}
if let Some(id) = &options.conversation_id {
span.record(attr::CONVERSATION_ID, id.as_str());
}
if capture_content {
if let Some(instructions) = &options.instructions {
if !instructions.is_empty() {
span.record(
attr::SYSTEM_INSTRUCTIONS,
system_instructions_json(instructions).as_str(),
);
}
}
if !options.tools.is_empty() && config.use_latest_experimental_gen_ai_semconv() {
span.record(
attr::TOOL_DEFINITIONS,
tool_definitions_json(&options.tools).as_str(),
);
}
}
}
fn system_instructions_json(instructions: &str) -> String {
serde_json::json!([{ "type": "text", "content": instructions }]).to_string()
}
fn tool_definitions_json(tools: &[ToolDefinition]) -> String {
let list: Vec<serde_json::Value> = tools
.iter()
.map(|t| {
serde_json::json!({
"name": t.name,
"description": t.description,
"parameters": t.parameters,
})
})
.collect();
serde_json::to_string(&list).unwrap_or_default()
}
fn messages_json(messages: &[Message]) -> String {
serde_json::to_string(messages).unwrap_or_default()
}
struct StreamTelemetry {
config: ObservabilityConfig,
#[cfg(feature = "otel-metrics")]
system: String,
#[cfg(feature = "otel-metrics")]
request_model: String,
#[cfg(feature = "otel-metrics")]
start: std::time::Instant,
}
pub struct ObservableChatClient<C: ChatClient> {
inner: C,
system: String,
config: ObservabilityConfig,
}
impl<C: ChatClient> ObservableChatClient<C> {
pub fn new(inner: C, system: impl Into<String>) -> Self {
Self {
inner,
system: system.into(),
config: ObservabilityConfig {
enable_sensitive_data: false,
..ObservabilityConfig::from_env()
},
}
}
pub fn from_env(inner: C, system: impl Into<String>) -> Self {
let config = ObservabilityConfig::from_env();
Self::new(inner, system).with_observability_config(config)
}
pub fn with_content_capture(mut self, capture: bool) -> Self {
self.config.enable_sensitive_data = capture;
self
}
pub fn with_observability_config(mut self, config: ObservabilityConfig) -> Self {
self.config = config;
self
}
pub fn inner(&self) -> &C {
&self.inner
}
fn model_for(&self, options: &ChatOptions) -> String {
options
.model
.clone()
.or_else(|| self.inner.model().map(str::to_string))
.unwrap_or_default()
}
}
#[async_trait]
impl<C: ChatClient> ChatClient for ObservableChatClient<C> {
async fn get_response(
&self,
messages: Vec<Message>,
options: ChatOptions,
) -> Result<ChatResponse> {
let request_model = self.model_for(&options);
let span = chat_span(
&self.system,
&request_model,
self.config.use_latest_experimental_gen_ai_semconv(),
);
record_request(&span, &options, &self.config);
if self.config.enable_sensitive_data {
span.record(attr::INPUT_MESSAGES, messages_json(&messages).as_str());
}
let config = self.config.clone();
#[cfg(feature = "otel-metrics")]
let start = std::time::Instant::now();
async move {
let result = self.inner.get_response(messages, options).await;
let span = Span::current();
match &result {
Ok(response) => {
record_response(&span, response, &config);
#[cfg(feature = "otel-metrics")]
{
let (input_tokens, output_tokens) = response
.usage_details
.as_ref()
.map(|u| (u.input_token_count, u.output_token_count))
.unwrap_or((None, None));
metrics::record_chat_completion(
&self.system,
&request_model,
response.model.as_deref(),
input_tokens,
output_tokens,
start.elapsed(),
config.use_latest_experimental_gen_ai_semconv(),
);
}
}
Err(err) => {
record_error(&span, err);
}
}
result
}
.instrument(span)
.await
}
async fn get_streaming_response(
&self,
messages: Vec<Message>,
options: ChatOptions,
) -> Result<ChatStream> {
let request_model = self.model_for(&options);
let span = chat_span(
&self.system,
&request_model,
self.config.use_latest_experimental_gen_ai_semconv(),
);
record_request(&span, &options, &self.config);
if self.config.enable_sensitive_data {
span.record(attr::INPUT_MESSAGES, messages_json(&messages).as_str());
}
let config = self.config.clone();
let inner = self
.inner
.get_streaming_response(messages, options)
.instrument(span.clone())
.await;
let inner = match inner {
Ok(s) => s,
Err(err) => {
record_error(&span, &err);
return Err(err);
}
};
let telemetry = StreamTelemetry {
config,
#[cfg(feature = "otel-metrics")]
system: self.system.clone(),
#[cfg(feature = "otel-metrics")]
request_model: request_model.clone(),
#[cfg(feature = "otel-metrics")]
start: std::time::Instant::now(),
};
let state = (inner, ChatResponse::default(), Some(span), false, telemetry);
let stream = futures::stream::unfold(
state,
|(mut inner, mut agg, mut span, done, telemetry)| async move {
if done {
return None;
}
match inner.next().await {
Some(Ok(update)) => {
agg.absorb_update(update.clone());
Some((Ok(update), (inner, agg, span, false, telemetry)))
}
Some(Err(err)) => {
if let Some(span) = &span {
record_error(span, &err);
}
Some((Err(err), (inner, agg, span.take(), true, telemetry)))
}
None => {
if let Some(span) = span.take() {
agg.finalize();
record_response(&span, &agg, &telemetry.config);
#[cfg(feature = "otel-metrics")]
{
let (input_tokens, output_tokens) = agg
.usage_details
.as_ref()
.map(|u| (u.input_token_count, u.output_token_count))
.unwrap_or((None, None));
metrics::record_chat_completion(
&telemetry.system,
&telemetry.request_model,
agg.model.as_deref(),
input_tokens,
output_tokens,
telemetry.start.elapsed(),
telemetry.config.use_latest_experimental_gen_ai_semconv(),
);
}
}
None
}
}
},
);
Ok(stream.boxed())
}
fn model(&self) -> Option<&str> {
self.inner.model()
}
}
#[derive(Debug, Clone, Default)]
pub struct ObservabilityConfig {
pub enable_sensitive_data: bool,
pub otel_semconv_stability_opt_in: Option<String>,
}
impl ObservabilityConfig {
pub fn from_env() -> Self {
Self {
enable_sensitive_data: env_flag("ENABLE_SENSITIVE_DATA"),
otel_semconv_stability_opt_in: std::env::var("OTEL_SEMCONV_STABILITY_OPT_IN").ok(),
}
}
pub fn use_latest_experimental_gen_ai_semconv(&self) -> bool {
match &self.otel_semconv_stability_opt_in {
None => true,
Some(value) => value
.split(',')
.any(|token| token.trim() == GEN_AI_LATEST_EXPERIMENTAL_OPT_IN),
}
}
pub fn emit_tool_call_attributes(&self) -> bool {
self.enable_sensitive_data && self.use_latest_experimental_gen_ai_semconv()
}
pub fn otel_service_name(&self) -> String {
std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "agent_framework".to_string())
}
}
fn env_flag(key: &str) -> bool {
std::env::var(key).is_ok_and(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
#[cfg(feature = "otel-metrics")]
pub mod metrics {
use std::sync::OnceLock;
use std::time::Duration;
use opentelemetry::metrics::Histogram;
use opentelemetry::KeyValue;
use super::{attr, op};
pub const TOKEN_USAGE_BUCKET_BOUNDARIES: &[f64] = &[
1.0,
4.0,
16.0,
64.0,
256.0,
1024.0,
4096.0,
16384.0,
65536.0,
262_144.0,
1_048_576.0,
4_194_304.0,
16_777_216.0,
67_108_864.0,
];
pub const OPERATION_DURATION_BUCKET_BOUNDARIES: &[f64] = &[
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,
];
pub const TOKEN_USAGE_METRIC: &str = "gen_ai.client.token.usage";
pub const OPERATION_DURATION_METRIC: &str = "gen_ai.client.operation.duration";
pub const FUNCTION_INVOCATION_DURATION_METRIC: &str =
"agent_framework.function.invocation.duration";
const TOKEN_TYPE: &str = "gen_ai.token.type";
const TOKEN_TYPE_INPUT: &str = "input";
const TOKEN_TYPE_OUTPUT: &str = "output";
const FUNCTION_NAME: &str = "agent_framework.function.name";
struct Metrics {
token_usage: Histogram<u64>,
operation_duration: Histogram<f64>,
function_invocation_duration: Histogram<f64>,
}
static METRICS: OnceLock<Metrics> = OnceLock::new();
fn instruments() -> &'static Metrics {
METRICS.get_or_init(|| {
let meter = opentelemetry::global::meter("agent_framework");
Metrics {
token_usage: meter
.u64_histogram(TOKEN_USAGE_METRIC)
.with_unit("tokens")
.with_description("Captures the token usage of chat clients")
.with_boundaries(TOKEN_USAGE_BUCKET_BOUNDARIES.to_vec())
.build(),
operation_duration: meter
.f64_histogram(OPERATION_DURATION_METRIC)
.with_unit("s")
.with_description("Captures the duration of chat client operations")
.with_boundaries(OPERATION_DURATION_BUCKET_BOUNDARIES.to_vec())
.build(),
function_invocation_duration: meter
.f64_histogram(FUNCTION_INVOCATION_DURATION_METRIC)
.with_unit("s")
.with_description("Measures the duration of a function's execution")
.with_boundaries(OPERATION_DURATION_BUCKET_BOUNDARIES.to_vec())
.build(),
}
})
}
pub(super) fn record_chat_completion(
provider: &str,
request_model: &str,
response_model: Option<&str>,
input_tokens: Option<u64>,
output_tokens: Option<u64>,
duration: Duration,
latest_semconv: bool,
) {
let m = instruments();
let provider_key = if latest_semconv {
attr::PROVIDER_NAME
} else {
attr::SYSTEM
};
let mut base = vec![
KeyValue::new(attr::OPERATION, op::CHAT),
KeyValue::new(provider_key, provider.to_string()),
KeyValue::new(attr::REQUEST_MODEL, request_model.to_string()),
];
if let Some(model) = response_model {
base.push(KeyValue::new(attr::RESPONSE_MODEL, model.to_string()));
}
if let Some(input) = input_tokens {
let mut attrs = base.clone();
attrs.push(KeyValue::new(TOKEN_TYPE, TOKEN_TYPE_INPUT));
m.token_usage.record(input, &attrs);
}
if let Some(output) = output_tokens {
let mut attrs = base.clone();
attrs.push(KeyValue::new(TOKEN_TYPE, TOKEN_TYPE_OUTPUT));
m.token_usage.record(output, &attrs);
}
m.operation_duration.record(duration.as_secs_f64(), &base);
}
pub fn record_function_invocation_duration(
tool_name: &str,
duration: Duration,
error_type: Option<&str>,
) {
let m = instruments();
let mut attrs = vec![KeyValue::new(FUNCTION_NAME, tool_name.to_string())];
if let Some(err) = error_type {
attrs.push(KeyValue::new(attr::ERROR_TYPE, err.to_string()));
}
m.function_invocation_duration
.record(duration.as_secs_f64(), &attrs);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_reasoning_and_embedding_attrs_match_upstream() {
assert_eq!(
attr::CACHE_CREATION_INPUT_TOKENS,
"gen_ai.usage.cache_creation.input_tokens"
);
assert_eq!(
attr::CACHE_READ_INPUT_TOKENS,
"gen_ai.usage.cache_read.input_tokens"
);
assert_eq!(
attr::REASONING_OUTPUT_TOKENS,
"gen_ai.usage.reasoning.output_tokens"
);
assert_eq!(attr::PROMPT_NAME, "gen_ai.prompt.name");
assert_eq!(op::EMBEDDINGS, "embeddings");
}
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn clear_env() {
unsafe {
std::env::remove_var("ENABLE_SENSITIVE_DATA");
std::env::remove_var("OTEL_SERVICE_NAME");
}
}
#[test]
fn from_env_defaults_to_disabled_when_unset() {
let _guard = ENV_MUTEX.lock().unwrap();
clear_env();
let config = ObservabilityConfig::from_env();
assert!(!config.enable_sensitive_data);
assert_eq!(config.otel_service_name(), "agent_framework");
}
#[test]
fn from_env_reads_truthy_enable_sensitive_data() {
let _guard = ENV_MUTEX.lock().unwrap();
for value in ["1", "true", "TRUE", " yes ", "on"] {
clear_env();
unsafe { std::env::set_var("ENABLE_SENSITIVE_DATA", value) };
let config = ObservabilityConfig::from_env();
assert!(
config.enable_sensitive_data,
"expected {value:?} to be truthy"
);
}
clear_env();
}
#[test]
fn from_env_rejects_unrecognized_values() {
let _guard = ENV_MUTEX.lock().unwrap();
clear_env();
unsafe { std::env::set_var("ENABLE_SENSITIVE_DATA", "nope") };
let config = ObservabilityConfig::from_env();
clear_env();
assert!(!config.enable_sensitive_data);
}
#[test]
fn otel_service_name_reads_env_override() {
let _guard = ENV_MUTEX.lock().unwrap();
clear_env();
unsafe { std::env::set_var("OTEL_SERVICE_NAME", "my-service") };
let config = ObservabilityConfig::from_env();
let name = config.otel_service_name();
clear_env();
assert_eq!(name, "my-service");
}
#[test]
fn error_type_gives_the_granular_service_errors_distinct_tags() {
assert_eq!(
error_type(&Error::service_invalid_auth("x")),
"service_invalid_auth"
);
assert_eq!(
error_type(&Error::service_invalid_request("x")),
"service_invalid_request"
);
assert_eq!(
error_type(&Error::service_content_filter("x")),
"service_content_filter"
);
assert_eq!(error_type(&Error::service("x")), "service");
assert_eq!(
error_type(&Error::service_status(500, "x", None)),
"service"
);
}
}