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