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
8mod 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    /// Runs the behavioural-detection input collection directly.
44    ///
45    /// Lets the per-query success and fallback branches (fingerprint stats,
46    /// session timeline) be exercised deterministically without racing the
47    /// fire-and-forget task the middleware spawns.
48    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}