betterstack_tracing/
config.rs

1/// Configuration for the Betterstack layer
2use crate::error::{BetterstackError, Result};
3use std::sync::Arc;
4use std::time::Duration;
5
6/// Default Betterstack logging endpoint
7pub const DEFAULT_ENDPOINT: &str = "https://in.logs.betterstack.com/";
8
9/// Default HTTP request timeout
10pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
11
12/// Default batch size (number of logs)
13pub const DEFAULT_BATCH_SIZE: usize = 10;
14
15/// Default batch delay (time-based flush)
16pub const DEFAULT_BATCH_DELAY: Duration = Duration::from_secs(2);
17
18/// Default channel capacity (max queued logs)
19pub const DEFAULT_CHANNEL_CAPACITY: usize = 1000;
20
21/// Maximum size for a single log record (Betterstack API limit)
22pub const MAX_LOG_RECORD_SIZE: usize = 1_048_576; // 1 MiB
23
24/// Maximum size for a batch request uncompressed (Betterstack API limit)
25pub const MAX_BATCH_SIZE_UNCOMPRESSED: usize = 10_485_760; // 10 MiB
26
27/// Recommended maximum size for a single log record
28pub const RECOMMENDED_LOG_SIZE: usize = 102_400; // 100 KiB
29
30/// Configuration for BetterstackLayer
31#[derive(Clone)]
32pub struct BetterstackConfig {
33    /// Betterstack API token (required)
34    pub(crate) token: String,
35
36    /// Betterstack endpoint URL
37    pub(crate) endpoint: String,
38
39    /// HTTP request timeout
40    pub(crate) timeout: Duration,
41
42    /// Maximum number of logs per batch
43    pub(crate) batch_size: usize,
44
45    /// Maximum time to wait before flushing a batch
46    pub(crate) batch_delay: Duration,
47
48    /// Channel capacity for buffering logs
49    pub(crate) channel_capacity: usize,
50
51    /// Whether to include span context in logs
52    pub(crate) include_span_context: bool,
53
54    /// Logger name to include in payloads
55    pub(crate) logger_name: String,
56
57    /// Logger version to include in payloads
58    pub(crate) logger_version: String,
59
60    /// Optional error callback
61    pub(crate) on_error: Option<Arc<dyn Fn(BetterstackError) + Send + Sync>>,
62}
63
64impl BetterstackConfig {
65    /// Get the API token
66    pub fn token(&self) -> &str {
67        &self.token
68    }
69
70    /// Get the endpoint URL
71    pub fn endpoint(&self) -> &str {
72        &self.endpoint
73    }
74
75    /// Get the HTTP timeout
76    pub fn timeout(&self) -> Duration {
77        self.timeout
78    }
79
80    /// Get the batch size
81    pub fn batch_size(&self) -> usize {
82        self.batch_size
83    }
84
85    /// Get the batch delay
86    pub fn batch_delay(&self) -> Duration {
87        self.batch_delay
88    }
89
90    /// Get the channel capacity
91    pub fn channel_capacity(&self) -> usize {
92        self.channel_capacity
93    }
94
95    /// Check if span context should be included
96    pub fn include_span_context(&self) -> bool {
97        self.include_span_context
98    }
99
100    /// Get the logger name
101    pub fn logger_name(&self) -> &str {
102        &self.logger_name
103    }
104
105    /// Get the logger version
106    pub fn logger_version(&self) -> &str {
107        &self.logger_version
108    }
109
110    /// Call the error callback if one is configured
111    pub(crate) fn handle_error(&self, error: BetterstackError) {
112        if let Some(ref callback) = self.on_error {
113            callback(error);
114        }
115    }
116}
117
118/// Builder for BetterstackConfig
119pub struct BetterstackConfigBuilder {
120    token: String,
121    endpoint: Option<String>,
122    timeout: Option<Duration>,
123    batch_size: Option<usize>,
124    batch_delay: Option<Duration>,
125    channel_capacity: Option<usize>,
126    include_span_context: Option<bool>,
127    logger_name: Option<String>,
128    logger_version: Option<String>,
129    on_error: Option<Arc<dyn Fn(BetterstackError) + Send + Sync>>,
130}
131
132impl BetterstackConfigBuilder {
133    /// Create a new builder with the required token
134    pub fn new(token: impl Into<String>) -> Self {
135        Self {
136            token: token.into(),
137            endpoint: None,
138            timeout: None,
139            batch_size: None,
140            batch_delay: None,
141            channel_capacity: None,
142            include_span_context: None,
143            logger_name: None,
144            logger_version: None,
145            on_error: None,
146        }
147    }
148
149    /// Set the Betterstack endpoint URL
150    ///
151    /// Default: `https://in.logs.betterstack.com/`
152    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
153        self.endpoint = Some(endpoint.into());
154        self
155    }
156
157    /// Set the HTTP request timeout
158    ///
159    /// Default: 10 seconds
160    pub fn timeout(mut self, timeout: Duration) -> Self {
161        self.timeout = Some(timeout);
162        self
163    }
164
165    /// Set the maximum batch size (number of logs)
166    ///
167    /// Default: 10
168    pub fn batch_size(mut self, size: usize) -> Self {
169        self.batch_size = Some(size);
170        self
171    }
172
173    /// Set the maximum batch delay (time before flush)
174    ///
175    /// Default: 2 seconds
176    pub fn batch_delay(mut self, delay: Duration) -> Self {
177        self.batch_delay = Some(delay);
178        self
179    }
180
181    /// Set the channel capacity (max queued logs)
182    ///
183    /// Default: 1000
184    pub fn channel_capacity(mut self, capacity: usize) -> Self {
185        self.channel_capacity = Some(capacity);
186        self
187    }
188
189    /// Set whether to include span context in logs
190    ///
191    /// Default: true
192    pub fn include_span_context(mut self, include: bool) -> Self {
193        self.include_span_context = Some(include);
194        self
195    }
196
197    /// Set the logger name
198    ///
199    /// Default: "tracing-betterstack"
200    pub fn logger_name(mut self, name: impl Into<String>) -> Self {
201        self.logger_name = Some(name.into());
202        self
203    }
204
205    /// Set the logger version
206    ///
207    /// Default: crate version
208    pub fn logger_version(mut self, version: impl Into<String>) -> Self {
209        self.logger_version = Some(version.into());
210        self
211    }
212
213    /// Set an error callback function
214    ///
215    /// This function will be called when errors occur during log sending.
216    /// Useful for monitoring and debugging.
217    pub fn on_error<F>(mut self, callback: F) -> Self
218    where
219        F: Fn(BetterstackError) + Send + Sync + 'static,
220    {
221        self.on_error = Some(Arc::new(callback));
222        self
223    }
224
225    /// Build the configuration
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if:
230    /// - Token is empty
231    /// - Batch size is 0
232    /// - Channel capacity is 0
233    /// - Timeout is 0
234    pub fn build(self) -> Result<BetterstackConfig> {
235        // Validate token
236        if self.token.is_empty() {
237            return Err(BetterstackError::ConfigError(
238                "Token cannot be empty".to_string(),
239            ));
240        }
241
242        let batch_size = self.batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
243        if batch_size == 0 {
244            return Err(BetterstackError::ConfigError(
245                "Batch size must be greater than 0".to_string(),
246            ));
247        }
248
249        let channel_capacity = self.channel_capacity.unwrap_or(DEFAULT_CHANNEL_CAPACITY);
250        if channel_capacity == 0 {
251            return Err(BetterstackError::ConfigError(
252                "Channel capacity must be greater than 0".to_string(),
253            ));
254        }
255
256        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
257        if timeout.is_zero() {
258            return Err(BetterstackError::ConfigError(
259                "Timeout must be greater than 0".to_string(),
260            ));
261        }
262
263        Ok(BetterstackConfig {
264            token: self.token,
265            endpoint: self
266                .endpoint
267                .unwrap_or_else(|| DEFAULT_ENDPOINT.to_string()),
268            timeout,
269            batch_size,
270            batch_delay: self.batch_delay.unwrap_or(DEFAULT_BATCH_DELAY),
271            channel_capacity,
272            include_span_context: self.include_span_context.unwrap_or(true),
273            logger_name: self
274                .logger_name
275                .unwrap_or_else(|| env!("CARGO_PKG_NAME").to_string()),
276            logger_version: self
277                .logger_version
278                .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()),
279            on_error: self.on_error,
280        })
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_builder_with_defaults() {
290        let config = BetterstackConfigBuilder::new("test-token")
291            .build()
292            .expect("config should build");
293
294        assert_eq!(config.token(), "test-token");
295        assert_eq!(config.endpoint(), DEFAULT_ENDPOINT);
296        assert_eq!(config.timeout(), DEFAULT_TIMEOUT);
297        assert_eq!(config.batch_size(), DEFAULT_BATCH_SIZE);
298        assert_eq!(config.batch_delay(), DEFAULT_BATCH_DELAY);
299        assert_eq!(config.channel_capacity(), DEFAULT_CHANNEL_CAPACITY);
300        assert!(config.include_span_context());
301    }
302
303    #[test]
304    fn test_builder_with_custom_values() {
305        let config = BetterstackConfigBuilder::new("test-token")
306            .endpoint("https://custom.endpoint.com/")
307            .timeout(Duration::from_secs(5))
308            .batch_size(50)
309            .batch_delay(Duration::from_secs(10))
310            .channel_capacity(500)
311            .include_span_context(false)
312            .logger_name("custom-logger")
313            .logger_version("1.2.3")
314            .build()
315            .expect("config should build");
316
317        assert_eq!(config.endpoint(), "https://custom.endpoint.com/");
318        assert_eq!(config.timeout(), Duration::from_secs(5));
319        assert_eq!(config.batch_size(), 50);
320        assert_eq!(config.batch_delay(), Duration::from_secs(10));
321        assert_eq!(config.channel_capacity(), 500);
322        assert!(!config.include_span_context());
323        assert_eq!(config.logger_name(), "custom-logger");
324        assert_eq!(config.logger_version(), "1.2.3");
325    }
326
327    #[test]
328    fn test_builder_empty_token_fails() {
329        let result = BetterstackConfigBuilder::new("").build();
330        assert!(result.is_err());
331        assert!(matches!(result, Err(BetterstackError::ConfigError(_))));
332    }
333
334    #[test]
335    fn test_builder_zero_batch_size_fails() {
336        let result = BetterstackConfigBuilder::new("token").batch_size(0).build();
337        assert!(result.is_err());
338    }
339
340    #[test]
341    fn test_builder_zero_channel_capacity_fails() {
342        let result = BetterstackConfigBuilder::new("token")
343            .channel_capacity(0)
344            .build();
345        assert!(result.is_err());
346    }
347
348    #[test]
349    fn test_builder_zero_timeout_fails() {
350        let result = BetterstackConfigBuilder::new("token")
351            .timeout(Duration::ZERO)
352            .build();
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_error_callback() {
358        use std::sync::Arc;
359        use std::sync::atomic::{AtomicBool, Ordering};
360
361        let called = Arc::new(AtomicBool::new(false));
362        let called_clone = called.clone();
363
364        let config = BetterstackConfigBuilder::new("token")
365            .on_error(move |_err| {
366                called_clone.store(true, Ordering::SeqCst);
367            })
368            .build()
369            .expect("config should build");
370
371        config.handle_error(BetterstackError::ConfigError("test".to_string()));
372        assert!(called.load(Ordering::SeqCst));
373    }
374
375    #[test]
376    fn test_config_with_extreme_values() {
377        // Test that very large values don't cause issues or overflow
378        let config = BetterstackConfigBuilder::new("test-token")
379            .batch_size(usize::MAX / 2) // Half of max to be safe with allocations
380            .batch_delay(Duration::from_secs(3600 * 24)) // 24 hours
381            .channel_capacity(100_000) // Very large capacity
382            .timeout(Duration::from_secs(600)) // 10 minutes
383            .build()
384            .expect("config with extreme values should build");
385
386        assert_eq!(config.batch_size(), usize::MAX / 2);
387        assert_eq!(config.batch_delay(), Duration::from_secs(3600 * 24));
388        assert_eq!(config.channel_capacity(), 100_000);
389        assert_eq!(config.timeout(), Duration::from_secs(600));
390    }
391
392    #[test]
393    fn test_config_with_minimal_non_zero_values() {
394        // Test with smallest possible non-zero values
395        let config = BetterstackConfigBuilder::new("test-token")
396            .batch_size(1) // Minimum batch size
397            .batch_delay(Duration::from_millis(1)) // Very small delay
398            .channel_capacity(1) // Minimum capacity
399            .timeout(Duration::from_millis(1)) // Very small timeout
400            .build()
401            .expect("config with minimal values should build");
402
403        assert_eq!(config.batch_size(), 1);
404        assert_eq!(config.batch_delay(), Duration::from_millis(1));
405        assert_eq!(config.channel_capacity(), 1);
406        assert_eq!(config.timeout(), Duration::from_millis(1));
407    }
408
409    #[test]
410    fn test_config_clone_preserves_all_fields() {
411        // Test that cloning a config preserves all fields including error callback
412        use std::sync::atomic::{AtomicUsize, Ordering};
413
414        let call_count = Arc::new(AtomicUsize::new(0));
415        let call_count_clone = call_count.clone();
416
417        let config1 = BetterstackConfigBuilder::new("test-token")
418            .endpoint("https://custom.endpoint.com/")
419            .timeout(Duration::from_secs(15))
420            .batch_size(42)
421            .batch_delay(Duration::from_secs(3))
422            .channel_capacity(2000)
423            .include_span_context(false)
424            .logger_name("custom-name")
425            .logger_version("2.0.0")
426            .on_error(move |_err| {
427                call_count_clone.fetch_add(1, Ordering::SeqCst);
428            })
429            .build()
430            .expect("config should build");
431
432        // Clone the config
433        let config2 = config1.clone();
434
435        // Verify all fields match
436        assert_eq!(config1.token(), config2.token());
437        assert_eq!(config1.endpoint(), config2.endpoint());
438        assert_eq!(config1.timeout(), config2.timeout());
439        assert_eq!(config1.batch_size(), config2.batch_size());
440        assert_eq!(config1.batch_delay(), config2.batch_delay());
441        assert_eq!(config1.channel_capacity(), config2.channel_capacity());
442        assert_eq!(
443            config1.include_span_context(),
444            config2.include_span_context()
445        );
446        assert_eq!(config1.logger_name(), config2.logger_name());
447        assert_eq!(config1.logger_version(), config2.logger_version());
448
449        // Verify error callbacks work independently (both should increment the same counter)
450        config1.handle_error(BetterstackError::ConfigError("test1".to_string()));
451        assert_eq!(call_count.load(Ordering::SeqCst), 1);
452
453        config2.handle_error(BetterstackError::ConfigError("test2".to_string()));
454        assert_eq!(call_count.load(Ordering::SeqCst), 2);
455    }
456}