Collect Trace Spans from Any Product
You are building a product that generates trace spans -- recording what an agent did, how long each step took, and whether it succeeded -- and you need those spans stored somewhere queryable. Managing per-product trace files means each product reinvents storage, indexing, and query logic. The trace gRPC service accepts spans from any product, stores them in a shared JSONL-backed index, and serves them back through a query interface. Your product sends a span; the service handles persistence and retrieval.
This guide walks through connecting to the trace service, recording a span, querying it back, and verifying the round trip works.
Prerequisites
- Node.js 18+
-
Generated client code available (run
npx fit-codegen --allif not) -
The trace service running (
npx fit-rc startorjust guide)
Install the transport and type packages:
npm install @forwardimpact/librpc @forwardimpact/libtype
Architecture overview
The trace service owns two RPCs:
| RPC | Purpose | Request type | Response type |
|---|---|---|---|
RecordSpan |
Store a span in the trace index | trace.Span |
trace.RecordResponse |
QuerySpans |
Retrieve spans by query or filter | trace.QueryRequest |
trace.QueryResponse |
The service stores spans in a TraceIndex backed by a
JSONL file at data/traces/index.jsonl. The index is
append-only during the service lifetime and flushed on shutdown.
Product A ──┐ ┌── data/traces/index.jsonl
├── gRPC ── trace ──┤
Product B ──┘ └── (query interface)
The trace service intentionally does not trace itself -- connecting a tracer to a service that records traces would create infinite recursion.
Connect to the trace service
Create a trace client. Because the trace service cannot use distributed tracing internally, the client connection is simpler than other services:
import { createClient } from "@forwardimpact/librpc";
import { createLogger } from "@forwardimpact/libtelemetry";
const logger = createLogger("my-product");
const traceClient = await createClient("trace", logger);
Record a span
Build a trace.Span message and call
RecordSpan. Every span requires a
trace_id and span_id:
import { trace } from "@forwardimpact/libtype";
const span = trace.Span.fromObject({
trace_id: "abc123",
span_id: "span-001",
parent_span_id: "",
name: "evaluate-agent-output",
kind: 1, // INTERNAL
start_time_unix_nano: BigInt(Date.now()) * 1_000_000n,
end_time_unix_nano: BigInt(Date.now() + 1500) * 1_000_000n,
attributes: {
"agent.name": "staff-engineer",
"eval.verdict": "pass",
},
events: [],
status: { code: 1, message: "" }, // OK
resource: {
attributes: {
"service.name": "my-product",
},
},
});
const result = await traceClient.RecordSpan(span);
console.log("Recorded:", result.success);
Expected output:
Recorded: true
Span fields
| Field | Required | Description |
|---|---|---|
trace_id |
yes | Groups related spans into a single trace |
span_id |
yes | Unique identifier for this span |
parent_span_id |
no | Links to the parent span in the same trace |
name |
no | Human-readable operation name |
kind |
no |
INTERNAL (1), SERVER (2), or
CLIENT (3)
|
start_time_unix_nano |
no | Start time as nanoseconds since epoch |
end_time_unix_nano |
no | End time as nanoseconds since epoch |
attributes |
no | Key-value pairs for metadata |
events |
no | Timestamped events within the span |
status |
no |
UNSET (0), OK (1), or
ERROR (2) with message
|
resource |
no | Resource attributes (service name, version) |
Add events to a span
Events mark points of interest within a span:
const spanWithEvents = trace.Span.fromObject({
trace_id: "abc123",
span_id: "span-002",
parent_span_id: "span-001",
name: "analyze-trace",
kind: 1,
start_time_unix_nano: BigInt(Date.now()) * 1_000_000n,
end_time_unix_nano: BigInt(Date.now() + 3000) * 1_000_000n,
events: [
{
name: "observation-coded",
time_unix_nano: BigInt(Date.now() + 1000) * 1_000_000n,
attributes: { "code": "tool-retry", "count": "3" },
},
{
name: "finding-written",
time_unix_nano: BigInt(Date.now() + 2500) * 1_000_000n,
attributes: { "finding.severity": "medium" },
},
],
status: { code: 1, message: "" },
resource: {
attributes: { "service.name": "my-product" },
},
});
await traceClient.RecordSpan(spanWithEvents);
Query spans
Retrieve spans using QuerySpans. You can query by text,
trace ID, or resource ID -- at least one must be provided:
By trace ID
const queryByTrace = trace.QueryRequest.fromObject({
filter: { trace_id: "abc123" },
});
const result = await traceClient.QuerySpans(queryByTrace);
console.log("Spans found:", result.spans?.length ?? 0);
for (const span of result.spans ?? []) {
console.log(` ${span.name} (${span.span_id})`);
}
Expected output:
Spans found: 2
evaluate-agent-output (span-001)
analyze-trace (span-002)
By resource ID
const queryByResource = trace.QueryRequest.fromObject({
filter: { resource_id: "my-product" },
});
const result = await traceClient.QuerySpans(queryByResource);
console.log("Spans from my-product:", result.spans?.length ?? 0);
By text query
const queryByText = trace.QueryRequest.fromObject({
query: "evaluate",
});
const result = await traceClient.QuerySpans(queryByText);
console.log("Matching spans:", result.spans?.length ?? 0);
Combine query and filter
const combined = trace.QueryRequest.fromObject({
query: "evaluate",
filter: { trace_id: "abc123" },
});
const result = await traceClient.QuerySpans(combined);
Build a trace tree
Spans reference their parent via parent_span_id. To
reconstruct the tree structure from a query result:
function buildTree(spans) {
const byId = new Map(spans.map((s) => [s.span_id, s]));
const roots = [];
for (const span of spans) {
if (!span.parent_span_id || !byId.has(span.parent_span_id)) {
roots.push(span);
}
}
function children(parentId) {
return spans.filter((s) => s.parent_span_id === parentId);
}
function print(span, depth = 0) {
const indent = " ".repeat(depth);
const durationMs = Number(
(BigInt(span.end_time_unix_nano) - BigInt(span.start_time_unix_nano))
/ 1_000_000n
);
console.log(`${indent}${span.name} (${durationMs}ms)`);
for (const child of children(span.span_id)) {
print(child, depth + 1);
}
}
for (const root of roots) {
print(root);
}
}
const result = await traceClient.QuerySpans(
trace.QueryRequest.fromObject({ filter: { trace_id: "abc123" } })
);
buildTree(result.spans ?? []);
Expected output:
evaluate-agent-output (1500ms)
analyze-trace (3000ms)
Verify
You have reached the outcome of this guide when:
-
createClient("trace")connects without error. -
RecordSpanwith a validtrace_idandspan_idreturns{ success: true }. -
QuerySpanswith the sametrace_idreturns the recorded spans. - Span attributes, events, and status are preserved in the round trip.
If the connection fails, confirm the trace service is running with
npx fit-rc status. If RecordSpan fails,
check that both trace_id and span_id are
non-empty strings.
What's next
- Send Spans from a Product -- a focused walkthrough of the most common bounded task: emitting spans and verifying they are queryable.
-
Trace Analysis --
the library guide for analyzing traces as qualitative research
with
fit-trace.