tracing-better-stack 0.1.0

A tracing-subscriber layer for Better Stack (Logtail) logging
Documentation
use reqwest::Client;
use std::time::Duration;
use tokio::time::sleep;

use crate::config::BetterStackConfig;
use crate::error::Result;
use crate::log_event::LogEvent;

lazy_static::lazy_static! {
    /// Global HTTP client for sending logs to Better Stack.
    /// Configured with a 30-second timeout for all requests.
    static ref HTTP_CLIENT: Client = Client::builder()
        .timeout(Duration::from_secs(30))
        .build()
        .expect("Failed to create HTTP client");
}

/// Sends a batch of log events to Better Stack with automatic retry logic.
///
/// Implements exponential backoff retry strategy for transient failures.
/// Will retry up to `config.max_retries` times before giving up.
///
/// # Arguments
///
/// * `batch` - Vector of log events to send
/// * `config` - Better Stack configuration including endpoint and retry settings
///
/// # Returns
///
/// * `Ok(())` if the batch was successfully sent
/// * `Err(BetterStackError)` if all retry attempts failed
///
/// # Retry Behavior
///
/// Uses exponential backoff starting from `config.initial_retry_delay`,
/// doubling after each failure up to `config.max_retry_delay`.
pub(crate) async fn send_batch(batch: Vec<LogEvent>, config: &BetterStackConfig) -> Result<()> {
    let mut retries = 0;
    let mut delay = config.initial_retry_delay;

    loop {
        match send_request(&batch, config).await {
            Ok(_) => return Ok(()),
            Err(e) => {
                if retries >= config.max_retries {
                    return Err(e);
                }

                eprintln!(
                    "Failed to send logs to Better Stack (attempt {}/{}): {}. Retrying in {:?}",
                    retries + 1,
                    config.max_retries,
                    e,
                    delay
                );

                sleep(delay).await;

                // Exponential backoff with max delay
                delay = std::cmp::min(delay * 2, config.max_retry_delay);
                retries += 1;
            }
        }
    }
}

/// Performs a single HTTP POST request to send logs to Better Stack.
///
/// Sends the batch to the Better Stack ingestion endpoint
/// using bearer token authentication. The format (JSON or MessagePack)
/// is determined by the enabled feature flag.
///
/// # Arguments
///
/// * `batch` - Slice of log events to send
/// * `config` - Better Stack configuration with endpoint and authentication
///
/// # Returns
///
/// * `Ok(())` if the request was successful (2xx status)
/// * `Err(BetterStackError)` if the request failed or returned non-2xx status
///
/// # Errors
///
/// Returns an error if:
/// - The HTTP request fails (network issues, timeout, etc.)
/// - Better Stack returns a non-success status code
/// - The response body cannot be read (for error reporting)
async fn send_request(batch: &[LogEvent], config: &BetterStackConfig) -> Result<()> {
    // Serialize the batch and set content type based on the feature flag
    let (body, content_type) = {
        #[cfg(feature = "json")]
        {
            let body = serde_json::to_vec(&batch)?;
            (body, "application/json")
        }

        #[cfg(feature = "message_pack")]
        {
            let body = rmp_serde::to_vec_named(&batch)?;
            (body, "application/msgpack")
        }
    };

    let response = HTTP_CLIENT
        .post(config.ingestion_url())
        .bearer_auth(&config.source_token)
        .header("Content-Type", content_type)
        .body(body)
        .send()
        .await?;

    if !response.status().is_success() {
        let status = response.status();
        let body = response
            .text()
            .await
            .unwrap_or_else(|_| "Unknown error".to_string());
        return Err(crate::error::BetterStackError::RuntimeError(format!(
            "Better Stack API error: {} - {}",
            status, body
        )));
    }

    Ok(())
}