Skip to main content

systemprompt_api/services/middleware/analytics/
events.rs

1//! Request-analytics event emission.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use serde_json::json;
7use std::sync::Arc;
8
9use systemprompt_logging::{AnalyticsEvent, AnalyticsRepository};
10use systemprompt_models::{RequestContext, RouteClassifier};
11
12#[derive(Debug)]
13pub struct AnalyticsEventParams {
14    pub req_ctx: RequestContext,
15    pub endpoint: String,
16    pub path: String,
17    pub method: String,
18    pub uri: http::Uri,
19    pub status_code: u16,
20    pub response_time_ms: u64,
21    pub user_agent: Option<String>,
22    pub referer: Option<String>,
23}
24
25pub(super) fn spawn_analytics_event_task(
26    analytics_repo: Arc<AnalyticsRepository>,
27    route_classifier: Arc<RouteClassifier>,
28    params: AnalyticsEventParams,
29) {
30    let sanitized_uri = sanitize_uri(&params.uri);
31
32    tokio::spawn(async move {
33        let message = format!(
34            "HTTP {} - {} {}",
35            params.status_code, params.method, sanitized_uri
36        );
37        let metadata = json!({
38            "status_code": params.status_code,
39            "method": params.method,
40            "uri": sanitized_uri,
41            "endpoint": params.endpoint,
42            "trace_id": params.req_ctx.trace_id(),
43            "user_agent": params.user_agent,
44            "referer": params.referer
45        });
46
47        let event_metadata = route_classifier.get_event_metadata(&params.path, &params.method);
48
49        let severity = if params.status_code >= 500 {
50            "error"
51        } else if params.status_code >= 400 {
52            "warning"
53        } else {
54            "info"
55        };
56
57        let event = AnalyticsEvent {
58            user_id: params.req_ctx.auth.actor.user_id.clone(),
59            session_id: params.req_ctx.request.session_id.clone(),
60            context_id: params.req_ctx.execution.context_id.clone(),
61            event_type: event_metadata.event_type.to_owned(),
62            event_category: event_metadata.event_category.to_owned(),
63            severity: severity.to_owned(),
64            endpoint: Some(params.endpoint),
65            error_code: if params.status_code >= 400 {
66                Some(i32::from(params.status_code))
67            } else {
68                None
69            },
70            response_time_ms: Some(params.response_time_ms as i32),
71            agent_id: None,
72            task_id: params.req_ctx.task_id().cloned(),
73            message: Some(message.clone()),
74            metadata: metadata.clone(),
75        };
76
77        if let Err(e) = analytics_repo.log_event(&event).await {
78            tracing::error!(error = %e, "Failed to log analytics event");
79        }
80
81        if params.status_code >= 500 {
82            tracing::error!(module = event_metadata.log_module, message = %message, metadata = ?metadata, "HTTP error");
83        }
84    });
85}
86
87pub(super) fn sanitize_uri(uri: &http::Uri) -> String {
88    let path = uri.path();
89
90    uri.query().map_or_else(
91        || path.to_owned(),
92        |query| {
93            let sanitized_params: Vec<String> = query
94                .split('&')
95                .map(|param| {
96                    param.split_once('=').map_or_else(
97                        || param.to_owned(),
98                        |(key, value)| {
99                            let key_lower = key.to_lowercase();
100                            if is_sensitive_key(&key_lower) {
101                                format!("{key}=[REDACTED]")
102                            } else {
103                                format!("{key}={value}")
104                            }
105                        },
106                    )
107                })
108                .collect();
109
110            format!("{path}?{}", sanitized_params.join("&"))
111        },
112    )
113}
114
115pub(super) fn is_sensitive_key(key: &str) -> bool {
116    matches!(
117        key,
118        "token" | "password" | "api_key" | "apikey" | "secret" | "authorization" | "auth"
119    )
120}