Skip to main content

systemprompt_analytics/services/
service.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4use chrono::{DateTime, Utc};
5use http::{HeaderMap, Uri};
6
7use axum::extract::Request;
8use systemprompt_database::DbPool;
9use systemprompt_identifiers::{SessionId, SessionSource, UserId};
10use systemprompt_models::ContentRouting;
11
12use crate::GeoIpReader;
13use crate::repository::{CreateSessionParams, SessionRecord, SessionRepository};
14use crate::services::SessionAnalytics;
15
16#[derive(Debug)]
17pub struct CreateAnalyticsSessionInput<'a> {
18    pub session_id: &'a SessionId,
19    pub user_id: Option<&'a UserId>,
20    pub analytics: &'a SessionAnalytics,
21    pub session_source: SessionSource,
22    pub is_bot: bool,
23    pub is_ai_crawler: bool,
24    pub expires_at: DateTime<Utc>,
25}
26
27#[derive(Clone)]
28pub struct AnalyticsService {
29    geoip_reader: Option<GeoIpReader>,
30    content_routing: Option<Arc<dyn ContentRouting>>,
31    session_repo: SessionRepository,
32}
33
34impl std::fmt::Debug for AnalyticsService {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("AnalyticsService")
37            .field("geoip_reader", &self.geoip_reader.is_some())
38            .field("content_routing", &self.content_routing.is_some())
39            .field("session_repo", &"SessionRepository")
40            .finish()
41    }
42}
43
44impl AnalyticsService {
45    pub fn new(
46        db_pool: &DbPool,
47        geoip_reader: Option<GeoIpReader>,
48        content_routing: Option<Arc<dyn ContentRouting>>,
49    ) -> Result<Self> {
50        Ok(Self {
51            geoip_reader,
52            content_routing,
53            session_repo: SessionRepository::new(db_pool)?,
54        })
55    }
56
57    pub fn extract_analytics(&self, headers: &HeaderMap, uri: Option<&Uri>) -> SessionAnalytics {
58        SessionAnalytics::from_headers_and_uri(
59            headers,
60            uri,
61            self.geoip_reader.as_ref(),
62            self.content_routing.as_deref(),
63        )
64    }
65
66    pub fn extract_from_request(&self, request: &Request) -> SessionAnalytics {
67        SessionAnalytics::from_headers_and_uri(
68            request.headers(),
69            Some(request.uri()),
70            self.geoip_reader.as_ref(),
71            self.content_routing.as_deref(),
72        )
73    }
74
75    pub fn is_bot(analytics: &SessionAnalytics) -> bool {
76        analytics.should_skip_tracking()
77    }
78
79    pub fn compute_fingerprint(analytics: &SessionAnalytics) -> String {
80        analytics.fingerprint_hash.clone().unwrap_or_else(|| {
81            use xxhash_rust::xxh64::xxh64;
82
83            let data = format!(
84                "{}|{}",
85                analytics.user_agent.as_deref().unwrap_or(""),
86                analytics.preferred_locale.as_deref().unwrap_or("")
87            );
88
89            format!("fp_{:016x}", xxh64(data.as_bytes(), 0))
90        })
91    }
92
93    pub async fn create_analytics_session(
94        &self,
95        input: CreateAnalyticsSessionInput<'_>,
96    ) -> Result<()> {
97        let fingerprint = Self::compute_fingerprint(input.analytics);
98
99        let params = CreateSessionParams {
100            session_id: input.session_id,
101            user_id: input.user_id,
102            session_source: input.session_source,
103            fingerprint_hash: Some(&fingerprint),
104            ip_address: input.analytics.ip_address.as_deref(),
105            user_agent: input.analytics.user_agent.as_deref(),
106            device_type: input.analytics.device_type.as_deref(),
107            browser: input.analytics.browser.as_deref(),
108            os: input.analytics.os.as_deref(),
109            country: input.analytics.country.as_deref(),
110            region: input.analytics.region.as_deref(),
111            city: input.analytics.city.as_deref(),
112            preferred_locale: input.analytics.preferred_locale.as_deref(),
113            referrer_source: input.analytics.referrer_source.as_deref(),
114            referrer_url: input.analytics.referrer_url.as_deref(),
115            landing_page: input.analytics.landing_page.as_deref(),
116            entry_url: input.analytics.entry_url.as_deref(),
117            utm_source: input.analytics.utm_source.as_deref(),
118            utm_medium: input.analytics.utm_medium.as_deref(),
119            utm_content: input.analytics.utm_content.as_deref(),
120            utm_term: input.analytics.utm_term.as_deref(),
121            utm_campaign: input.analytics.utm_campaign.as_deref(),
122            is_bot: input.is_bot,
123            is_ai_crawler: input.is_ai_crawler,
124            expires_at: input.expires_at,
125        };
126
127        self.session_repo.create_session(&params).await?;
128
129        Ok(())
130    }
131
132    pub async fn find_recent_session_by_fingerprint(
133        &self,
134        fingerprint: &str,
135        max_age_seconds: i64,
136    ) -> Result<Option<SessionRecord>> {
137        self.session_repo
138            .find_recent_by_fingerprint(fingerprint, max_age_seconds)
139            .await
140    }
141
142    pub const fn session_repo(&self) -> &SessionRepository {
143        &self.session_repo
144    }
145}