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 _>` 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
19use crate::BoxedSource;
20
21pub type AnalyticsResult<T> = Result<T, AnalyticsProviderError>;
22
23#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum AnalyticsProviderError {
26    #[error("Session not found")]
27    SessionNotFound,
28
29    #[error("Fingerprint not found")]
30    FingerprintNotFound,
31
32    #[error("Internal error: {0}")]
33    Internal(#[source] BoxedSource),
34}
35
36/// A single HTTP request reduced to the signals the session pipeline records.
37///
38/// Produced once per request by an [`AnalyticsProvider`] and passed by
39/// reference from there on — the classification verdicts (`is_bot`,
40/// `is_ai_crawler`, `skip_tracking`) are decided by the provider, which owns
41/// the keyword tables, so no consumer re-derives them.
42#[derive(Debug, Clone, Default)]
43pub struct SessionAnalytics {
44    pub ip_address: Option<String>,
45    pub user_agent: Option<String>,
46    pub device_type: Option<String>,
47    pub browser: Option<String>,
48    pub os: Option<String>,
49    pub fingerprint_hash: Option<String>,
50    pub preferred_locale: Option<String>,
51    pub country: Option<String>,
52    pub region: Option<String>,
53    pub city: Option<String>,
54    pub referrer_source: Option<String>,
55    pub referrer_url: Option<String>,
56    pub landing_page: Option<String>,
57    pub entry_url: Option<String>,
58    pub utm_source: Option<String>,
59    pub utm_medium: Option<String>,
60    pub utm_campaign: Option<String>,
61    pub utm_content: Option<String>,
62    pub utm_term: Option<String>,
63    pub is_bot: bool,
64    pub is_ai_crawler: bool,
65    pub skip_tracking: bool,
66}
67
68impl SessionAnalytics {
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
110impl<'a> CreateSessionInput<'a> {
111    #[must_use]
112    pub const fn new(
113        session_id: &'a SessionId,
114        analytics: &'a SessionAnalytics,
115        session_source: SessionSource,
116        expires_at: DateTime<Utc>,
117    ) -> Self {
118        Self {
119            session_id,
120            user_id: None,
121            analytics,
122            session_source,
123            is_bot: false,
124            is_ai_crawler: false,
125            expires_at,
126        }
127    }
128
129    #[must_use]
130    pub const fn with_user_id(mut self, user_id: &'a UserId) -> Self {
131        self.user_id = Some(user_id);
132        self
133    }
134
135    #[must_use]
136    pub const fn with_classification(mut self, is_bot: bool, is_ai_crawler: bool) -> Self {
137        self.is_bot = is_bot;
138        self.is_ai_crawler = is_ai_crawler;
139        self
140    }
141}
142
143/// Optional request signals for analytics extraction that vary per call site.
144/// `GeoIP` and content-routing are supplied by the provider itself, so only the
145/// request-scoped inputs live here.
146#[derive(Debug, Default, Clone, Copy)]
147pub struct ExtractSignals<'a> {
148    pub uri: Option<&'a Uri>,
149    pub caller_ip: Option<IpAddr>,
150}
151
152pub trait AnalyticsProvider: Send + Sync {
153    fn extract_analytics(
154        &self,
155        headers: &HeaderMap,
156        signals: ExtractSignals<'_>,
157    ) -> SessionAnalytics;
158}
159
160#[async_trait]
161pub trait SessionProvider: Send + Sync {
162    async fn create_session(&self, input: CreateSessionInput<'_>) -> AnalyticsResult<()>;
163
164    async fn find_recent_session_by_fingerprint(
165        &self,
166        fingerprint: &str,
167        max_age_seconds: i64,
168    ) -> AnalyticsResult<Option<AnalyticsSession>>;
169
170    async fn find_session_by_id(
171        &self,
172        session_id: &SessionId,
173    ) -> AnalyticsResult<Option<AnalyticsSession>>;
174
175    async fn find_active_session_by_id(
176        &self,
177        session_id: &SessionId,
178    ) -> AnalyticsResult<Option<ActiveSession>>;
179
180    async fn revoke_session(&self, session_id: &SessionId) -> AnalyticsResult<()>;
181
182    async fn revoke_all_sessions_for_user(&self, user_id: &UserId) -> AnalyticsResult<u64>;
183
184    async fn migrate_user_sessions(
185        &self,
186        from_user_id: &UserId,
187        to_user_id: &UserId,
188    ) -> AnalyticsResult<u64>;
189
190    async fn mark_session_converted(&self, session_id: &SessionId) -> AnalyticsResult<()>;
191}
192
193/// Session-scoped usage counters bumped by domain workflows.
194///
195/// Fire-and-forget at the call sites (task and message creation): failures
196/// are logged, never propagated into the owning workflow. Held as
197/// `Arc<dyn SessionUsageCounters>`, hence `#[async_trait]`.
198#[async_trait]
199pub trait SessionUsageCounters: Send + Sync {
200    async fn increment_task_count(&self, session_id: &SessionId) -> AnalyticsResult<()>;
201
202    async fn increment_message_count(&self, session_id: &SessionId) -> AnalyticsResult<()>;
203}
204
205#[async_trait]
206pub trait FingerprintProvider: Send + Sync {
207    async fn count_active_sessions(&self, fingerprint: &str) -> AnalyticsResult<i64>;
208
209    async fn find_reusable_session(&self, fingerprint: &str) -> AnalyticsResult<Option<SessionId>>;
210
211    async fn upsert_fingerprint(
212        &self,
213        fingerprint: &str,
214        ip_address: Option<&str>,
215        user_agent: Option<&str>,
216        screen_info: Option<&str>,
217    ) -> AnalyticsResult<()>;
218}
219
220pub type DynSessionUsageCounters = Arc<dyn SessionUsageCounters>;