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