Skip to main content

systemprompt_traits/
analytics.rs

1//! Session analytics and fingerprinting provider traits.
2//!
3//! These traits are dispatched as trait objects (`dyn _`), so they use
4//! `#[async_trait]`; native `async fn` in traits is not yet `dyn`-compatible.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use http::{HeaderMap, Uri};
12use std::net::IpAddr;
13use std::sync::Arc;
14use systemprompt_identifiers::{SessionId, SessionSource, UserId};
15
16pub type AnalyticsResult<T> = Result<T, AnalyticsProviderError>;
17
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum AnalyticsProviderError {
21    #[error("Session not found")]
22    SessionNotFound,
23
24    #[error("Fingerprint not found")]
25    FingerprintNotFound,
26
27    #[error("Internal error: {0}")]
28    Internal(String),
29}
30
31/// A single HTTP request reduced to the signals the session pipeline records.
32///
33/// Produced once per request by an [`AnalyticsProvider`] and passed by
34/// reference from there on — the classification verdicts (`is_bot`,
35/// `is_ai_crawler`, `skip_tracking`) are decided by the provider, which owns
36/// the keyword tables, so no consumer re-derives them.
37#[derive(Debug, Clone, Default)]
38pub struct SessionAnalytics {
39    pub ip_address: Option<String>,
40    pub user_agent: Option<String>,
41    pub device_type: Option<String>,
42    pub browser: Option<String>,
43    pub os: Option<String>,
44    pub fingerprint_hash: Option<String>,
45    pub preferred_locale: Option<String>,
46    pub country: Option<String>,
47    pub region: Option<String>,
48    pub city: Option<String>,
49    pub referrer_source: Option<String>,
50    pub referrer_url: Option<String>,
51    pub landing_page: Option<String>,
52    pub entry_url: Option<String>,
53    pub utm_source: Option<String>,
54    pub utm_medium: Option<String>,
55    pub utm_campaign: Option<String>,
56    pub utm_content: Option<String>,
57    pub utm_term: Option<String>,
58    pub is_bot: bool,
59    pub is_ai_crawler: bool,
60    /// Whether the session pipeline should suppress the analytics write.
61    /// Broader than `is_bot`: also covers bot IP ranges, datacenter ranges,
62    /// high-risk countries, and spam referrers.
63    pub skip_tracking: bool,
64}
65
66impl SessionAnalytics {
67    /// Returns the client-supplied fingerprint when present, else derives a
68    /// stable one from the user agent and locale.
69    pub fn compute_fingerprint(&self) -> String {
70        use xxhash_rust::xxh64::xxh64;
71
72        if let Some(hash) = &self.fingerprint_hash {
73            return hash.clone();
74        }
75
76        let data = format!(
77            "{}|{}",
78            self.user_agent.as_deref().unwrap_or(""),
79            self.preferred_locale.as_deref().unwrap_or("")
80        );
81
82        format!("fp_{:016x}", xxh64(data.as_bytes(), 0))
83    }
84}
85
86#[derive(Debug, Clone)]
87pub struct AnalyticsSession {
88    pub session_id: SessionId,
89    pub user_id: Option<UserId>,
90    pub fingerprint: Option<String>,
91    pub created_at: DateTime<Utc>,
92}
93
94#[derive(Debug, Clone)]
95pub struct ActiveSession {
96    pub user_id: Option<UserId>,
97}
98
99#[derive(Debug)]
100pub struct CreateSessionInput<'a> {
101    pub session_id: &'a SessionId,
102    pub user_id: Option<&'a UserId>,
103    pub analytics: &'a SessionAnalytics,
104    pub session_source: SessionSource,
105    pub is_bot: bool,
106    pub is_ai_crawler: bool,
107    pub expires_at: DateTime<Utc>,
108}
109
110/// Optional request signals for analytics extraction that vary per call site.
111/// `GeoIP` and content-routing are supplied by the provider itself, so only the
112/// request-scoped inputs live here.
113#[derive(Debug, Default, Clone, Copy)]
114pub struct ExtractSignals<'a> {
115    pub uri: Option<&'a Uri>,
116    pub caller_ip: Option<IpAddr>,
117}
118
119#[async_trait]
120pub trait AnalyticsProvider: Send + Sync {
121    fn extract_analytics(
122        &self,
123        headers: &HeaderMap,
124        signals: ExtractSignals<'_>,
125    ) -> SessionAnalytics;
126
127    async fn create_session(&self, input: CreateSessionInput<'_>) -> AnalyticsResult<()>;
128
129    async fn find_recent_session_by_fingerprint(
130        &self,
131        fingerprint: &str,
132        max_age_seconds: i64,
133    ) -> AnalyticsResult<Option<AnalyticsSession>>;
134
135    async fn find_session_by_id(
136        &self,
137        session_id: &SessionId,
138    ) -> AnalyticsResult<Option<AnalyticsSession>>;
139
140    async fn find_active_session_by_id(
141        &self,
142        session_id: &SessionId,
143    ) -> AnalyticsResult<Option<ActiveSession>>;
144
145    async fn revoke_session(&self, session_id: &SessionId) -> AnalyticsResult<()>;
146
147    async fn revoke_all_sessions_for_user(&self, user_id: &UserId) -> AnalyticsResult<u64>;
148
149    async fn migrate_user_sessions(
150        &self,
151        from_user_id: &UserId,
152        to_user_id: &UserId,
153    ) -> AnalyticsResult<u64>;
154
155    async fn mark_session_converted(&self, session_id: &SessionId) -> AnalyticsResult<()>;
156}
157
158#[async_trait]
159pub trait FingerprintProvider: Send + Sync {
160    async fn count_active_sessions(&self, fingerprint: &str) -> AnalyticsResult<i64>;
161
162    async fn find_reusable_session(&self, fingerprint: &str) -> AnalyticsResult<Option<String>>;
163
164    async fn upsert_fingerprint(
165        &self,
166        fingerprint: &str,
167        ip_address: Option<&str>,
168        user_agent: Option<&str>,
169        screen_info: Option<&str>,
170    ) -> AnalyticsResult<()>;
171}
172
173pub type DynAnalyticsProvider = Arc<dyn AnalyticsProvider>;
174
175pub type DynFingerprintProvider = Arc<dyn FingerprintProvider>;