Skip to main content

systemprompt_traits/
analytics.rs

1//! Request analytics extraction, session lifecycle, and fingerprint provider
2//! traits.
3//!
4//! [`AnalyticsProvider`], [`FingerprintProvider`] and [`SessionUsageCounters`]
5//! are held as `Arc<dyn _>` (see the `Dyn*` aliases) by the runtime context
6//! and by domain services, so they use `#[async_trait]`; native `async fn`
7//! in traits is not `dyn`-compatible.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use http::{HeaderMap, Uri};
15use std::net::IpAddr;
16use std::sync::Arc;
17use systemprompt_identifiers::{SessionId, SessionSource, UserId};
18
19pub type AnalyticsResult<T> = Result<T, AnalyticsProviderError>;
20
21#[derive(Debug, thiserror::Error)]
22#[non_exhaustive]
23pub enum AnalyticsProviderError {
24    #[error("Session not found")]
25    SessionNotFound,
26
27    #[error("Fingerprint not found")]
28    FingerprintNotFound,
29
30    #[error("Internal error: {0}")]
31    Internal(String),
32}
33
34/// A single HTTP request reduced to the signals the session pipeline records.
35///
36/// Produced once per request by an [`AnalyticsProvider`] and passed by
37/// reference from there on — the classification verdicts (`is_bot`,
38/// `is_ai_crawler`, `skip_tracking`) are decided by the provider, which owns
39/// the keyword tables, so no consumer re-derives them.
40#[derive(Debug, Clone, Default)]
41pub struct SessionAnalytics {
42    pub ip_address: Option<String>,
43    pub user_agent: Option<String>,
44    pub device_type: Option<String>,
45    pub browser: Option<String>,
46    pub os: Option<String>,
47    pub fingerprint_hash: Option<String>,
48    pub preferred_locale: Option<String>,
49    pub country: Option<String>,
50    pub region: Option<String>,
51    pub city: Option<String>,
52    pub referrer_source: Option<String>,
53    pub referrer_url: Option<String>,
54    pub landing_page: Option<String>,
55    pub entry_url: Option<String>,
56    pub utm_source: Option<String>,
57    pub utm_medium: Option<String>,
58    pub utm_campaign: Option<String>,
59    pub utm_content: Option<String>,
60    pub utm_term: Option<String>,
61    pub is_bot: bool,
62    pub is_ai_crawler: bool,
63    pub skip_tracking: bool,
64}
65
66impl SessionAnalytics {
67    pub fn compute_fingerprint(&self) -> String {
68        use xxhash_rust::xxh64::xxh64;
69
70        if let Some(hash) = &self.fingerprint_hash {
71            return hash.clone();
72        }
73
74        let data = format!(
75            "{}|{}",
76            self.user_agent.as_deref().unwrap_or(""),
77            self.preferred_locale.as_deref().unwrap_or("")
78        );
79
80        format!("fp_{:016x}", xxh64(data.as_bytes(), 0))
81    }
82}
83
84#[derive(Debug, Clone)]
85pub struct AnalyticsSession {
86    pub session_id: SessionId,
87    pub user_id: Option<UserId>,
88    pub fingerprint: Option<String>,
89    pub created_at: DateTime<Utc>,
90}
91
92#[derive(Debug, Clone)]
93pub struct ActiveSession {
94    pub user_id: Option<UserId>,
95}
96
97#[derive(Debug)]
98pub struct CreateSessionInput<'a> {
99    pub session_id: &'a SessionId,
100    pub user_id: Option<&'a UserId>,
101    pub analytics: &'a SessionAnalytics,
102    pub session_source: SessionSource,
103    pub is_bot: bool,
104    pub is_ai_crawler: bool,
105    pub expires_at: DateTime<Utc>,
106}
107
108impl<'a> CreateSessionInput<'a> {
109    #[must_use]
110    pub const fn new(
111        session_id: &'a SessionId,
112        analytics: &'a SessionAnalytics,
113        session_source: SessionSource,
114        expires_at: DateTime<Utc>,
115    ) -> Self {
116        Self {
117            session_id,
118            user_id: None,
119            analytics,
120            session_source,
121            is_bot: false,
122            is_ai_crawler: false,
123            expires_at,
124        }
125    }
126
127    #[must_use]
128    pub const fn with_user_id(mut self, user_id: &'a UserId) -> Self {
129        self.user_id = Some(user_id);
130        self
131    }
132
133    #[must_use]
134    pub const fn with_classification(mut self, is_bot: bool, is_ai_crawler: bool) -> Self {
135        self.is_bot = is_bot;
136        self.is_ai_crawler = is_ai_crawler;
137        self
138    }
139}
140
141/// Optional request signals for analytics extraction that vary per call site.
142/// `GeoIP` and content-routing are supplied by the provider itself, so only the
143/// request-scoped inputs live here.
144#[derive(Debug, Default, Clone, Copy)]
145pub struct ExtractSignals<'a> {
146    pub uri: Option<&'a Uri>,
147    pub caller_ip: Option<IpAddr>,
148}
149
150pub trait AnalyticsProvider: Send + Sync {
151    fn extract_analytics(
152        &self,
153        headers: &HeaderMap,
154        signals: ExtractSignals<'_>,
155    ) -> SessionAnalytics;
156}
157
158#[async_trait]
159pub trait SessionProvider: Send + Sync {
160    async fn create_session(&self, input: CreateSessionInput<'_>) -> AnalyticsResult<()>;
161
162    async fn find_recent_session_by_fingerprint(
163        &self,
164        fingerprint: &str,
165        max_age_seconds: i64,
166    ) -> AnalyticsResult<Option<AnalyticsSession>>;
167
168    async fn find_session_by_id(
169        &self,
170        session_id: &SessionId,
171    ) -> AnalyticsResult<Option<AnalyticsSession>>;
172
173    async fn find_active_session_by_id(
174        &self,
175        session_id: &SessionId,
176    ) -> AnalyticsResult<Option<ActiveSession>>;
177
178    async fn revoke_session(&self, session_id: &SessionId) -> AnalyticsResult<()>;
179
180    async fn revoke_all_sessions_for_user(&self, user_id: &UserId) -> AnalyticsResult<u64>;
181
182    async fn migrate_user_sessions(
183        &self,
184        from_user_id: &UserId,
185        to_user_id: &UserId,
186    ) -> AnalyticsResult<u64>;
187
188    async fn mark_session_converted(&self, session_id: &SessionId) -> AnalyticsResult<()>;
189}
190
191/// Session-scoped usage counters bumped by domain workflows.
192///
193/// Fire-and-forget at the call sites (task and message creation): failures
194/// are logged, never propagated into the owning workflow. Held as
195/// `Arc<dyn SessionUsageCounters>`, hence `#[async_trait]`.
196#[async_trait]
197pub trait SessionUsageCounters: Send + Sync {
198    async fn increment_task_count(&self, session_id: &SessionId) -> AnalyticsResult<()>;
199
200    async fn increment_message_count(&self, session_id: &SessionId) -> AnalyticsResult<()>;
201}
202
203#[async_trait]
204pub trait FingerprintProvider: Send + Sync {
205    async fn count_active_sessions(&self, fingerprint: &str) -> AnalyticsResult<i64>;
206
207    async fn find_reusable_session(&self, fingerprint: &str) -> AnalyticsResult<Option<SessionId>>;
208
209    async fn upsert_fingerprint(
210        &self,
211        fingerprint: &str,
212        ip_address: Option<&str>,
213        user_agent: Option<&str>,
214        screen_info: Option<&str>,
215    ) -> AnalyticsResult<()>;
216}
217
218pub type DynAnalyticsProvider = Arc<dyn AnalyticsProvider>;
219
220pub type DynFingerprintProvider = Arc<dyn FingerprintProvider>;
221
222pub type DynSessionUsageCounters = Arc<dyn SessionUsageCounters>;
223
224pub type DynSessionProvider = Arc<dyn SessionProvider>;