Add Observability
You need to log what a service is doing and trace operations across
service boundaries, but you do not want to configure a logging
framework, pick a format, or wire up an export pipeline.
@forwardimpact/libtelemetry provides three tools that
work out of the box: a Logger that produces RFC
5424-formatted lines, a Tracer that records spans to a
span service, and an Observer that unifies both for
gRPC operations. This page covers the bounded task of adding
observability to a service. For the full lifecycle setup, see
Service Lifecycle.
Prerequisites
- Node.js 22+
- Install the library:
npm install @forwardimpact/libtelemetry
Add a log line
Create a logger with a domain name and call info,
error, or debug:
import { createLogger } from "@forwardimpact/libtelemetry";
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
const logger = createLogger("my-service", createDefaultRuntime());
logger.info("startup", "Server listening", { port: "3000" });
Expected output on stderr:
INFO 2026-05-04T10:00:00.000Z my-service startup 42001 MSG001 [port="3000"] Server listening
The format follows RFC 5424:
LEVEL TIMESTAMP DOMAIN APP_ID PROC_ID MSG_ID [ATTRIBUTES] MESSAGE
Each field is space-separated, making lines greppable. Attributes
appear as key-value pairs inside square brackets. When no attributes
are provided, the field is a single dash (-).
Log levels
Control which methods print with the
LOG_LEVEL environment variable:
LOG_LEVEL |
Methods that print |
|---|---|
error |
error, exception |
info |
error, exception,
info (default)
|
debug |
all methods |
Domain-scoped debug output
Enable debug output for specific domains without changing the global level:
DEBUG=my-service node server.js
Use comma-separated patterns and wildcards:
DEBUG=my-service,grpc:* node server.js
Use DEBUG=* to enable debug output for all domains.
Log errors
Use logger.exception for caught errors — it logs the
message at all levels and appends the stack trace when debug output
is enabled:
logger.exception("db", err, { host: "localhost" });
Add a span
The Tracer requires a span service client and a gRPC
metadata constructor. Once configured, creating a span is a single
call:
import { Tracer } from "@forwardimpact/libtelemetry/tracer.js";
const tracer = new Tracer({
serviceName: "my-service",
spanClient, // gRPC client for the span service
grpcMetadata, // gRPC Metadata constructor
});
const span = tracer.startSpan("processRequest", {
kind: "SERVER",
attributes: { endpoint: "/api/data" },
});
try {
const result = await handleRequest();
span.addEvent("processing_complete", { items: String(result.count) });
span.setOk();
} catch (err) {
span.setError(err);
throw err;
} finally {
await span.end();
}
Trace context propagation
When one service calls another, use startClientSpan for
outgoing calls -- it returns both the span and populated metadata:
const { span, metadata } = tracer.startClientSpan("Vector", "QueryItems", {
resource_id: "doc-123",
});
try {
const response = await vectorClient.queryItems(request, metadata);
span.setOk();
} catch (err) {
span.setError(err);
throw err;
} finally {
await span.end();
}
For incoming calls, startServerSpan extracts trace
context from the request metadata:
const span = tracer.startServerSpan(
"Agent",
"ProcessStream",
call.request,
call.metadata,
);
Observe gRPC operations
The Observer class unifies logging and tracing for gRPC
handlers:
import { createObserver, createLogger } from "@forwardimpact/libtelemetry";
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
const logger = createLogger("agent", createDefaultRuntime());
const observer = createObserver("Agent", logger, tracer);
Observe a server-side unary call:
const response = await observer.observeServerUnaryCall(
"ProcessRequest",
call,
async (call) => {
// Your business logic here
return { result: "done" };
},
);
The observer:
- Logs the incoming request at debug level.
-
Starts a
SERVERspan with trace context from gRPC metadata. - Runs your handler within the span context (for automatic parent propagation).
-
Logs the response and sets the span status to
OK. -
On error, logs the exception, sets the span status to
ERROR, and enriches the error object withtrace_idandspan_idfor correlation.
The same pattern works for streaming calls
(observeServerStreamingCall), outgoing unary calls
(observeClientUnaryCall), and outgoing streaming calls
(observeClientStreamingCall).
When no tracer is configured, the observer falls back to logging only -- no spans are created, and the gRPC calls proceed without trace context. This means you can add the observer first and wire up tracing later without changing your handler code.
Query and visualize recorded spans
Once spans are flowing into the span index,
fit-visualize reads them back and renders them as
Mermaid sequence diagrams. It is a filter-and-query tool: pipe a
JMESPath expression on stdin to
select spans, and it emits a diagram of the service interactions in
those traces.
echo "[?name=='ProcessStream']" | npx fit-visualize
Pass an empty list expression to select every span, then narrow with a filter flag:
echo "[]" | npx fit-visualize --trace 0f53069dbc62d
| Flag | Effect |
|---|---|
--trace |
Restrict to spans whose trace ID matches. |
--resource |
Restrict to spans whose resource ID matches. |
The JMESPath expression and the flags compose: the expression
filters span fields (name, kind,
attributes), and the flags scope the query to a single trace or
resource. For example, select only client spans for one resource:
echo "[?kind==\`2\`]" | npx fit-visualize --resource common.Conversation.abc123
When --resource is set, every matching trace is
combined into one diagram titled by resource ID, with a note marking
each trace boundary -- useful for following one conversation across
several requests. Without it, each trace renders as its own diagram
titled by trace ID.
The output is a fenced Mermaid block ready to paste into any Markdown renderer:
sequenceDiagram
title Trace: 0f53069dbc62d
participant cli
participant agent
cli->>+agent: ProcessStream (time=2026-05-04T10:00:00.000Z)
agent-->>-cli: OK
When no spans match the filter, the command prints
No spans found matching the filter criteria. instead of
a diagram, so an empty result is unambiguous rather than a blank
diagram.
What's next
Manage Service Lifecycle from One Interface
Services that stay running and problems that surface before they escalate — supervision and observability from one interface.
Start, Stop, or Check a Service
Start, stop, restart, check status, and read logs through one interface — without remembering each service's specific incantation.