use std::marker::PhantomData;
use std::sync::Arc;
use rig::agent::{HookAction, PromptHook, ToolCallHookAction};
use rig::completion::{CompletionModel, CompletionResponse, Message};
use crate::emit::emit_kind;
use crate::event::{EventKind, PAYLOAD_TRUNCATE_BYTES, truncate_utf8};
use crate::sampling::{AlwaysSample, SamplingPolicy};
pub type ConversationIdResolver = Arc<dyn Fn() -> Option<String> + Send + Sync>;
pub type ModelResolver<R> = Arc<dyn Fn(&CompletionResponse<R>) -> Option<String> + Send + Sync>;
pub type PreviousResponseIdResolver<R> =
Arc<dyn Fn(&CompletionResponse<R>) -> Option<String> + Send + Sync>;
#[derive(Debug, Clone)]
pub struct TelemetryHookConfig {
pub model: String,
pub conversation_id: String,
pub payload_truncate_bytes: usize,
}
impl TelemetryHookConfig {
pub fn new(model: impl Into<String>, conversation_id: impl Into<String>) -> Self {
Self {
model: model.into(),
conversation_id: conversation_id.into(),
payload_truncate_bytes: PAYLOAD_TRUNCATE_BYTES,
}
}
}
pub struct TelemetryHook<M: CompletionModel> {
config: TelemetryHookConfig,
conversation_id_resolver: Option<ConversationIdResolver>,
model_resolver: Option<ModelResolver<M::Response>>,
previous_response_id_resolver: Option<PreviousResponseIdResolver<M::Response>>,
sampling: Arc<dyn SamplingPolicy>,
_model: PhantomData<fn() -> M>,
}
impl<M: CompletionModel> TelemetryHook<M> {
pub fn new(config: TelemetryHookConfig) -> Self {
Self {
config,
conversation_id_resolver: None,
model_resolver: None,
previous_response_id_resolver: None,
sampling: Arc::new(AlwaysSample),
_model: PhantomData,
}
}
pub fn with_defaults(model: impl Into<String>, conversation_id: impl Into<String>) -> Self {
Self::new(TelemetryHookConfig::new(model, conversation_id))
}
#[must_use]
pub fn with_conversation_id_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn() -> Option<String> + Send + Sync + 'static,
{
self.conversation_id_resolver = Some(Arc::new(resolver));
self
}
#[must_use]
pub fn with_model_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn(&CompletionResponse<M::Response>) -> Option<String> + Send + Sync + 'static,
{
self.model_resolver = Some(Arc::new(resolver));
self
}
#[must_use]
pub fn with_previous_response_id_resolver<F>(mut self, resolver: F) -> Self
where
F: Fn(&CompletionResponse<M::Response>) -> Option<String> + Send + Sync + 'static,
{
self.previous_response_id_resolver = Some(Arc::new(resolver));
self
}
#[must_use]
pub fn with_sampling_policy(mut self, policy: Arc<dyn SamplingPolicy>) -> Self {
self.sampling = policy;
self
}
fn resolved_conversation_id(&self) -> String {
self.conversation_id_resolver
.as_ref()
.and_then(|f| f())
.unwrap_or_else(|| self.config.conversation_id.clone())
}
fn resolved_model(&self, response: &CompletionResponse<M::Response>) -> String {
self.model_resolver
.as_ref()
.and_then(|f| f(response))
.unwrap_or_else(|| self.config.model.clone())
}
fn resolved_previous_response_id(
&self,
response: &CompletionResponse<M::Response>,
) -> Option<String> {
self.previous_response_id_resolver
.as_ref()
.and_then(|f| f(response))
}
pub fn observe_prompt_error(&self, error: &rig::completion::PromptError) {
let conversation_id = self.resolved_conversation_id();
if !self
.sampling
.should_sample("prompt.failed", &conversation_id)
{
return;
}
let (error_class, retriable, provider_error_code, http_status) = map_prompt_error(error);
crate::emit::emit_kind(
conversation_id,
crate::event::EventKind::PromptFailed {
model: self.config.model.clone(),
error_class,
message: error.to_string(),
retriable,
provider_error_code,
http_status,
},
);
}
pub fn observe_tool_error(
&self,
tool_name: &str,
call_id: &str,
error: &dyn std::error::Error,
) {
let conversation_id = self.resolved_conversation_id();
if !self.sampling.should_sample("tool.failed", call_id) {
return;
}
crate::emit::emit_kind(
conversation_id,
crate::event::EventKind::ToolFailed {
tool_name: tool_name.to_string(),
call_id: call_id.to_string(),
error_class: crate::event::ErrorClass::Unknown,
message: error.to_string(),
},
);
}
}
impl<M: CompletionModel> Clone for TelemetryHook<M> {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
conversation_id_resolver: self.conversation_id_resolver.clone(),
model_resolver: self.model_resolver.clone(),
previous_response_id_resolver: self.previous_response_id_resolver.clone(),
sampling: self.sampling.clone(),
_model: PhantomData,
}
}
}
impl<M: CompletionModel> std::fmt::Debug for TelemetryHook<M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TelemetryHook")
.field("config", &self.config)
.field(
"conversation_id_resolver",
&self.conversation_id_resolver.as_ref().map(|_| "<fn>"),
)
.field(
"model_resolver",
&self.model_resolver.as_ref().map(|_| "<fn>"),
)
.field(
"previous_response_id_resolver",
&self.previous_response_id_resolver.as_ref().map(|_| "<fn>"),
)
.field("sampling", &self.sampling)
.finish_non_exhaustive()
}
}
impl<M> PromptHook<M> for TelemetryHook<M>
where
M: CompletionModel,
{
async fn on_completion_call(&self, _prompt: &Message, history: &[Message]) -> HookAction {
let messages_in = history.len().saturating_add(1);
let conversation_id = self.resolved_conversation_id();
if self
.sampling
.should_sample("prompt.started", &conversation_id)
{
emit_kind(
conversation_id,
EventKind::PromptStarted {
model: self.config.model.clone(),
messages_in,
},
);
}
HookAction::cont()
}
async fn on_completion_response(
&self,
_prompt: &Message,
response: &CompletionResponse<M::Response>,
) -> HookAction {
let usage = response.usage;
let conversation_id = self.resolved_conversation_id();
if self
.sampling
.should_sample("prompt.completed", &conversation_id)
{
emit_kind(
conversation_id,
EventKind::PromptCompleted {
model: self.resolved_model(response),
tokens_in: positive(usage.input_tokens),
tokens_out: positive(usage.output_tokens),
cached_tokens_in: positive(usage.cached_input_tokens),
reasoning_tokens: positive(usage.reasoning_tokens),
cost_usd: None,
finish_reason: None,
response_id: response.message_id.clone(),
previous_response_id: self.resolved_previous_response_id(response),
time_to_first_token_ms: None,
duration_ms: None,
},
);
}
HookAction::cont()
}
async fn on_tool_call(
&self,
tool_name: &str,
tool_call_id: Option<String>,
internal_call_id: &str,
args: &str,
) -> ToolCallHookAction {
let (args_json, truncated) = truncate_utf8(args, self.config.payload_truncate_bytes);
if self
.sampling
.should_sample("tool.invoked", internal_call_id)
{
emit_kind(
self.resolved_conversation_id(),
EventKind::ToolInvoked {
tool_name: tool_name.to_string(),
provider_call_id: tool_call_id,
call_id: internal_call_id.to_string(),
args_json,
truncated,
},
);
}
ToolCallHookAction::cont()
}
async fn on_tool_result(
&self,
tool_name: &str,
tool_call_id: Option<String>,
internal_call_id: &str,
_args: &str,
result: &str,
) -> HookAction {
let (result, truncated) = truncate_utf8(result, self.config.payload_truncate_bytes);
if self
.sampling
.should_sample("tool.completed", internal_call_id)
{
emit_kind(
self.resolved_conversation_id(),
EventKind::ToolCompleted {
tool_name: tool_name.to_string(),
provider_call_id: tool_call_id,
call_id: internal_call_id.to_string(),
result,
truncated,
duration_ms: None,
},
);
}
HookAction::cont()
}
}
fn positive(value: u64) -> Option<u64> {
if value == 0 { None } else { Some(value) }
}
fn map_prompt_error(
err: &rig::completion::PromptError,
) -> (crate::event::ErrorClass, bool, Option<String>, Option<u16>) {
match err {
rig::completion::PromptError::CompletionError(e) => map_completion_error(e),
rig::completion::PromptError::ToolError(_) => {
(crate::event::ErrorClass::Validation, false, None, None)
}
_ => (crate::event::ErrorClass::Unknown, false, None, None),
}
}
fn map_completion_error(
err: &rig::completion::CompletionError,
) -> (crate::event::ErrorClass, bool, Option<String>, Option<u16>) {
use crate::event::ErrorClass;
match err {
rig::completion::CompletionError::HttpError(http_err) => {
let status = match http_err {
rig::http_client::Error::InvalidStatusCode(s) => Some(s.as_u16()),
rig::http_client::Error::InvalidStatusCodeWithMessage(s, _) => Some(s.as_u16()),
_ => None,
};
let (class, retriable) = match status {
Some(401 | 403) => (ErrorClass::Auth, false),
Some(429) => (ErrorClass::RateLimit, true),
Some(400 | 422 | 404) => (ErrorClass::Validation, false),
Some(408) => (ErrorClass::Timeout, true),
Some(500..=599) => (ErrorClass::ProviderServer, true),
_ => (ErrorClass::Transport, true),
};
(class, retriable, None, status)
}
rig::completion::CompletionError::JsonError(_)
| rig::completion::CompletionError::UrlError(_) => {
(ErrorClass::Validation, false, None, None)
}
rig::completion::CompletionError::ResponseError(_)
| rig::completion::CompletionError::ProviderError(_) => {
(ErrorClass::ProviderServer, true, None, None)
}
_ => (ErrorClass::Unknown, false, None, None),
}
}