Skip to main content

systemprompt_api/services/middleware/analytics/
events.rs

1//! Request-analytics event emission.
2//!
3//! A page view is an HTML page being served. The route classifier knows only
4//! the path, so an XHR under an HTML prefix (`/admin/auth/me` answered as
5//! JSON) or a form post answered with a redirect would count as one. The
6//! classification is therefore corrected against whether the response
7//! actually carried an HTML body.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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(&params.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(&params.path, &params.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}