systemprompt_analytics/services/extractor/
mod.rs1use 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
54pub 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 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}