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//! The client IP is never parsed from hop headers here: it arrives already
11//! resolved (via the HTTP boundary's trusted-proxy walk) as `caller_ip`.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use http::{HeaderMap, Uri};
17use std::collections::HashMap;
18use std::net::IpAddr;
19
20use super::ai_crawler_keywords::matches_ai_crawler;
21use super::bot_keywords::{matches_bot_ip_range, matches_bot_pattern};
22use super::detection;
23use super::user_agent::parse_user_agent;
24use crate::GeoIpReader;
25
26mod geoip;
27
28pub use systemprompt_traits::SessionAnalytics;
29
30fn parse_query_params(uri: &Uri) -> HashMap<String, String> {
31    uri.query().map_or_else(HashMap::new, |q| {
32        q.split('&')
33            .filter_map(|param| {
34                let mut parts = param.splitn(2, '=');
35                Some((parts.next()?.to_owned(), parts.next()?.to_owned()))
36            })
37            .collect()
38    })
39}
40
41fn parse_referrer_source(url: &str) -> Option<String> {
42    match url::Url::parse(url) {
43        Ok(parsed_url) => parsed_url
44            .host_str()
45            .map(str::to_owned)
46            .filter(|host| host.parse::<IpAddr>().is_err()),
47        Err(err) => {
48            tracing::debug!(url = %url, error = %err, "failed to parse referrer URL");
49            None
50        },
51    }
52}
53
54/// `headers` is the only required input; every enrichment source (URI for
55/// UTM/landing-page, `GeoIP` reader, content-routing classifier, resolved
56/// caller IP) is opt-in via a `with_*` setter.
57pub struct SessionAnalyticsBuilder<'a> {
58    headers: &'a HeaderMap,
59    uri: Option<&'a Uri>,
60    geoip_reader: Option<&'a GeoIpReader>,
61    content_routing: Option<&'a dyn systemprompt_models::ContentRouting>,
62    caller_ip: Option<IpAddr>,
63}
64
65impl std::fmt::Debug for SessionAnalyticsBuilder<'_> {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("SessionAnalyticsBuilder")
68            .field("headers", &self.headers)
69            .field("uri", &self.uri)
70            .field("geoip_reader", &self.geoip_reader.map(|_| "<reader>"))
71            .field(
72                "content_routing",
73                &self.content_routing.map(|_| "<content_routing>"),
74            )
75            .field("caller_ip", &self.caller_ip)
76            .finish()
77    }
78}
79
80impl<'a> SessionAnalyticsBuilder<'a> {
81    #[must_use]
82    pub const fn new(headers: &'a HeaderMap) -> Self {
83        Self {
84            headers,
85            uri: None,
86            geoip_reader: None,
87            content_routing: None,
88            caller_ip: None,
89        }
90    }
91
92    #[must_use]
93    pub const fn with_uri(mut self, uri: &'a Uri) -> Self {
94        self.uri = Some(uri);
95        self
96    }
97
98    #[must_use]
99    pub const fn with_geoip(mut self, reader: &'a GeoIpReader) -> Self {
100        self.geoip_reader = Some(reader);
101        self
102    }
103
104    #[must_use]
105    pub fn with_content_routing(
106        mut self,
107        content_routing: &'a dyn systemprompt_models::ContentRouting,
108    ) -> Self {
109        self.content_routing = Some(content_routing);
110        self
111    }
112
113    #[must_use]
114    pub const fn with_caller_ip(mut self, caller_ip: IpAddr) -> Self {
115        self.caller_ip = Some(caller_ip);
116        self
117    }
118
119    #[must_use]
120    pub fn build(self) -> SessionAnalytics {
121        let headers = self.headers;
122
123        let user_agent = headers
124            .get("user-agent")
125            .and_then(|v| v.to_str().ok())
126            .map(str::to_owned);
127
128        let ip_address = self.caller_ip.map(|ip| ip.to_string());
129
130        let fingerprint_hash = headers
131            .get("x-fingerprint")
132            .and_then(|v| v.to_str().ok())
133            .map(str::to_owned);
134
135        let preferred_locale = headers
136            .get("accept-language")
137            .and_then(|v| v.to_str().ok())
138            .and_then(|s| s.split(',').next())
139            .map(|s| s.trim().split(';').next().unwrap_or(s).to_owned());
140
141        let (device_type, browser, os) = user_agent
142            .as_ref()
143            .map_or((None, None, None), |ua| parse_user_agent(ua));
144
145        let (country, region, city) = ip_address
146            .as_ref()
147            .and_then(|ip_str| geoip::lookup_geoip(ip_str, self.geoip_reader))
148            .unwrap_or((None, None, None));
149
150        let referrer_url = headers
151            .get("referer")
152            .and_then(|v| v.to_str().ok())
153            .map(str::to_owned);
154
155        let referrer_source = referrer_url.as_deref().and_then(parse_referrer_source);
156
157        let mut analytics = SessionAnalytics {
158            ip_address,
159            user_agent,
160            device_type,
161            browser,
162            os,
163            fingerprint_hash,
164            preferred_locale,
165            country,
166            region,
167            city,
168            referrer_source,
169            referrer_url,
170            landing_page: None,
171            entry_url: None,
172            utm_source: None,
173            utm_medium: None,
174            utm_campaign: None,
175            utm_content: None,
176            utm_term: None,
177            is_bot: false,
178            is_ai_crawler: false,
179            skip_tracking: false,
180        };
181
182        if let Some(uri) = self.uri {
183            Self::apply_uri(&mut analytics, uri, self.content_routing);
184        }
185
186        Self::classify(&mut analytics);
187
188        analytics
189    }
190
191    /// Decides the bot/crawler verdicts once, at the only place that owns the
192    /// keyword tables. Must run after `apply_uri` — `skip_tracking` consults
193    /// the referrer, and the country comes from the `GeoIP` lookup above.
194    fn classify(analytics: &mut SessionAnalytics) {
195        analytics.is_ai_crawler = analytics
196            .user_agent
197            .as_ref()
198            .is_some_and(|ua| matches_ai_crawler(ua));
199
200        analytics.is_bot = !analytics.is_ai_crawler
201            && analytics
202                .user_agent
203                .as_ref()
204                .is_none_or(|ua| ua.is_empty() || matches_bot_pattern(ua));
205
206        let flagged_origin = analytics
207            .ip_address
208            .as_ref()
209            .is_some_and(|ip| matches_bot_ip_range(ip) || detection::is_datacenter_ip(ip))
210            || analytics
211                .country
212                .as_ref()
213                .is_some_and(|c| detection::is_high_risk_country(c))
214            || analytics
215                .referrer_url
216                .as_ref()
217                .is_some_and(|url| detection::is_spam_referrer(url));
218
219        analytics.skip_tracking = !analytics.is_ai_crawler && (analytics.is_bot || flagged_origin);
220    }
221
222    fn apply_uri(
223        analytics: &mut SessionAnalytics,
224        uri: &Uri,
225        content_routing: Option<&dyn systemprompt_models::ContentRouting>,
226    ) {
227        let query_params = parse_query_params(uri);
228
229        analytics.utm_source = query_params.get("utm_source").cloned();
230        analytics.utm_medium = query_params.get("utm_medium").cloned();
231        analytics.utm_campaign = query_params.get("utm_campaign").cloned();
232        analytics.utm_content = query_params.get("utm_content").cloned();
233        analytics.utm_term = query_params.get("utm_term").cloned();
234
235        if content_routing.is_some_and(|routing| routing.is_html_page(uri.path())) {
236            analytics.entry_url = Some(uri.to_string());
237            analytics.landing_page = Some(uri.path().to_owned());
238        }
239    }
240}