Skip to main content

actix_analytics/
analytics.rs

1use actix_web::{
2    rt::spawn,
3    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
4    http::header::{HeaderValue, HOST, USER_AGENT},
5    Error,
6};
7use chrono::Utc;
8use futures::future::LocalBoxFuture;
9use reqwest::Client;
10use serde::Serialize;
11use std::sync::{Arc, Mutex};
12use std::{
13    future::{ready, Ready},
14    time::{Duration, Instant},
15};
16
17#[derive(Debug, Clone, Serialize)]
18struct RequestData {
19    hostname: String,
20    ip_address: Option<String>,
21    path: String,
22    user_agent: String,
23    method: String,
24    response_time: u32,
25    status: u16,
26    user_id: Option<String>,
27    created_at: String,
28}
29
30type StringMapper = dyn for<'a> Fn(&ServiceRequest) -> String + Send + Sync;
31
32#[derive(Clone)]
33struct Config {
34    privacy_level: i32,
35    server_url: String,
36    get_hostname: Arc<StringMapper>,
37    get_ip_address: Arc<StringMapper>,
38    get_path: Arc<StringMapper>,
39    get_user_agent: Arc<StringMapper>,
40    get_user_id: Arc<StringMapper>,
41}
42
43impl Default for Config {
44    fn default() -> Self {
45        Self {
46            privacy_level: 0,
47            server_url: String::from("https://www.apianalytics-server.com/"),
48            get_hostname: Arc::new(get_hostname),
49            get_ip_address: Arc::new(get_ip_address),
50            get_path: Arc::new(get_path),
51            get_user_agent: Arc::new(get_user_agent),
52            get_user_id: Arc::new(get_user_id),
53        }
54    }
55}
56
57pub trait HeaderValueExt {
58    fn to_string(&self) -> String;
59}
60
61impl HeaderValueExt for HeaderValue {
62    fn to_string(&self) -> String {
63        self.to_str().unwrap_or_default().to_string()
64    }
65}
66
67fn get_hostname(req: &ServiceRequest) -> String {
68    req.headers()
69        .get(HOST)
70        .map(|x| x.to_string())
71        .unwrap_or_default()
72}
73
74fn get_ip_address(req: &ServiceRequest) -> String {
75    let headers = req.headers();
76
77    if let Some(val) = headers
78        .get("cf-connecting-ip")
79        .and_then(|v| v.to_str().ok())
80        .map(|s| s.trim().to_owned())
81        .filter(|s| !s.is_empty())
82    {
83        return val;
84    }
85
86    if let Some(val) = headers
87        .get("x-forwarded-for")
88        .and_then(|v| v.to_str().ok())
89        .and_then(|s| s.split(',').next())
90        .map(|s| s.trim().to_owned())
91        .filter(|s| !s.is_empty())
92    {
93        return val;
94    }
95
96    if let Some(val) = headers
97        .get("x-real-ip")
98        .and_then(|v| v.to_str().ok())
99        .map(|s| s.trim().to_owned())
100        .filter(|s| !s.is_empty())
101    {
102        return val;
103    }
104
105    req.peer_addr()
106        .map(|addr| addr.ip().to_string())
107        .unwrap_or_default()
108}
109
110fn get_path(req: &ServiceRequest) -> String {
111    req.path().to_string()
112}
113
114fn get_user_agent(req: &ServiceRequest) -> String {
115    req.headers()
116        .get(USER_AGENT)
117        .map(|x| x.to_string())
118        .unwrap_or_default()
119}
120
121fn get_user_id(_req: &ServiceRequest) -> String {
122    String::new()
123}
124
125struct RequestBuffer {
126    requests: Vec<RequestData>,
127    last_posted: Instant,
128}
129
130impl RequestBuffer {
131    fn new() -> Self {
132        Self {
133            requests: Vec::new(),
134            last_posted: Instant::now(),
135        }
136    }
137}
138
139pub struct Analytics {
140    api_key: String,
141    config: Config,
142}
143
144impl Analytics {
145    pub fn new(api_key: String) -> Self {
146        Self {
147            api_key,
148            config: Config::default(),
149        }
150    }
151
152    pub fn with_privacy_level(mut self, privacy_level: i32) -> Self {
153        self.config.privacy_level = privacy_level;
154        self
155    }
156
157    pub fn with_server_url(mut self, server_url: String) -> Self {
158        self.config.server_url = if server_url.ends_with('/') {
159            server_url
160        } else {
161            server_url + "/"
162        };
163        self
164    }
165
166    pub fn with_hostname_mapper<F>(mut self, mapper: F) -> Self
167    where
168        F: Fn(&ServiceRequest) -> String + Send + Sync + 'static,
169    {
170        self.config.get_hostname = Arc::new(mapper);
171        self
172    }
173
174    pub fn with_ip_address_mapper<F>(mut self, mapper: F) -> Self
175    where
176        F: Fn(&ServiceRequest) -> String + Send + Sync + 'static,
177    {
178        self.config.get_ip_address = Arc::new(mapper);
179        self
180    }
181
182    pub fn with_path_mapper<F>(mut self, mapper: F) -> Self
183    where
184        F: Fn(&ServiceRequest) -> String + Send + Sync + 'static,
185    {
186        self.config.get_path = Arc::new(mapper);
187        self
188    }
189
190    pub fn with_user_agent_mapper<F>(mut self, mapper: F) -> Self
191    where
192        F: Fn(&ServiceRequest) -> String + Send + Sync + 'static,
193    {
194        self.config.get_user_agent = Arc::new(mapper);
195        self
196    }
197
198    pub fn with_user_id_mapper<F>(mut self, mapper: F) -> Self
199    where
200        F: Fn(&ServiceRequest) -> String + Send + Sync + 'static,
201    {
202        self.config.get_user_id = Arc::new(mapper);
203        self
204    }
205}
206
207impl<S, B> Transform<S, ServiceRequest> for Analytics
208where
209    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
210    S::Future: 'static,
211    B: 'static,
212{
213    type Response = ServiceResponse<B>;
214    type Error = Error;
215    type InitError = ();
216    type Transform = AnalyticsMiddleware<S>;
217    type Future = Ready<Result<Self::Transform, Self::InitError>>;
218
219    fn new_transform(&self, service: S) -> Self::Future {
220        let client = Client::builder()
221            .timeout(Duration::from_secs(10))
222            .build()
223            .unwrap_or_else(|_| Client::new());
224
225        ready(Ok(AnalyticsMiddleware {
226            api_key: Arc::new(self.api_key.clone()),
227            config: Arc::new(self.config.clone()),
228            buffer: Arc::new(Mutex::new(RequestBuffer::new())),
229            client: Arc::new(client),
230            service,
231        }))
232    }
233}
234
235pub struct AnalyticsMiddleware<S> {
236    api_key: Arc<String>,
237    config: Arc<Config>,
238    buffer: Arc<Mutex<RequestBuffer>>,
239    client: Arc<Client>,
240    service: S,
241}
242
243#[derive(Debug, Clone, Serialize)]
244struct Payload {
245    api_key: String,
246    requests: Vec<RequestData>,
247    framework: String,
248    privacy_level: i32,
249}
250
251impl Payload {
252    pub fn new(api_key: String, requests: Vec<RequestData>, privacy_level: i32) -> Self {
253        Self {
254            api_key,
255            requests,
256            framework: String::from("Actix"),
257            privacy_level,
258        }
259    }
260}
261
262async fn post_requests(client: &Client, data: Payload, server_url: &str) {
263    let url = format!("{}api/log-request", server_url);
264    let _ = client.post(url).json(&data).send().await;
265}
266
267fn log_request(
268    buffer: &Arc<Mutex<RequestBuffer>>,
269    client: &Arc<Client>,
270    api_key: &str,
271    request_data: RequestData,
272    config: &Config,
273) {
274    let batch = {
275        let mut buf = buffer.lock().unwrap();
276        buf.requests.push(request_data);
277        if buf.last_posted.elapsed().as_secs_f64() > 60.0 {
278            buf.last_posted = Instant::now();
279            std::mem::take(&mut buf.requests)
280        } else {
281            vec![]
282        }
283    };
284
285    if !batch.is_empty() {
286        let payload = Payload::new(api_key.to_owned(), batch, config.privacy_level);
287        let server_url = config.server_url.clone();
288        let client = Arc::clone(client);
289        spawn(async move {
290            post_requests(&client, payload, &server_url).await;
291        });
292    }
293}
294
295impl<S, B> Service<ServiceRequest> for AnalyticsMiddleware<S>
296where
297    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
298    S::Future: 'static,
299    B: 'static,
300{
301    type Response = ServiceResponse<B>;
302    type Error = Error;
303    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
304
305    forward_ready!(service);
306
307    fn call(&self, req: ServiceRequest) -> Self::Future {
308        let start = Instant::now();
309
310        let api_key = Arc::clone(&self.api_key);
311        let config = Arc::clone(&self.config);
312        let buffer = Arc::clone(&self.buffer);
313        let client = Arc::clone(&self.client);
314        let hostname = (self.config.get_hostname)(&req);
315        let ip_address = if self.config.privacy_level >= 2 {
316            None
317        } else {
318            let val = (self.config.get_ip_address)(&req);
319            if val.is_empty() { None } else { Some(val) }
320        };
321        let path = (self.config.get_path)(&req);
322        let method = req.method().to_string();
323        let user_agent = (self.config.get_user_agent)(&req);
324        let user_id = {
325            let val = (self.config.get_user_id)(&req);
326            if val.is_empty() { None } else { Some(val) }
327        };
328
329        let future = self.service.call(req);
330
331        Box::pin(async move {
332            let res = future.await?;
333            let response_time = start.elapsed().as_millis().min(u32::MAX as u128) as u32;
334
335            let request_data = RequestData {
336                hostname,
337                ip_address,
338                path,
339                user_agent,
340                method,
341                response_time,
342                status: res.status().as_u16(),
343                user_id,
344                created_at: Utc::now().to_rfc3339(),
345            };
346
347            log_request(&buffer, &client, &api_key, request_data, &config);
348
349            Ok(res)
350        })
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use actix_web::test::TestRequest;
358
359    #[test]
360    fn test_get_ip_cf_connecting_ip() {
361        let req = TestRequest::get()
362            .insert_header(("cf-connecting-ip", "203.0.113.1"))
363            .insert_header(("x-forwarded-for", "10.0.0.1"))
364            .to_srv_request();
365        assert_eq!(get_ip_address(&req), "203.0.113.1");
366    }
367
368    #[test]
369    fn test_get_ip_x_forwarded_for_first() {
370        let req = TestRequest::get()
371            .insert_header(("x-forwarded-for", "203.0.113.1, 10.0.0.1"))
372            .to_srv_request();
373        assert_eq!(get_ip_address(&req), "203.0.113.1");
374    }
375
376    #[test]
377    fn test_get_ip_x_real_ip() {
378        let req = TestRequest::get()
379            .insert_header(("x-real-ip", "203.0.113.1"))
380            .to_srv_request();
381        assert_eq!(get_ip_address(&req), "203.0.113.1");
382    }
383
384    #[test]
385    fn test_get_ip_empty_when_no_headers() {
386        let req = TestRequest::get().to_srv_request();
387        // No proxy headers and no peer addr in test context
388        let ip = get_ip_address(&req);
389        // Either empty or a loopback — just assert it doesn't panic
390        let _ = ip;
391    }
392
393    #[test]
394    fn test_get_hostname() {
395        let req = TestRequest::get()
396            .insert_header(("host", "example.com"))
397            .to_srv_request();
398        assert_eq!(get_hostname(&req), "example.com");
399    }
400
401    #[test]
402    fn test_get_path() {
403        let req = TestRequest::get().uri("/api/users").to_srv_request();
404        assert_eq!(get_path(&req), "/api/users");
405    }
406
407    #[test]
408    fn test_get_user_agent() {
409        let req = TestRequest::get()
410            .insert_header(("user-agent", "TestAgent/1.0"))
411            .to_srv_request();
412        assert_eq!(get_user_agent(&req), "TestAgent/1.0");
413    }
414
415    #[test]
416    fn test_get_user_id_default_empty() {
417        let req = TestRequest::get().to_srv_request();
418        assert_eq!(get_user_id(&req), "");
419    }
420}