Skip to main content

logtail_rust/
lib.rs

1use crate::http_client::service;
2use crate::http_client::HttpClient;
3use crate::http_client::ReqwestClient;
4use crate::r#struct::env_config::EnvConfig;
5use crate::r#struct::log_level::LogLevel;
6use std::time::Duration;
7// re-export types to make usable by consumers
8pub use crate::http_client::{LogtailError, RetryConfig};
9pub use crate::r#struct::env_config::EnvEnum;
10pub use crate::r#struct::log_schema::LogSchema;
11pub mod http_client;
12mod r#struct;
13
14pub struct Logger<C: HttpClient = ReqwestClient> {
15    env_config: EnvConfig,
16    client: C,
17    retry_config: RetryConfig,
18}
19
20impl Default for Logger<ReqwestClient> {
21    fn default() -> Self {
22        let env_config = EnvConfig::default();
23        Self {
24            env_config,
25            client: ReqwestClient,
26            retry_config: RetryConfig::default(),
27        }
28    }
29}
30
31impl Logger<ReqwestClient> {
32    pub fn new(app_version: String, verbose: bool) -> Self {
33        let env_config = EnvConfig::new(app_version, verbose);
34        Self {
35            env_config,
36            client: ReqwestClient,
37            retry_config: RetryConfig::default(),
38        }
39    }
40
41    pub fn with_retry(retry_config: RetryConfig) -> Self {
42        let env_config = EnvConfig::default();
43        Self {
44            env_config,
45            client: ReqwestClient,
46            retry_config,
47        }
48    }
49
50    pub fn builder() -> LoggerBuilder {
51        LoggerBuilder::default()
52    }
53}
54
55impl<C: HttpClient> Logger<C> {
56    #[cfg(test)]
57    pub(crate) fn env_config(&self) -> &EnvConfig {
58        &self.env_config
59    }
60
61    #[cfg(test)]
62    pub(crate) fn retry_config(&self) -> &RetryConfig {
63        &self.retry_config
64    }
65
66    #[cfg(test)]
67    pub(crate) fn with_client(env_config: EnvConfig, client: C) -> Self {
68        Self {
69            env_config,
70            client,
71            retry_config: RetryConfig::default(),
72        }
73    }
74
75    #[cfg(test)]
76    pub(crate) fn with_client_and_retry(
77        env_config: EnvConfig,
78        client: C,
79        retry_config: RetryConfig,
80    ) -> Self {
81        Self {
82            env_config,
83            client,
84            retry_config,
85        }
86    }
87
88    pub async fn info(&self, log: LogSchema) -> Result<(), LogtailError> {
89        let env_config = &self.env_config;
90        let better_log = log.to_betterstack(env_config, LogLevel::Info);
91        if better_log.env != EnvEnum::Local {
92            service::push_log_with_retry(&self.client, env_config, &better_log, &self.retry_config)
93                .await?;
94        }
95        if env_config.verbose {
96            println!("{}", better_log);
97        }
98        Ok(())
99    }
100
101    pub async fn warn(&self, log: LogSchema) -> Result<(), LogtailError> {
102        let env_config = &self.env_config;
103        let better_log = log.to_betterstack(&self.env_config, LogLevel::Warn);
104        if better_log.env != EnvEnum::Local {
105            service::push_log_with_retry(&self.client, env_config, &better_log, &self.retry_config)
106                .await?;
107        }
108        if self.env_config.verbose {
109            println!("{}", better_log);
110        }
111        Ok(())
112    }
113
114    pub async fn error(&self, log: LogSchema) -> Result<(), LogtailError> {
115        let env_config = &self.env_config;
116        let better_log = log.to_betterstack(&self.env_config, LogLevel::Error);
117        if better_log.env != EnvEnum::Local {
118            service::push_log_with_retry(&self.client, env_config, &better_log, &self.retry_config)
119                .await?;
120        }
121        if self.env_config.verbose {
122            eprintln!("{}", better_log);
123        }
124        Ok(())
125    }
126
127    pub async fn debug(&self, log: LogSchema) -> Result<(), LogtailError> {
128        let better_log = log.to_betterstack(&self.env_config, LogLevel::Debug);
129        println!("{}", better_log);
130        Ok(())
131    }
132}
133
134#[derive(Default)]
135pub struct LoggerBuilder {
136    app_version: Option<String>,
137    verbose: Option<bool>,
138    environment: Option<EnvEnum>,
139    logs_source_token: Option<String>,
140    retry_config: RetryConfig,
141}
142
143impl LoggerBuilder {
144    pub fn app_version(mut self, app_version: impl Into<String>) -> Self {
145        self.app_version = Some(app_version.into());
146        self
147    }
148
149    pub fn verbose(mut self, verbose: bool) -> Self {
150        self.verbose = Some(verbose);
151        self
152    }
153
154    pub fn environment(mut self, environment: EnvEnum) -> Self {
155        self.environment = Some(environment);
156        self
157    }
158
159    pub fn logs_source_token(mut self, token: impl Into<String>) -> Self {
160        self.logs_source_token = Some(token.into());
161        self
162    }
163
164    pub fn max_retries(mut self, max_retries: u32) -> Self {
165        self.retry_config.max_retries = max_retries;
166        self
167    }
168
169    pub fn base_delay(mut self, base_delay: Duration) -> Self {
170        self.retry_config.base_delay = base_delay;
171        self
172    }
173
174    pub fn max_delay(mut self, max_delay: Duration) -> Self {
175        self.retry_config.max_delay = max_delay;
176        self
177    }
178
179    pub fn jitter(mut self, jitter: bool) -> Self {
180        self.retry_config.jitter = jitter;
181        self
182    }
183
184    pub fn build(self) -> Logger<ReqwestClient> {
185        let env_config = match (self.environment, self.logs_source_token) {
186            (Some(environment), Some(token)) => EnvConfig::from_values(
187                self.app_version
188                    .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()),
189                environment,
190                token,
191                self.verbose.unwrap_or(true),
192            ),
193            _ => {
194                let version = self
195                    .app_version
196                    .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
197                let verbose = self.verbose.unwrap_or(true);
198                EnvConfig::new(version, verbose)
199            }
200        };
201
202        Logger {
203            env_config,
204            client: ReqwestClient,
205            retry_config: self.retry_config,
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::http_client::mock::MockHttpClient;
214    use crate::r#struct::env_config::EnvConfig;
215    use std::sync::atomic::Ordering;
216
217    fn qa_config() -> EnvConfig {
218        EnvConfig::from_values("1.0.0".to_string(), EnvEnum::QA, "token".to_string(), false)
219    }
220
221    fn local_config() -> EnvConfig {
222        EnvConfig::from_values(
223            "1.0.0".to_string(),
224            EnvEnum::Local,
225            "token".to_string(),
226            false,
227        )
228    }
229
230    fn test_log() -> LogSchema {
231        LogSchema {
232            message: "test".to_string(),
233            context: "ctx".to_string(),
234        }
235    }
236
237    fn no_retry_config() -> RetryConfig {
238        RetryConfig {
239            max_retries: 0,
240            base_delay: Duration::from_millis(1),
241            max_delay: Duration::from_millis(1),
242            jitter: false,
243        }
244    }
245
246    #[tokio::test]
247    async fn info_sends_info_level() {
248        let mock = MockHttpClient::with_success(None);
249        let logger = Logger::with_client(qa_config(), mock);
250
251        let _ = logger.info(test_log()).await;
252
253        let body = logger.client.captured_body.lock().unwrap().clone().unwrap();
254        assert_eq!(body["level"], "Info");
255    }
256
257    #[tokio::test]
258    async fn warn_sends_warn_level() {
259        let mock = MockHttpClient::with_success(None);
260        let logger = Logger::with_client(qa_config(), mock);
261
262        let _ = logger.warn(test_log()).await;
263
264        let body = logger.client.captured_body.lock().unwrap().clone().unwrap();
265        assert_eq!(body["level"], "Warn");
266    }
267
268    #[tokio::test]
269    async fn error_sends_error_level() {
270        let mock = MockHttpClient::with_success(None);
271        let logger = Logger::with_client(qa_config(), mock);
272
273        let _ = logger.error(test_log()).await;
274
275        let body = logger.client.captured_body.lock().unwrap().clone().unwrap();
276        assert_eq!(body["level"], "Error");
277    }
278
279    #[tokio::test]
280    async fn debug_skips_http() {
281        let mock = MockHttpClient::with_success(None);
282        let logger = Logger::with_client(qa_config(), mock);
283
284        let _ = logger.debug(test_log()).await;
285
286        assert_eq!(logger.client.call_count.load(Ordering::SeqCst), 0);
287    }
288
289    #[tokio::test]
290    async fn local_env_skips_http() {
291        let mock = MockHttpClient::with_success(None);
292        let logger = Logger::with_client(local_config(), mock);
293
294        let _ = logger.info(test_log()).await;
295        let _ = logger.warn(test_log()).await;
296        let _ = logger.error(test_log()).await;
297
298        assert_eq!(logger.client.call_count.load(Ordering::SeqCst), 0);
299    }
300
301    #[tokio::test]
302    async fn non_local_env_sends_http() {
303        let mock = MockHttpClient::with_success(None);
304        let logger = Logger::with_client(qa_config(), mock);
305
306        let _ = logger.info(test_log()).await;
307
308        assert_eq!(logger.client.call_count.load(Ordering::SeqCst), 1);
309    }
310
311    #[tokio::test]
312    async fn info_returns_ok_on_success() {
313        let mock = MockHttpClient::with_success(None);
314        let logger = Logger::with_client_and_retry(qa_config(), mock, no_retry_config());
315
316        let result = logger.info(test_log()).await;
317        assert!(result.is_ok());
318    }
319
320    #[tokio::test]
321    async fn info_returns_err_on_failure() {
322        let mock = MockHttpClient::with_error("server error");
323        let logger = Logger::with_client_and_retry(qa_config(), mock, no_retry_config());
324
325        let result = logger.info(test_log()).await;
326        assert!(result.is_err());
327    }
328
329    #[tokio::test]
330    async fn debug_returns_ok() {
331        let mock = MockHttpClient::with_success(None);
332        let logger = Logger::with_client(qa_config(), mock);
333
334        let result = logger.debug(test_log()).await;
335        assert!(result.is_ok());
336    }
337
338    #[tokio::test]
339    async fn local_env_returns_ok() {
340        let mock = MockHttpClient::with_error("server error");
341        let logger = Logger::with_client(local_config(), mock);
342
343        let result = logger.info(test_log()).await;
344        assert!(result.is_ok());
345    }
346
347    // --- LoggerBuilder tests ---
348
349    #[test]
350    fn builder_with_explicit_env_and_token() {
351        let logger = Logger::builder()
352            .app_version("2.0.0")
353            .verbose(false)
354            .environment(EnvEnum::Prod)
355            .logs_source_token("my-token")
356            .build();
357
358        assert_eq!(logger.env_config().app_version, "2.0.0");
359        assert!(!logger.env_config().verbose);
360        assert_eq!(logger.env_config().environment, EnvEnum::Prod);
361        assert_eq!(logger.env_config().logs_source_token, "my-token");
362    }
363
364    #[test]
365    fn builder_retry_options() {
366        let logger = Logger::builder()
367            .environment(EnvEnum::QA)
368            .logs_source_token("tok")
369            .max_retries(5)
370            .base_delay(Duration::from_millis(200))
371            .max_delay(Duration::from_millis(800))
372            .jitter(false)
373            .build();
374
375        assert_eq!(logger.retry_config().max_retries, 5);
376        assert_eq!(logger.retry_config().base_delay, Duration::from_millis(200));
377        assert_eq!(logger.retry_config().max_delay, Duration::from_millis(800));
378        assert!(!logger.retry_config().jitter);
379    }
380
381    #[test]
382    fn builder_defaults_without_overrides() {
383        let logger = Logger::builder()
384            .environment(EnvEnum::QA)
385            .logs_source_token("tok")
386            .build();
387
388        assert_eq!(logger.env_config().app_version, env!("CARGO_PKG_VERSION"));
389        assert!(logger.env_config().verbose);
390        assert_eq!(logger.retry_config().max_retries, 3);
391        assert_eq!(logger.retry_config().base_delay, Duration::from_secs(1));
392        assert_eq!(logger.retry_config().max_delay, Duration::from_secs(5));
393        assert!(logger.retry_config().jitter);
394    }
395
396    // --- Logger::with_retry tests ---
397
398    #[tokio::test]
399    async fn with_retry_uses_custom_retry_config() {
400        let custom = RetryConfig {
401            max_retries: 1,
402            base_delay: Duration::from_millis(1),
403            max_delay: Duration::from_millis(1),
404            jitter: false,
405        };
406        let mock = MockHttpClient::with_sequence(vec![
407            Err((500, "fail".to_string())),
408            Ok(Some(serde_json::json!({"ok": true}))),
409        ]);
410        let logger = Logger::with_client_and_retry(qa_config(), mock, custom);
411
412        let result = logger.info(test_log()).await;
413        assert!(result.is_ok());
414        // 1 initial + 1 retry = 2 calls
415        assert_eq!(logger.client.call_count.load(Ordering::SeqCst), 2);
416    }
417}