Skip to content

Observability

Bridge ships with first-class observability built on four pillars:

  1. OpenTelemetry spans — every tool call produces a bridge.tool span via the standard @opentelemetry/api. Zero-overhead no-ops when no OTel SDK is registered.
  2. OpenTelemetry metrics — counters and duration histograms for every tool call, also via @opentelemetry/api.
  3. Structured logging — a pluggable Logger interface routes engine-level events (completions, errors, warnings) to any compatible logger.
  4. GraphQL extensions.traces — structured per-request traces returned in the GraphQL extensions field for debugging and testing (opt-in).

Bridge instruments every tool invocation using the standard @opentelemetry/api package. No additional configuration is required inside Bridge itself — you only need to register an OTel SDK in your application.

Each tool call produces one span named bridge.tool with these attributes:

AttributeValue
bridge.tool.nameTool name as resolved by the engine (e.g. "geocoder", "std.str.toUpperCase")
bridge.tool.fnRegistered function that was called (e.g. "httpCall", "upperCase")

On error the span status is set to ERROR and the exception is recorded with span.recordException().

The following instruments are registered under the @stackables/bridge meter:

MetricTypeUnitDescription
bridge.tool.callsCounterTotal number of tool invocations (success + error)
bridge.tool.durationHistogrammsTool call wall-clock duration in milliseconds
bridge.tool.errorsCounterTotal number of tool invocations that threw

All instruments carry the same bridge.tool.name and bridge.tool.fn attribute set as spans, so they can be filtered and grouped the same way.

import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter(),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
}),
});
sdk.start();

Once the SDK is running, every bridge.tool span and metric is automatically exported to your configured backend (Jaeger, Zipkin, Grafana Tempo/Mimir, etc.). No changes to Bridge configuration are needed.


Pass any logger that implements the four-method Logger interface:

import { bridgeTransform } from "@stackables/bridge";
import type { Logger } from "@stackables/bridge";
const schema = bridgeTransform(baseSchema, instructions, {
tools,
logger: console, // console works out of the box
// logger: pinoInstance, // pino, winston, bunyan — any compatible logger
});
interface Logger {
debug: (...args: any[]) => void;
info: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
}

When logger is omitted (default), all methods are silent no-ops — zero output and zero overhead.

LevelEvent
debugSuccessful tool completion: tool name, fn, duration
errorTool invocation failure: tool name, fn, error message
warnEngine-level warnings (e.g. accessing a field on an array without pickFirst)

Pass trace to also populate the GraphQL response extensions.traces array (useful for debugging and per-request visibility in tests or developer tooling):

import { bridgeTransform, useBridgeTracing } from "@stackables/bridge";
const schema = bridgeTransform(baseSchema, instructions, {
tools,
trace: "full", // records tool, fn, input, output, timing
// trace: "basic", // records tool, fn, timing, error (no input/output)
});

Then register the companion Yoga plugin so traces are surfaced in the response:

import { createYoga } from "graphql-yoga";
const yoga = createYoga({
schema,
plugins: [useBridgeTracing()],
});

Zero overhead when disabled — when trace is omitted or "off", no collector is created and the hot path is not touched.

OTel spans and metrics are emitted regardless of the trace option; they become no-ops automatically when no OTel SDK is registered.

When extensions.traces is active, the GraphQL response includes:

{
"data": { "lookup": { "label": "Berlin, DE" } },
"extensions": {
"traces": [
{
"tool": "geocoder",
"fn": "httpCall",
"input": {
"q": "Berlin",
"baseUrl": "https://api.example.com",
"method": "GET",
"path": "/geocode"
},
"output": { "label": "Berlin, DE" },
"durationMs": 42.5,
"startedAt": 0.12
}
]
}
}
ValueRecords
"full"tool, fn, input, output, error, timing
"basic"tool, fn, error, timing (no input/output — lighter payload)
"off" (default)nothing — zero overhead
FieldTypeDescription
toolstringTool name as resolved by the engine (e.g. "geocoder", "std.str.toUpperCase")
fnstringThe registered function that was called (e.g. "httpCall", "upperCase")
inputobjectInput object passed to the tool function, after all wire resolution ("full" only)
outputanyReturn value of the tool — present on success ("full" only)
errorstringError message (present when the tool threw)
durationMsnumberWall-clock execution time in milliseconds
startedAtnumberTimestamp (ms) relative to the first trace in the request

A trace always has either output or error, never both.


The engine instruments all tool invocations via all three pillars:

  • Bridge-wired tools — tools scheduled via with <tool> as <alias> in bridge blocks
  • Tool-def tools — tools defined in tool <name> from <fn> { … } blocks (traced with both the tool name and the underlying fn)
  • Direct tool functions — namespace tools like std.str.toUpperCase
  • Error paths — when a tool throws, the span is marked ERROR, the error counter increments, and logger.error fires; if a catch fallback or on error handler fires, the fallback tool gets its own instrumentation

extensions.traces entries appear in completion order. For sequential || chains, short-circuited tools will be absent:

o.label <- primary.label || backup.label

If primary returns a non-null label, only one trace is recorded.


import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { bridgeTransform, useBridgeTracing } from "@stackables/bridge";
import pino from "pino";
// 1. Start OTel SDK (spans + metrics → your backend)
new NodeSDK({
traceExporter: new OTLPTraceExporter(),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
}),
}).start();
// 2. Configure Bridge with logger + trace
const schema = bridgeTransform(baseSchema, instructions, {
tools,
logger: pino(), // structured log output
trace: "basic", // lightweight per-request traces in extensions
});
// 3. Register Yoga plugin for extensions.traces
const yoga = createYoga({ schema, plugins: [useBridgeTracing()] });

For non-Yoga setups (or middleware), read traces directly from the GraphQL context:

import { getBridgeTraces } from "@stackables/bridge";
const traces = getBridgeTraces(context);

The test helper createGateway supports the trace option:

const gateway = createGateway(typeDefs, instructions, {
tools: { geocoder: mockGeocoder },
trace: "full",
});
const executor = buildHTTPExecutor({ fetch: gateway.fetch });
const result = await executor({ document: parse(query) });
const traces = result.extensions.traces;
assert.equal(traces.length, 1);
assert.equal(traces[0].tool, "geocoder");
assert.deepStrictEqual(traces[0].input, { q: "Berlin" });

All observability types are exported from the package:

import type { Logger, ToolTrace, TraceLevel } from "@stackables/bridge";

TraceLevel is "basic" | "full" | "off".