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.
Features
- 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
Quick Start
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)
Installation
Or add to your .nimble file:
requires "nimtrace >= 0.1.0"
API Reference
Event Macros
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)])
Span Macros
infoSpan "operation_name": # code runs inside the span info("event within span") # With fields infoSpan "http_request", @[field("method", "POST"), field("url", "/api")]: discard
Available at all levels: traceSpan, debugSpan, infoSpan, warnSpan, errorSpan.
Auto-Instrumentation
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 = 200
Async Context Propagation
When 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
Subscribers
FmtSubscriber (Human-Readable)
let sub = newFmtSubscriber( minLevel = lvlInfo, # filter threshold useColors = true, # ANSI colors (default: true) stream = stderr # output stream (default: stderr) ) setGlobalSubscriber(sub)
JsonSubscriber (Machine-Readable)
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}}Environment Filter
# 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
OtlpSubscriber (OpenTelemetry Export)
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
Compile-Time Configuration
| Flag | Effect |
|---|---|
-d:nimtraceDisabled |
Eliminate all tracing code at compile time |
-d:nimtraceMaxLevel:warn |
Remove trace/debug/info at compile time |
Fields
Fields are typed key-value pairs:
field("user", "alice") # string field("count", 42) # integer field("ratio", 3.14) # float field("active", true) # boolean
Architecture
nimtrace
├── 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)
Performance
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).
Testing
# 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
Design Principles
- 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
License
MIT