systemprompt_api/services/middleware/analytics/
mod.rs1mod detection;
12mod events;
13
14use axum::extract::Request;
15use axum::http::StatusCode;
16use axum::middleware::Next;
17use axum::response::Response;
18use std::sync::Arc;
19
20use systemprompt_analytics::SessionRepository;
21use systemprompt_identifiers::SessionId;
22use systemprompt_logging::AnalyticsRepository;
23use systemprompt_models::{RequestContext, RouteClassifier};
24use systemprompt_runtime::AppContext;
25use systemprompt_security::ScannerDetector;
26
27pub use events::AnalyticsEventParams;
28
29#[cfg(feature = "test-api")]
30pub mod test_api {
31 use std::sync::Arc;
32
33 use systemprompt_analytics::{BehavioralAnalysisInput, SessionRepository};
34 use systemprompt_identifiers::SessionId;
35
36 #[must_use]
37 pub fn sanitize_uri(uri: &http::Uri) -> String {
38 super::events::sanitize_uri(uri)
39 }
40
41 #[must_use]
42 pub fn is_sensitive_key(key: &str) -> bool {
43 super::events::is_sensitive_key(key)
44 }
45
46 pub async fn collect_analysis_input(
52 session_repo: &Arc<SessionRepository>,
53 session_id: SessionId,
54 fingerprint_hash: Option<String>,
55 user_agent: Option<String>,
56 request_count: i64,
57 ) -> BehavioralAnalysisInput {
58 super::detection::collect_analysis_input_for_test(
59 session_repo,
60 session_id,
61 fingerprint_hash,
62 user_agent,
63 request_count,
64 )
65 .await
66 }
67}
68
69struct TrackingParams<'a> {
70 req_ctx: &'a RequestContext,
71 uri: &'a http::Uri,
72 method: &'a http::Method,
73 status_code: u16,
74 response_time_ms: u64,
75 user_agent: Option<String>,
76 referer: Option<String>,
77 is_scanner: bool,
78}
79
80#[derive(Debug, Clone)]
81pub struct AnalyticsMiddleware {
82 session_repo: Arc<SessionRepository>,
83 analytics_repo: Arc<AnalyticsRepository>,
84 route_classifier: Arc<RouteClassifier>,
85}
86
87impl AnalyticsMiddleware {
88 pub fn new(app_context: &AppContext) -> anyhow::Result<Self> {
89 let db_pool = app_context.db_pool();
90 let session_repo = Arc::new(SessionRepository::new(db_pool)?);
91 let analytics_repo = Arc::new(AnalyticsRepository::new(db_pool)?);
92 let route_classifier = Arc::clone(app_context.route_classifier());
93
94 Ok(Self {
95 session_repo,
96 analytics_repo,
97 route_classifier,
98 })
99 }
100
101 pub async fn track_request(
102 &self,
103 request: Request,
104 next: Next,
105 ) -> Result<Response, StatusCode> {
106 let method = request.method().clone();
107 let uri = request.uri().clone();
108
109 let Some(req_ctx) = request.extensions().get::<RequestContext>().cloned() else {
110 return Ok(next.run(request).await);
111 };
112
113 if !req_ctx.request.is_tracked {
114 return Ok(next.run(request).await);
115 }
116
117 let user_agent = request
118 .headers()
119 .get("user-agent")
120 .and_then(|v| v.to_str().ok())
121 .map(str::to_owned);
122
123 let referer = request
124 .headers()
125 .get("referer")
126 .and_then(|v| v.to_str().ok())
127 .map(str::to_owned);
128
129 let start_time = std::time::Instant::now();
130 let response = next.run(request).await;
131 let response_time_ms = start_time.elapsed().as_millis() as u64;
132 let status_code = response.status();
133
134 let should_track = self
135 .route_classifier
136 .should_track_analytics(uri.path(), method.as_str());
137
138 let is_scanner =
139 ScannerDetector::is_scanner(Some(uri.path()), user_agent.as_deref(), None, None);
140
141 if should_track {
142 self.spawn_tracking_tasks(TrackingParams {
143 req_ctx: &req_ctx,
144 uri: &uri,
145 method: &method,
146 status_code: status_code.as_u16(),
147 response_time_ms,
148 user_agent,
149 referer,
150 is_scanner,
151 });
152 }
153
154 Ok(response)
155 }
156
157 fn spawn_tracking_tasks(&self, params: TrackingParams<'_>) {
158 let req_ctx = params.req_ctx;
159 let uri = params.uri;
160 let method = params.method;
161 let status_code = params.status_code;
162 let response_time_ms = params.response_time_ms;
163 let user_agent = params.user_agent;
164 let referer = params.referer;
165 let is_scanner = params.is_scanner;
166 let endpoint = format!("{} {}", method, uri.path());
167 let path = uri.path().to_owned();
168
169 if is_scanner {
170 self.spawn_mark_scanner_task(req_ctx.request.session_id.clone());
171 }
172
173 self.spawn_velocity_scanner_check(req_ctx.request.session_id.clone());
174
175 self.spawn_session_tracking_task(req_ctx.request.session_id.clone());
176
177 detection::spawn_behavioral_detection_task(
178 Arc::clone(&self.session_repo),
179 req_ctx.request.session_id.clone(),
180 req_ctx.request.fingerprint_hash.clone(),
181 user_agent.clone(),
182 1,
183 );
184
185 events::spawn_analytics_event_task(
186 Arc::clone(&self.analytics_repo),
187 Arc::clone(&self.route_classifier),
188 AnalyticsEventParams {
189 req_ctx: req_ctx.clone(),
190 endpoint,
191 path,
192 method: method.to_string(),
193 uri: uri.clone(),
194 status_code,
195 response_time_ms,
196 user_agent,
197 referer,
198 },
199 );
200 }
201
202 fn spawn_session_tracking_task(&self, session_id: SessionId) {
203 let session_repo = Arc::clone(&self.session_repo);
204
205 tokio::spawn(async move {
206 if let Err(e) = session_repo.update_activity(&session_id).await {
207 tracing::error!(error = %e, "Failed to update session activity");
208 }
209
210 if let Err(e) = session_repo.increment_request_count(&session_id).await {
211 tracing::error!(error = %e, "Failed to increment request count");
212 }
213 });
214 }
215
216 fn spawn_velocity_scanner_check(&self, session_id: SessionId) {
217 let session_repo = Arc::clone(&self.session_repo);
218
219 tokio::spawn(async move {
220 let (request_count, duration_seconds) = session_repo
221 .get_session_velocity(&session_id)
222 .await
223 .unwrap_or((None, None));
224
225 if let (Some(count), Some(duration)) = (request_count, duration_seconds)
226 && ScannerDetector::is_high_velocity(count, duration)
227 && let Err(e) = session_repo.mark_as_scanner(&session_id).await
228 {
229 tracing::warn!(
230 error = %e,
231 session_id = %session_id,
232 "Failed to mark high-velocity session as scanner"
233 );
234 }
235 });
236 }
237
238 fn spawn_mark_scanner_task(&self, session_id: SessionId) {
239 let session_repo = Arc::clone(&self.session_repo);
240
241 tokio::spawn(async move {
242 if let Err(e) = session_repo.mark_as_scanner(&session_id).await {
243 tracing::warn!(error = %e, session_id = %session_id, "Failed to mark session as scanner");
244 }
245 });
246 }
247}