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
trace 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 18+
- 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";
const logger = createLogger("my-service");
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 trace span
The Tracer requires a trace 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",
traceClient, // gRPC client for the trace 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";
const logger = createLogger("agent");
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.
Related
- Service Lifecycle -- the full setup guide
- Manage a Service -- start, stop, check