Skip to main content

systemprompt_api/services/middleware/analytics/
mod.rs

1//! Request analytics middleware.
2//!
3//! [`AnalyticsMiddleware`] records tracked requests after the response is
4//! produced, spawning detached tasks for session activity, velocity-based
5//! scanner detection, behavioural bot scoring, and analytics-event capture so
6//! the request path is never blocked on persistence.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11mod 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    /// Runs the behavioural-detection input collection directly.
47    ///
48    /// Lets the per-query success and fallback branches (fingerprint stats,
49    /// session timeline) be exercised deterministically without racing the
50    /// fire-and-forget task the middleware spawns.
51    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}