A structured, span-based tracing framework for Nim — inspired by Rust's tracing crate.
nimtrace provides structured diagnostics with spans (time intervals) and events (points in time), enabling rich, contextual logging with zero-cost abstractions when tracing is disabled.
- Structured tracing — spans and events with typed fields, not just string messages
- Zero-cost when disabled — compile with
-d:nimtraceDisabledto eliminate all tracing code - Thread-safe — thread-local span stacks, no locks on the hot path
- Pluggable subscribers — runtime-polymorphic output backends
- Built-in subscribers — human-readable (colored), JSON Lines, and no-op
- Environment-based filtering —
NIMTRACE_LOG=warn,mymod=debugsyntax - Auto-instrumentation —
{.traced.}pragma wraps procs in spans automatically - OpenTelemetry-compatible IDs — 8-byte SpanId, 16-byte TraceId
- Pure Nim — no external C dependencies, stdlib only
import nimtrace
# Set up a subscriber at program startup
let subscriber = newFmtSubscriber(minLevel = lvlInfo)
setGlobalSubscriber(subscriber)
# Emit events
info("application started")
debug("this is filtered out")
warn("disk usage high", @[field("percent", 92)])
# Create spans
infoSpan "handle_request", @[field("method", "GET"), field("path", "/api")]:
info("processing request")
debugSpan "db_query":
info("query complete", @[field("rows", 42)])Output:
2025-02-18T04:50:00Z INFO handle_request{method="GET" path="/api"}: processing request
2025-02-18T04:50:00Z INFO handle_request{method="GET" path="/api"}:db_query: query complete rows=42
2025-02-18T04:50:00Z INFO handle_request{method="GET" path="/api"}: closed (1.2ms)
nimble install nimtraceOr add to your .nimble file:
requires "nimtrace >= 0.1.0"trace("verbose detail")
debug("debugging info")
info("normal operation")
warn("something unusual")
error("something failed")
# With structured fields
info("request handled", @[field("status", 200), field("latency_ms", 12.5)])infoSpan "operation_name":
# code runs inside the span
info("event within span")
# With fields
infoSpan "http_request", @[field("method", "POST"), field("url", "/api")]:
discardAvailable at all levels: traceSpan, debugSpan, infoSpan, warnSpan, errorSpan.
import nimtrace
import nimtrace/instrument
proc handleRequest(method: string, path: string): int {.traced.} =
# Automatically wrapped in a span named "handleRequest"
# with fields: method="..." path="..."
result = 200When using async/await, multiple coroutines share the same thread-local span stack. nimtrace provides primitives to preserve context across yield points:
import nimtrace
proc handler() {.async.} =
infoSpan "handle_request":
info("before await")
# Preserve context across await
preserveContext:
await someAsyncOperation()
info("after await — still in the right span")For spawning child async tasks that inherit parent context:
proc parentTask() {.async.} =
infoSpan "parent":
let childCtx = forkContext()
await childTask(childCtx)
proc childTask(ctx: SpanContextSnapshot) {.async.} =
withContext ctx:
infoSpan "child_work":
info("running with parent's span ancestry")Key APIs:
captureContext()— snapshot the current span stackrestoreContext(snapshot)— replace the span stack with a snapshotpreserveContext: body— auto-save/restore around a block (e.g., containingawait)withContext snapshot: body— run body within a captured context (scoped)forkContext()— copy context for a child task
let sub = newFmtSubscriber(
minLevel = lvlInfo, # filter threshold
useColors = true, # ANSI colors (default: true)
stream = stderr # output stream (default: stderr)
)
setGlobalSubscriber(sub)let sub = newJsonSubscriber(
minLevel = lvlDebug,
stream = stdout
)
setGlobalSubscriber(sub)Outputs one JSON object per line (JSON Lines format):
{"timestamp":"2025-02-18T04:50:00Z","level":"INFO","name":"request handled","fields":{"status":200}}# Reads NIMTRACE_LOG env var: "warn,mymod=debug,db=trace"
let inner = newFmtSubscriber()
let sub = newEnvFilter(inner, "NIMTRACE_LOG")
setGlobalSubscriber(sub)Format: default_level,module1=level1,module2=level2
Export spans to any OpenTelemetry-compatible collector (Jaeger, Grafana Tempo, Honeycomb, etc.) via OTLP/HTTP JSON:
let sub = newOtlpSubscriber(
endpoint = "http://localhost:4318/v1/traces",
serviceName = "my-service",
resourceAttrs = @[field("deployment.environment", "production")],
headers = @[("Authorization", "Bearer my-token")],
batchSize = 64 # auto-flush after 64 completed spans
)
setGlobalSubscriber(sub)
# ... application code ...
# Flush remaining spans at shutdown
sub.flush()Features:
- Batch export — collects completed spans, flushes on threshold or explicit
flush() - Span events — nimtrace events within a span become OTLP span events
- Resource attributes —
service.name+ custom attributes - Status codes — error-level spans get
STATUS_CODE_ERROR - Custom headers — for authentication with hosted collectors
- Failed export retry — re-queues batch on HTTP failure
Works with any OTLP/HTTP JSON endpoint:
- Jaeger:
http://localhost:4318/v1/traces - Grafana Tempo:
http://localhost:4318/v1/traces - Honeycomb:
https://api.honeycomb.io/v1/traceswith API key header
| Flag | Effect |
|---|---|
-d:nimtraceDisabled |
Eliminate all tracing code at compile time |
-d:nimtraceMaxLevel:warn |
Remove trace/debug/info at compile time |
Fields are typed key-value pairs:
field("user", "alice") # string
field("count", 42) # integer
field("ratio", 3.14) # float
field("active", true) # booleannimtrace
├── core # Level, Field, Metadata, SpanId, TraceId
├── span # Span, SpanContext, thread-local stack
├── event # Event type
├── subscriber # SubscriberBase (virtual methods)
├── registry # Global subscriber management
├── api # Event/span templates (user-facing)
├── filter # LevelFilter, EnvFilter
├── instrument # {.traced.} pragma macro
├── async_context # Async-aware context propagation
└── subscribers
├── fmt # Colored, human-readable output
├── json # JSON Lines output
├── otlp # OpenTelemetry OTLP/HTTP JSON export
└── noop # Zero-cost no-op (default)
Benchmarks on Nim 2.2.6, -d:release --opt:speed:
| Operation | Time |
|---|---|
| Disabled path (no subscriber) | ~15ns |
| Field creation (int) | ~5ns |
| SpanId generation | ~5ns |
| Level filter check | ~40ns |
| Event emission (simple) | ~63ns |
| Event emission (3 fields) | ~119ns |
| Span lifecycle (simple) | ~157ns |
| Span lifecycle (with fields) | ~222ns |
| Nested spans (2 levels) | ~299ns |
With -d:nimtraceDisabled, all tracing compiles to nothing (0ns).
# Run all tests
for t in tests/test_*.nim; do nim c --hints:off --path:src -r $t; done
# Run with ORC
for t in tests/test_*.nim; do nim c --hints:off --path:src --mm:orc -r $t; done
# Run benchmarks
nim c --hints:off --path:src -d:release --opt:speed -r benchmarks/bench_hot_path.nim- Zero-cost abstractions — templates inline everything; disabled paths compile away
- Value-type spans — stack allocated, no GC pressure on the hot path
- Thread-local stacks —
{.threadvar.}for lock-free span context propagation - Composable subscribers — filters wrap subscribers, build any pipeline
- No silent failure — explicit error handling, no swallowed exceptions
MIT