TraceWeft is an open-source Rust-first observability and debugging toolkit for LLM agents. It captures model calls, tool calls, memory operations, retrievals, state transitions, checkpoints, handoffs, and errors as structured traces, then lets developers inspect, replay, diff, and export them through OpenTelemetry-compatible pipelines.
TraceWeft is local-first by default: run a Rust agent, open the local debugger, and inspect the full execution without sending prompts or tool outputs to a SaaS service.
The local trace workbench (trace-weft dev plus the web UI):
| Span tree & inspector | Trace graph |
|---|---|
![]() |
![]() |
TraceWeft is not yet published to crates.io. Depend on it by git, pinning a revision for reproducible builds:
[dependencies]
trace-weft = { git = "https://github.com/kidoz/trace-weft", rev = "<commit-sha>" }The SDK is sqlite-by-default (a SQLite mirror alongside the JSONL stream). For
a pure local-JSONL integrator that pulls no sqlx:
[dependencies]
trace-weft = { git = "https://github.com/kidoz/trace-weft", rev = "<commit-sha>", default-features = false }Requires Rust 1.94.1+ (edition 2024). Install the CLI from a checkout:
cargo install --path crates/trace-weft-cliInitialize a recorder once at startup, then record spans. The recorder is a
process-wide singleton; init_local wires the JSONL + SQLite recorder and the
blob store for content capture.
use trace_weft::{CapturePolicy, LocalConfig, init_local};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_local(LocalConfig {
database_path: "./.trace-weft/traces.jsonl".into(),
sqlite_db_path: "./.trace-weft/traces.sqlite".into(),
blob_dir: "./.trace-weft/blobs".into(),
capture_content: CapturePolicy::RedactedPreview,
})
.await?;
let answer = answer_question("why is the sky blue?".into()).await?;
println!("{answer}");
Ok(())
}For tests and evaluation, swap in an in-memory store:
use std::sync::Arc;
use trace_weft::{eval::MemoryStore, init_custom};
let store = MemoryStore::new();
init_custom(Arc::new(store.clone()))?;
// ... run the agent, then assert over store.spans / store.eventsTo compile tracing in but disable it at runtime, use the no-op store:
init_custom(Arc::new(trace_weft::NullStore)).
SpanBuilder is the capable, imperative recorder. Build a span, set the rich
fields you have on hand, and wrap the work in .run(...):
use trace_weft::{build_llm_call, CostEstimate, TokenUsage};
let answer = build_llm_call("chat_completion")
.provider("anthropic")
.model("claude-fable-5")
.prompt_version("v3")
.token_usage(TokenUsage { input: 1200, output: 280, reasoning: None, breakdown: Default::default() })
.cost(CostEstimate { currency: "USD".into(), amount: 0.012 })
.cache_hit(false)
.attribute("temperature", serde_json::json!(0.2))
.run(|| async {
// the real call
client.complete(&prompt).await
})
.await?;SpanBuilder::run(f) executes the closure and records:
- latency —
start_time/end_time/latency_msaround the closure; - status —
OkonOk(_),ErroronErr(e)(witherror_typefromDebugand a redacted message fromDisplay); - parenting — auto-links to the ambient span context (see below) unless you
set one explicitly with
.with_parent(...); - replay — short-circuits to a mocked value when replay is configured.
It does not capture inputs/outputs by itself — set .input_ref() /
.output_ref() (or use the macros, which capture content for you). For
closures that don't return Result, use .run_infallible(|| async { ... }).
Builder setters cover the high-value SpanRecord fields: .provider(),
.model(), .prompt_version(), .tool_name(), .input_ref(),
.output_ref(), .token_usage(), .cost(), .cache_hit(),
.retrieval(query_hash, doc_refs), .attribute(k, v), .attributes(map).
Wrap each trait method body in a builder call — the span kind matches the role:
#[async_trait::async_trait]
impl LlmClient for AnthropicClient {
async fn complete(&self, req: Request) -> anyhow::Result<Response> {
trace_weft::build_llm_call("complete")
.provider("anthropic")
.model(&req.model)
.run(|| async { self.inner.complete(req).await })
.await
}
}
#[async_trait::async_trait]
impl Tool for KbSearch {
async fn call(&self, query: String) -> anyhow::Result<Vec<Doc>> {
trace_weft::build_tool("kb_search")
.tool_name("kb_search")
.run(|| async { self.search(query).await })
.await
}
}#[agent], #[tool], and #[llm_call] instrument a function (free function or
impl/trait-impl method, including &self) and record a span with the matching
SpanKind. They:
- set the correct span kind and
Errorstatus when the body returnsErr; - auto-link to the ambient span context, so nested instrumented calls form a tree with no manual ID threading;
- capture inputs/outputs under the configured
CapturePolicy— every captured argument must beSerialize; opt an argument out with#[trace(skip)].
use trace_weft::{agent, llm_call, tool};
struct ResearchAgent { client: AnthropicClient }
impl ResearchAgent {
#[agent]
async fn run(&self, question: String) -> anyhow::Result<String> {
let plan = self.plan(&question).await?; // child span, auto-parented
self.draft(plan).await
}
#[llm_call]
async fn plan(&self, question: &str, #[trace(skip)] api_key: &str) -> anyhow::Result<Plan> {
// `question` is captured to input_ref; `api_key` is skipped
self.client.plan(question, api_key).await
}
}Trait definitions carry no body, so annotate the concrete impl. The
instrumented function must be async.
CapturePolicy (set via LocalConfig.capture_content) governs content capture:
| Policy | Behavior |
|---|---|
MetadataOnly |
No content captured (zero serialization cost). |
RedactedPreview |
Redacts content, stores it, sets a redacted preview. |
FullContentLocalOnly / FullContentExportable |
Stores full content. |
Captured content is hashed, written to the blob store, and referenced from the
span as input_ref / output_ref.
Spans have duration; events are point-in-time occurrences inside a span (a
retry, a budget check, a guardrail trip, an REPL step). Build one with event
and .record() it — it auto-links to the ambient span and gets a monotonic
ordering seq:
use trace_weft::{event, EventKind};
event(EventKind::Budget, "budget_check")
.attribute("tokens_remaining", serde_json::json!(1500))
.record()
.await;EventKind: LlmCall, ToolCall, ReplExec, Rpc, Budget, Guardrail,
Retry, Termination, Log, Custom.
SpanBuilder::run and the macros install the current span as a task-local
parent for the duration of the body. Child spans and events created inside it
link automatically; with_parent(trace_id, run_id, span_id) is the explicit
override for cross-task / cross-thread handoffs. Construct IDs with
TraceId::new(), SpanId::new(), RunId::new() — no direct uuid dependency
required.
SpanBuilder::wait_for_approval() records the span as PendingApproval, blocks
until the UI/server approves or rejects, then resumes — a debugger-style
breakpoint for risky actions, and a differentiator over plain OpenTelemetry:
match build_tool("transfer_funds").wait_for_approval().await? {
trace_weft::HitlResponse::Approved(args) => execute(args).await,
trace_weft::HitlResponse::Rejected(reason) => bail!("rejected: {reason}"),
}Once your application produces traces into .trace-weft/traces.sqlite, inspect
them visually:
trace-weft dev # starts the local axum API on :3000 (local-first)trace-weft dev starts only the API server (port 3000 by default,
local-first auth). To view the React UI in a browser, run it against that API:
npm --prefix apps/web install
npm --prefix apps/web run dev # Vite dev server on :5173, proxies /api → :3000Then open http://localhost:5173 for the Trace List, Span Tree, Waterfall, and
Replay/Diff UI. Alternatively, the desktop app (apps/desktop) bundles the
built UI and embeds the API server in one window.
The UI's API base is import.meta.env.VITE_API_BASE (empty by default → same
-origin /api, which the Vite dev server proxies); the desktop build sets it to
the embedded server's http://127.0.0.1:3000.
The server query endpoints (/api/traces, /api/traces/{id}, /api/evals)
run against either backend, selected by the database URL:
- SQLite (default, local-first) — any path or
sqlite://URL. - Postgres — a
postgres:///postgresql://URL. The schema is created on first connect and the same endpoints serve it with output matching SQLite.
API-key authentication and per-project tenant isolation are configured via environment variables:
TRACE_WEFT_API_KEYS— comma-separatedraw_key:project_idpairs. Keys are hashed (SHA-256) at startup and never stored in the clear; requests authenticate withAuthorization: Bearer <raw_key>. Trace queries are scoped to the key's project, and ingested spans are stamped with it server-side.TRACE_WEFT_DEV_MODE=1— enable the dev bypass (no key required; queries span all tenants). The embedded local server (trace-weft dev, desktop app) defaults this on when no keys are configured; productionstart_serveruse defaults it off, rejecting unauthenticated requests with401.
OTLP/HTTP JSON ingestion (/v1/traces) decodes payloads with the
opentelemetry-proto types, preserving original trace/span/parent IDs and
returning 400 for malformed bodies.
crates/trace-weft- main user-facing SDK facade (builder, macros, events, capture, HITL, replay)crates/trace-weft-core- IDs, schemas, span/event types, redaction traitscrates/trace-weft-macros- proc macros:#[agent],#[tool],#[llm_call]crates/trace-weft-otel- OpenTelemetry export/import bridgecrates/trace-weft-openinference- OpenInference compatibility mappingcrates/trace-weft-recorder- local JSONL/SQLite/blob recorder (sqlitefeature, on by default)crates/trace-weft-ingest- OTLP ingestion viaopentelemetry-proto, preserving original IDscrates/trace-weft-server- axum API, SQLite + Postgres query layer, API-key auth and tenant scopingcrates/trace-weft-cli- CLI: dev, import, export, replayapps/web- React / TypeScript / Vite UI


