systemprompt_api/services/middleware/analytics/
events.rs1use serde_json::json;
7use std::sync::Arc;
8
9use systemprompt_logging::{AnalyticsEvent, AnalyticsRepository};
10use systemprompt_models::{RequestContext, RouteClassifier};
11
12#[derive(Debug)]
13pub struct AnalyticsEventParams {
14 pub req_ctx: RequestContext,
15 pub endpoint: String,
16 pub path: String,
17 pub method: String,
18 pub uri: http::Uri,
19 pub status_code: u16,
20 pub response_time_ms: u64,
21 pub user_agent: Option<String>,
22 pub referer: Option<String>,
23}
24
25pub(super) fn spawn_analytics_event_task(
26 analytics_repo: Arc<AnalyticsRepository>,
27 route_classifier: Arc<RouteClassifier>,
28 params: AnalyticsEventParams,
29) {
30 let sanitized_uri = sanitize_uri(¶ms.uri);
31
32 tokio::spawn(async move {
33 let message = format!(
34 "HTTP {} - {} {}",
35 params.status_code, params.method, sanitized_uri
36 );
37 let metadata = json!({
38 "status_code": params.status_code,
39 "method": params.method,
40 "uri": sanitized_uri,
41 "endpoint": params.endpoint,
42 "trace_id": params.req_ctx.trace_id(),
43 "user_agent": params.user_agent,
44 "referer": params.referer
45 });
46
47 let event_metadata = route_classifier.get_event_metadata(¶ms.path, ¶ms.method);
48
49 let severity = if params.status_code >= 500 {
50 "error"
51 } else if params.status_code >= 400 {
52 "warning"
53 } else {
54 "info"
55 };
56
57 let event = AnalyticsEvent {
58 user_id: params.req_ctx.auth.actor.user_id.clone(),
59 session_id: params.req_ctx.request.session_id.clone(),
60 context_id: params.req_ctx.execution.context_id.clone(),
61 event_type: event_metadata.event_type.to_owned(),
62 event_category: event_metadata.event_category.to_owned(),
63 severity: severity.to_owned(),
64 endpoint: Some(params.endpoint),
65 error_code: if params.status_code >= 400 {
66 Some(i32::from(params.status_code))
67 } else {
68 None
69 },
70 response_time_ms: Some(params.response_time_ms as i32),
71 agent_id: None,
72 task_id: params.req_ctx.task_id().cloned(),
73 message: Some(message.clone()),
74 metadata: metadata.clone(),
75 };
76
77 if let Err(e) = analytics_repo.log_event(&event).await {
78 tracing::error!(error = %e, "Failed to log analytics event");
79 }
80
81 if params.status_code >= 500 {
82 tracing::error!(module = event_metadata.log_module, message = %message, metadata = ?metadata, "HTTP error");
83 }
84 });
85}
86
87pub(super) fn sanitize_uri(uri: &http::Uri) -> String {
88 let path = uri.path();
89
90 uri.query().map_or_else(
91 || path.to_owned(),
92 |query| {
93 let sanitized_params: Vec<String> = query
94 .split('&')
95 .map(|param| {
96 param.split_once('=').map_or_else(
97 || param.to_owned(),
98 |(key, value)| {
99 let key_lower = key.to_lowercase();
100 if is_sensitive_key(&key_lower) {
101 format!("{key}=[REDACTED]")
102 } else {
103 format!("{key}={value}")
104 }
105 },
106 )
107 })
108 .collect();
109
110 format!("{path}?{}", sanitized_params.join("&"))
111 },
112 )
113}
114
115pub(super) fn is_sensitive_key(key: &str) -> bool {
116 matches!(
117 key,
118 "token" | "password" | "api_key" | "apikey" | "secret" | "authorization" | "auth"
119 )
120}