# betterstack-tracing
A [tracing](https://docs.rs/tracing) layer for sending logs to [Betterstack](https://betterstack.com).
This crate provides a Rust implementation inspired by the [slog-betterstack](https://github.com/samber/slog-betterstack) Go library, adapted for Rust's `tracing` ecosystem.
## Features
- **Non-blocking async log sending** - Logs are sent in the background without blocking your application
- **Automatic batching** - Configurable size and time-based batching for efficient log delivery
- **Gzip compression** - Automatically compresses batches for reduced bandwidth usage
- **Size limit validation** - Enforces Betterstack API limits (1 MiB per log, 10 MiB per batch)
- **Span context tracking** - Automatically includes parent span information for distributed tracing
- **Configurable error handling** - Optional error callbacks for monitoring send failures
- **Connection pooling** - Reuses HTTP connections for better performance
- **Type-safe configuration** - Builder pattern with compile-time validation
## Installation
Add this to your `Cargo.toml`:
```toml
[dependencies]
betterstack-tracing = "0.1"
tracing = "0.1"
tracing-subscriber = "0.3"
tokio = { version = "1", features = ["full"] }
```
## Quick Start
```rust
use betterstack_tracing::BetterstackLayer;
use tracing_subscriber::prelude::*;
#[tokio::main]
async fn main() {
// Create the Betterstack layer
let config = BetterstackLayer::builder("your-betterstack-token")
.build()
.expect("failed to create config");
let betterstack_layer = BetterstackLayer::new(config);
// Initialize tracing with the Betterstack layer
tracing_subscriber::registry()
.with(betterstack_layer)
.init();
// Use tracing as normal
tracing::info!("Application started");
tracing::error!(error = "connection refused", "Failed to connect");
// Give logs time to be sent before exiting
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
```
## Configuration
### Builder Options
```rust
use std::time::Duration;
let config = BetterstackLayer::builder("your-token")
// Optional: Custom endpoint (default: https://in.logs.betterstack.com/)
.endpoint("https://in.logs.betterstack.com/")
// Optional: HTTP request timeout (default: 10s)
.timeout(Duration::from_secs(10))
// Optional: Batch size (default: 10 logs)
.batch_size(10)
// Optional: Batch delay (default: 2s)
.batch_delay(Duration::from_secs(2))
// Optional: Channel capacity (default: 1000)
.channel_capacity(1000)
// Optional: Include span context (default: true)
.include_span_context(true)
// Optional: Custom logger name (default: "tracing-betterstack")
.logger_name("my-app")
// Optional: Custom logger version (default: crate version)
.logger_version("1.0.0")
// Optional: Error callback
.on_error(|error| {
eprintln!("Betterstack error: {}", error);
})
.build()
.expect("failed to create config");
let layer = BetterstackLayer::new(config);
```
### Batching Strategy
Logs are automatically batched and sent when either:
- The batch reaches `batch_size` logs, OR
- `batch_delay` time has elapsed since the last send
This provides a good balance between latency and throughput.
### Span Context
When `include_span_context` is enabled (default), the layer automatically captures the current span hierarchy and includes it in the log payload:
```rust
let span = tracing::info_span!("http_request", request_id = "123");
let _enter = span.enter();
tracing::info!("Processing request");
// This log will include the "http_request" span context
```
## Examples
### Basic Usage
```rust
use betterstack_tracing::BetterstackLayer;
use tracing_subscriber::prelude::*;
#[tokio::main]
async fn main() {
let token = std::env::var("BETTERSTACK_TOKEN")
.expect("BETTERSTACK_TOKEN must be set");
let config = BetterstackLayer::builder(token)
.build()
.expect("failed to create config");
let betterstack_layer = BetterstackLayer::new(config);
tracing_subscriber::registry()
.with(betterstack_layer)
.init();
tracing::info!("Hello, Betterstack!");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
```
### With Console Output
Combine with the `fmt` layer to log to both console and Betterstack:
```rust
use betterstack_tracing::BetterstackLayer;
use tracing_subscriber::prelude::*;
#[tokio::main]
async fn main() {
let config = BetterstackLayer::builder("your-token")
.build()
.expect("failed to create config");
let betterstack_layer = BetterstackLayer::new(config);
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer()) // Console output
.with(betterstack_layer) // Betterstack
.init();
tracing::info!("This goes to both console and Betterstack");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
```
### With Spans
```rust
use betterstack_tracing::BetterstackLayer;
use tracing_subscriber::prelude::*;
#[tokio::main]
async fn main() {
let config = BetterstackLayer::builder("your-token")
.include_span_context(true)
.build()
.expect("failed to create config");
let betterstack_layer = BetterstackLayer::new(config);
tracing_subscriber::registry()
.with(betterstack_layer)
.init();
let span = tracing::info_span!("http_request",
request_id = "req-123",
method = "POST"
);
let _enter = span.enter();
tracing::info!("Handling request");
// The log will include span context with request_id and method
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
```
See the `examples/` directory for more complete examples.
## Payload Format
Logs are sent to Betterstack in the following JSON format:
```json
{
"dt": "2025-10-11T12:34:56.789Z",
"level": "INFO",
"message": "log message",
"logger.name": "betterstack-tracing",
"logger.version": "0.1.0",
"target": "my_app::module",
"file": "src/main.rs",
"line": 42,
"custom_field": "value",
"spans": [
{
"name": "http_request",
"fields": {
"request_id": "req-123"
}
}
]
}
```
All custom fields and span information are included at the root level of the JSON object, not nested under an "extra" key, in compliance with the [Betterstack logs API](https://betterstack.com/docs/logs/ingesting-data/http/).
## Performance
- **Non-blocking**: Log calls return immediately, sending happens in background
- **Batched**: Reduces HTTP overhead by sending multiple logs per request
- **Backpressure**: Bounded channel prevents memory growth under load
- **Connection pooling**: Reuses HTTP connections for better performance
If the log channel is full, new logs are dropped to prevent blocking your application.
## Error Handling
By default, send errors are silently ignored. You can provide an error callback:
```rust
let config = BetterstackLayer::builder("your-token")
.on_error(|error| {
eprintln!("Failed to send logs to Betterstack: {}", error);
// Or send to metrics, etc.
})
.build()
.expect("failed to create config");
```
## Size Limits
The crate automatically enforces [Betterstack API size limits](https://betterstack.com/docs/logs/ingesting-data/http/):
- **Individual logs**: Maximum 1 MiB per log record
- Logs exceeding this limit are dropped with a warning
- Logs exceeding 100 KiB generate a debug message
- **Batches**: Maximum 10 MiB uncompressed per batch
- Batches are automatically compressed with gzip (typically 3-4x compression ratio)
- Oversized batches result in an error
These limits help prevent rejected requests and ensure reliable log delivery.
## Graceful Shutdown
The layer automatically flushes pending logs when dropped, with a 5-second timeout. For explicit control:
```rust
layer.flush().await;
```
## License
Licensed under the MIT license.
## Acknowledgments
- Inspired by [slog-betterstack](https://github.com/samber/slog-betterstack) by [@samber](https://github.com/samber)
- Built for the [tracing](https://docs.rs/tracing) ecosystem