High-performance logging for latency-critical Rust applications.
InqJet defers formatting to a background thread. The producer writes raw bytes to a lock-free ring buffer, not formatted strings. The hot-path cost is independent of message complexity.
6-20x faster than tracing across realistic logging scenarios (p50, single producer):
| Scenario | inqjet | tracing | Speedup |
|---|---|---|---|
| Static message | 0.16us | 0.99us | 6.2x |
| Single integer | 0.11us | 1.03us | 9.4x |
| Single &str | 0.17us | 1.07us | 6.3x |
| Realistic 4-arg | 0.18us | 1.04us | 5.8x |
Float {:.4} |
0.08us | 1.62us | 20.3x |
Debug {:?} Vec |
0.46us | 1.20us | 2.6x |
| Verbose 6-arg | 0.21us | 1.31us | 6.2x |
See BENCHMARKS.md for full results including tail latency and methodology.
use inqjet::{InqJetBuilder, LevelFilter};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let _guard = InqJetBuilder::default()
.with_writer(std::io::stdout())
.with_log_level(LevelFilter::Info)
.build()?;
// Native macros — bypass the log facade, direct to ring buffer:
inqjet::info!("Server started on port {}", 8080);
inqjet::error!("Connection failed: {}", "timeout");
// log crate macros also work (with `log-compat` feature, on by default):
// log::info!("This also works");
// Guard drop joins the archiver thread and flushes remaining records.
Ok(())
}Traditional loggers (tracing, env_logger, slog) format the log message on the
caller's thread. For info!("price: {:.4}", 99.95), the caller pays the cost
of float-to-string conversion before the message is handed off.
InqJet flips this. The producer writes the raw f64 bytes (8-byte memcpy) to a
ring buffer. A background archiver thread reads those bytes and formats the
output. The producer never calls Display::fmt.
A proc macro (inqjet::info!()) analyzes the format string at compile time and
generates per-argument encoding via autoref dispatch:
| Tier | Types | Producer cost | Mechanism |
|---|---|---|---|
| 1. Pod | Primitives, user structs | memcpy |
copy_nonoverlapping to ring buffer |
| 1.5. String | &str, String |
Length-prefix + byte copy | [len: u32][bytes] |
| 2. Fallback | Everything else | Eager format_args! |
Format to TLS buffer, copy as bytes |
Tiers 1 and 1.5 are the common cases. Only Tier 2 (Debug on complex types) formats on the producer — and even then, inqjet's ring buffer transport is faster than tracing's channel.
Producer Thread Archiver Thread
--------------- ---------------
inqjet::info!("msg: {}", val) loop {
|-- level check (AtomicU8 load) record = consumer.try_read()
|-- snap timestamp (u64 ns) |-- read header (24 bytes)
|-- encode args as raw bytes |-- extract fn_ptr, timestamp, level
|-- claim ring buffer space +-- (fn_ptr)(ts, lvl, payload, writer)
|-- memcpy header + payload flush writer
+-- commit (atomic store) park_timeout if idle
Key design decisions:
- Ring buffer, not channel.
nexus-logbufMPSC ring buffer. Fixed allocation, no per-message malloc. CAS-based multi-producer, single consumer. - Thread-local producers. Each thread lazily clones a producer on first log call. After that, pure thread-local — zero contention on the producer path.
- Function pointer dispatch. Each record header carries a
fn(ts, level, &[u8], &mut Write). The consumer calls it to format the payload. No vtable, no dynamic dispatch beyond the fn ptr. - Level gating before format_args. The
AtomicU8check happens beforeformat_args!()evaluates its arguments. Filtered messages cost ~8ns.
Mark your structs for zero-cost logging via memcpy:
use inqjet::Pod;
#[derive(Pod, Debug)]
struct OrderInfo {
id: u64,
price: f64,
qty: i64,
}
// Producer copies 24 bytes. Consumer formats with the original format string.
inqjet::info!("order: id={} price={:.2} qty={}", order.id, order.price, order.qty);#[derive(Pod)] enforces at compile time that the type has no Drop impl.
use inqjet::{InqJetBuilder, LevelFilter, ColorMode, BackpressureMode};
use std::time::Duration;
let _guard = InqJetBuilder::default()
.with_writer(std::io::stdout())
.with_log_level(LevelFilter::Info)
.with_buffer_size(1 << 20) // 1MB ring buffer (default: 64KB)
.with_timeout(Some(Duration::from_millis(5))) // Archiver park timeout (default)
.with_color_mode(ColorMode::Auto) // Auto / Always / Never
.with_backpressure(BackpressureMode::Backoff) // Backoff (default) or Drop
.build()?;Ring buffer size in bytes (rounded up to next power of two). Default: 64KB.
- 64KB: Low-memory, consistent message rate
- 256KB-1MB: Recommended for bursty workloads
- 4MB+: High-throughput, many producers
When the ring buffer is full:
Backoff(default): Exponential backoff viacrossbeam::Backoff. Spins briefly, then yields. Guarantees delivery at the cost of variable latency under pressure.Drop: Drop the message and return immediately. Bounded producer latency, no delivery guarantee.
Busy-spin the archiver thread (dedicates a CPU core):
let _guard = InqJetBuilder::default()
.with_writer(std::io::stdout())
.with_log_level(LevelFilter::Info)
.with_timeout(None) // Busy-spin, never park
.build()?;inqjet::set_level(LevelFilter::Debug);Takes effect immediately for all subsequent log calls.
| Flag | Default | Description |
|---|---|---|
log-compat |
Yes | Enables log::Log bridge so log::info!() routes through inqjet. |
Without log-compat, only the native inqjet::info!() macros are available.
The native macros are always faster than the bridge path.
2024-01-15T14:30:45.123456789Z [INFO] my_app::auth:127 User alice logged in
2024-01-15T14:30:45.124001234Z [ERROR] my_app::db Connection failed: timeout
ISO 8601 UTC timestamps with nanosecond precision. ANSI colors when writing to
a terminal (respects NO_COLOR and TERM=dumb).
Licensed under either of Apache License, Version 2.0 or MIT license at your option.