A small structured context propagation layer for the standard Rust log
ecosystem.
context-logger keeps your existing log::info!, log::warn!, etc. calls
unchanged and adds scoped structured context on top — inherited request-level
fields, local operation fields, computed defaults, and async-safe propagation.
The standard log crate is a small and widely used logging facade, but it does
not provide scoped context propagation by itself. This becomes noisy when every
log line should carry fields like request_id, user_id, tenant_id, or
operation.
context-logger fills this gap by adding a small runtime context layer on top
of ordinary log calls:
- Works with existing
logcalls — keep usinglog::info!,log::warn!, and your existing logger backend. - Fully dynamic context — create, extend, merge, and pass context as ordinary runtime data. Add fields at any point in your code without declaring spans.
- Scoped structured fields — attach request-level fields, operation-level fields, and default fields to every log record, with explicit control over what is inherited by child scopes.
- Async propagation — carry context through
.awaitwithFutureExt.
No.
context-loggeris focused on logs, not traces.
tracing puts logs and spans into one instrumentation model.
context-logger keeps logs as regular log records and adds scoped structured
context to them. Context remains ordinary runtime data, so existing
log::info!, log::warn!, etc. calls stay unchanged.
What about traces?
Use a dedicated tracing library such as fastrace.
This keeps the two concerns separate:
context-loggerenriches ordinarylogrecords with scoped structured context;fastracerecords timeline spans and exports traces through OpenTelemetry.
Together they cover structured logs and distributed traces without requiring all
logging to go through the tracing instrumentation model.
cargo run --example log_async | jq
{
"instance": "contexted_log_async",
"level": "INFO",
"message": "Initialized context logger",
"target": "log_async",
"timestamp": 1784151962445
}
{
"instance": "contexted_log_async",
"level": "INFO",
"message": "Logging in",
"target": "log_async",
"thread_name": "first_future",
"timestamp": 1784151962445,
"user_id": "12345"
}
{
"action": {
"action": "login",
"name": "user"
},
"instance": "contexted_log_async",
"level": "INFO",
"message": "User logged in successfully",
"target": "log_async",
"thread_name": "first_future",
"timestamp": 1784151962445
}Add context-logger to your Cargo.toml:
[dependencies]
chrono = "0.4"
context-logger = "0.2"
log = { version = "0.4", features = ["kv_serde"] }
env_logger = { version = "0.11", features = ["kv"] }Then, you can use it in your code:
use context_logger::{ContextLogger, LogContext, LogContextExt as _};
use log::info;
fn main() {
// Create an underlying logger instance.
let env_logger = env_logger::builder()
.filter_level(log::LevelFilter::Info)
.build();
let filter = env_logger.filter();
// Wrap it with ContextLogger to enable context propagation.
let logger = ContextLogger::new(env_logger)
// Add static default field (static field).
.with_default_field("service", "api")
// Add dynamic default field (per-event field).
.with_default_field_fn("timestamp", |log_record| {
chrono::Utc::now().to_rfc3339()
})
.with_default_field_fn("level", |log_record| log_record.level().to_string());
// Initialize the resulting logger.
logger.init(filter);
// Create a context with properties.
let context = LogContext::new()
// Field that will be inherited by child contexts.
.with_inherited_field("request_id", "req-123")
.with_inherited_field("user_id", 42)
.with_local_field("http_method", "GET");
// Use the context.
context.in_scope(|| {
// Log with context automatically attached:
// service=api timestamp=... level=INFO request_id=req-123 user_id=42
info!("Processing request");
})
}Context logger supports async functions and can propagate log context across
.await points.
use context_logger::{ContextLogger, LogContext, FutureExt, LogScope};
use log::info;
async fn process_user_data(user_id: &str) {
let context = LogContext::new()
.with_inherited_field("user_id", user_id);
async {
info!("Processing user data"); // Includes user_id
// Context automatically propagates through .await points.
fetch_user_preferences().await;
info!("User data processed"); // Still includes user_id
}
.in_log_context(context)
.await;
}
async fn fetch_user_preferences() {
// Add additional field for this operation.
LogScope::add_local_field("operation", "fetch_preferences");
info!("Fetching preferences"); // Includes both user_id and operation
}
async fn spawn_background_job(user_id: &str) {
let context = LogContext::new()
.with_inherited_field("user_id", user_id);
async {
// The scope stack is thread-local: capture the active context
// before crossing the task boundary with tokio::spawn.
let context = LogScope::current_context();
tokio::spawn(
async move {
info!("Running background job"); // Includes user_id
}
.in_log_context(context),
)
.await
.unwrap();
}
.in_log_context(context)
.await;
}This project is licensed under the MIT License. See the LICENSE file for details.