Skip to main content

systemprompt_analytics/services/
service.rs

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