Skip to main content

systemprompt_analytics/services/extractor/
mod.rs

1//! HTTP request → [`SessionAnalytics`] extraction.
2//!
3//! `HeaderValue::to_str().ok()` is used liberally below. That is deliberate:
4//! a non-ASCII header value is not actionable for the analytics pipeline and
5//! must not abort session creation. Treating those headers as absent is the
6//! correct fallback — the corresponding session field stays `None` and
7//! downstream consumers treat the request as un-attributed for that
8//! dimension.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use http::{HeaderMap, Uri};
14use std::collections::HashMap;
15
16use axum::extract::Request;
17
18use super::ai_crawler_keywords::matches_ai_crawler;
19use super::bot_keywords::{matches_bot_ip_range, matches_bot_pattern};
20use super::detection;
21use super::user_agent::parse_user_agent;
22use crate::GeoIpReader;
23
24mod geoip;
25
26#[derive(Debug, Clone, Default)]
27pub struct SessionAnalytics {
28    pub ip_address: Option<String>,
29    pub user_agent: Option<String>,
30    pub device_type: Option<String>,
31    pub browser: Option<String>,
32    pub os: Option<String>,
33    pub fingerprint_hash: Option<String>,
34    pub preferred_locale: Option<String>,
35    pub country: Option<String>,
36    pub region: Option<String>,
37    pub city: Option<String>,
38    pub referrer_source: Option<String>,
39    pub referrer_url: Option<String>,
40    pub landing_page: Option<String>,
41    pub entry_url: Option<String>,
42    pub utm_source: Option<String>,
43    pub utm_medium: Option<String>,
44    pub utm_campaign: Option<String>,
45    pub utm_content: Option<String>,
46    pub utm_term: Option<String>,
47}
48
49impl SessionAnalytics {
50    pub fn from_headers(headers: &HeaderMap) -> Self {
51        Self::from_headers_with_geoip(headers, None)
52    }
53
54    pub fn from_headers_with_geoip(
55        headers: &HeaderMap,
56        geoip_reader: Option<&GeoIpReader>,
57    ) -> Self {
58        Self::from_headers_with_geoip_and_socket(headers, geoip_reader, None)
59    }
60
61    pub fn from_headers_with_geoip_and_socket(
62        headers: &HeaderMap,
63        geoip_reader: Option<&GeoIpReader>,
64        socket_addr: Option<std::net::SocketAddr>,
65    ) -> Self {
66        let user_agent = headers
67            .get("user-agent")
68            .and_then(|v| v.to_str().ok())
69            .map(str::to_owned);
70
71        let ip_address = headers
72            .get("x-forwarded-for")
73            .and_then(|v| v.to_str().ok())
74            .and_then(|s| s.split(',').next())
75            .map(|s| s.trim().to_owned())
76            .or_else(|| {
77                headers
78                    .get("x-real-ip")
79                    .and_then(|v| v.to_str().ok())
80                    .map(str::to_owned)
81            })
82            .or_else(|| socket_addr.map(|addr| addr.ip().to_string()));
83
84        let fingerprint_hash = headers
85            .get("x-fingerprint")
86            .and_then(|v| v.to_str().ok())
87            .map(str::to_owned);
88
89        let preferred_locale = headers
90            .get("accept-language")
91            .and_then(|v| v.to_str().ok())
92            .and_then(|s| s.split(',').next())
93            .map(|s| s.trim().split(';').next().unwrap_or(s).to_owned());
94
95        let (device_type, browser, os) = user_agent
96            .as_ref()
97            .map_or((None, None, None), |ua| parse_user_agent(ua));
98
99        let (country, region, city) = ip_address
100            .as_ref()
101            .and_then(|ip_str| geoip::lookup_geoip(ip_str, geoip_reader))
102            .unwrap_or((None, None, None));
103
104        let referrer_url = headers
105            .get("referer")
106            .and_then(|v| v.to_str().ok())
107            .map(str::to_owned);
108
109        let referrer_source = referrer_url
110            .as_ref()
111            .and_then(|url| Self::parse_referrer_source(url));
112
113        Self {
114            ip_address,
115            user_agent,
116            device_type,
117            browser,
118            os,
119            fingerprint_hash,
120            preferred_locale,
121            country,
122            region,
123            city,
124            referrer_source,
125            referrer_url,
126            landing_page: None,
127            entry_url: None,
128            utm_source: None,
129            utm_medium: None,
130            utm_campaign: None,
131            utm_content: None,
132            utm_term: None,
133        }
134    }
135
136    pub fn from_headers_and_uri(
137        headers: &HeaderMap,
138        uri: Option<&Uri>,
139        geoip_reader: Option<&GeoIpReader>,
140        content_routing: Option<&dyn systemprompt_models::ContentRouting>,
141    ) -> Self {
142        let mut analytics = Self::from_headers_with_geoip(headers, geoip_reader);
143
144        if let Some(uri) = uri {
145            let query_params = Self::parse_query_params(uri);
146
147            analytics.utm_source = query_params.get("utm_source").cloned();
148            analytics.utm_medium = query_params.get("utm_medium").cloned();
149            analytics.utm_campaign = query_params.get("utm_campaign").cloned();
150            analytics.utm_content = query_params.get("utm_content").cloned();
151            analytics.utm_term = query_params.get("utm_term").cloned();
152
153            let is_html_page =
154                content_routing.is_some_and(|routing| routing.is_html_page(uri.path()));
155
156            if is_html_page {
157                analytics.entry_url = Some(uri.to_string());
158                analytics.landing_page = Some(uri.path().to_owned());
159            }
160        }
161
162        analytics
163    }
164
165    pub fn from_request(
166        request: &Request,
167        geoip_reader: Option<&GeoIpReader>,
168        content_routing: Option<&dyn systemprompt_models::ContentRouting>,
169    ) -> Self {
170        Self::from_headers_and_uri(
171            request.headers(),
172            Some(request.uri()),
173            geoip_reader,
174            content_routing,
175        )
176    }
177
178    fn parse_query_params(uri: &Uri) -> HashMap<String, String> {
179        uri.query().map_or_else(HashMap::new, |q| {
180            q.split('&')
181                .filter_map(|param| {
182                    let mut parts = param.splitn(2, '=');
183                    Some((parts.next()?.to_owned(), parts.next()?.to_owned()))
184                })
185                .collect()
186        })
187    }
188
189    fn parse_referrer_source(url: &str) -> Option<String> {
190        geoip::parse_referrer_source(url)
191    }
192
193    pub fn is_bot(&self) -> bool {
194        if self.is_ai_crawler() {
195            return false;
196        }
197        self.user_agent
198            .as_ref()
199            .is_none_or(|ua| ua.is_empty() || matches_bot_pattern(ua))
200    }
201
202    pub fn is_ai_crawler(&self) -> bool {
203        self.user_agent
204            .as_ref()
205            .is_some_and(|ua| matches_ai_crawler(ua))
206    }
207
208    pub fn is_bot_ip(&self) -> bool {
209        self.ip_address
210            .as_ref()
211            .is_some_and(|ip| matches_bot_ip_range(ip))
212    }
213
214    pub fn is_spam_referrer(&self) -> bool {
215        self.referrer_url
216            .as_ref()
217            .is_some_and(|url| detection::is_spam_referrer(url))
218    }
219
220    pub fn is_datacenter_ip(&self) -> bool {
221        self.ip_address
222            .as_ref()
223            .is_some_and(|ip| detection::is_datacenter_ip(ip))
224    }
225
226    pub fn is_high_risk_country(&self) -> bool {
227        self.country
228            .as_ref()
229            .is_some_and(|c| detection::is_high_risk_country(c))
230    }
231
232    pub fn should_skip_tracking(&self) -> bool {
233        if self.is_ai_crawler() {
234            return false;
235        }
236        self.is_bot()
237            || self.is_bot_ip()
238            || self.is_datacenter_ip()
239            || self.is_high_risk_country()
240            || self.is_spam_referrer()
241    }
242}