Skip to content

Distributed Tracing

Stoma includes a lightweight OpenTelemetry-compatible tracing system designed for edge runtimes. It uses the OTel data model (traces, spans, attributes, events) and exports via OTLP/HTTP JSON using fetch() — no gRPC, no protobuf, no Node.js dependencies. When tracing is not configured, there is zero overhead.

Add a tracing configuration to your GatewayConfig:

import { createGateway } from "@homegrower-club/stoma";
import { OTLPSpanExporter } from "@homegrower-club/stoma";
const gateway = createGateway({
name: "my-api",
tracing: {
exporter: new OTLPSpanExporter({
endpoint: "https://otel-collector.internal:4318/v1/traces",
}),
serviceName: "my-api-gateway",
serviceVersion: "1.0.0",
sampleRate: 0.1, // Sample 10% of requests
},
routes: [/* ... */],
});
FieldTypeDescription
exporterSpanExporterRequired. Where to send completed spans.
serviceNamestringService name set on the OTel resource. Default: "stoma-gateway".
serviceVersionstringService version set on the OTel resource. Optional.
sampleRatenumberHead-based sampling rate from 0.0 (none) to 1.0 (all). Default: 1.0.

Ships spans to any OTLP-compatible collector (Jaeger, Grafana Tempo, Honeycomb, Datadog OTLP endpoint, etc.) using the OTLP/HTTP JSON encoding via fetch().

import { OTLPSpanExporter } from "@homegrower-club/stoma";
const exporter = new OTLPSpanExporter({
endpoint: "https://otel-collector.internal:4318/v1/traces",
headers: {
Authorization: "Bearer <token>",
},
timeoutMs: 5000,
serviceName: "my-api",
serviceVersion: "2.1.0",
});
FieldTypeDescription
endpointstringRequired. OTLP collector URL (e.g. https://collector:4318/v1/traces).
headersRecord<string, string>Custom headers sent with each export request. Default: {}.
timeoutMsnumberTimeout for the export fetch call. Default: 10000.
serviceNamestringOverrides TracingConfig.serviceName for this exporter. Default: "stoma-gateway".
serviceVersionstringOverrides TracingConfig.serviceVersion for this exporter. Optional.

Logs each span to console.debug() in a compact one-line format. Useful for local development and debugging.

import { ConsoleSpanExporter } from "@homegrower-club/stoma";
const gateway = createGateway({
tracing: {
exporter: new ConsoleSpanExporter(),
},
routes: [/* ... */],
});

Output format:

[trace] GET /api/users SERVER 42ms trace=abcdef... span=123456... status=OK
[trace] jwt-auth INTERNAL 2ms trace=abcdef... span=789abc... parent=123456... status=OK

Run Jaeger with OTLP ingestion and point the exporter at it:

Terminal window
# Start Jaeger with OTLP receiver (Docker)
docker run -d --name jaeger \
-p 16686:16686 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
import { createGateway, OTLPSpanExporter, cors, rateLimit } from "@homegrower-club/stoma";
const gateway = createGateway({
name: "my-api",
tracing: {
exporter: new OTLPSpanExporter({
endpoint: "http://localhost:4318/v1/traces",
}),
serviceName: "my-api-gateway",
sampleRate: 1.0, // Trace every request in development
},
policies: [cors(), rateLimit({ max: 100 })],
routes: [
{
path: "/api/*",
pipeline: {
upstream: { type: "url", target: "https://api.internal" },
},
},
],
});
export default gateway.app;

Open http://localhost:16686 to view traces in the Jaeger UI.

Each completed span is represented as a ReadableSpan:

interface ReadableSpan {
traceId: string; // 32-hex trace ID (shared across all spans in a trace)
spanId: string; // 16-hex span ID (unique per span)
parentSpanId?: string; // Parent span ID (absent for root spans)
name: string; // Span name (e.g. "GET /api/users", "jwt-auth")
kind: SpanKind; // "SERVER" | "CLIENT" | "INTERNAL"
startTimeMs: number; // Unix timestamp in milliseconds
endTimeMs: number; // Unix timestamp in milliseconds
attributes: Record<string, string | number | boolean>;
status: { code: SpanStatusCode; message?: string };
events: SpanEvent[]; // Timestamped events recorded during the span
}

Stoma uses the stable HTTP semantic convention attribute keys from the OpenTelemetry specification. The SemConv object provides named constants:

ConstantValueDescription
SemConv.HTTP_METHOD"http.request.method"HTTP request method (GET, POST, etc.)
SemConv.HTTP_ROUTE"http.route"Matched route pattern (e.g. /users/:id)
SemConv.HTTP_STATUS_CODE"http.response.status_code"HTTP response status code
SemConv.URL_PATH"url.path"Full request URL path
SemConv.SERVER_ADDRESS"server.address"Server hostname

The gateway creates a hierarchy of spans for each request. See Architecture for the full request lifecycle.

Root span (SERVER) — Created by the context injector at the start of each request. Covers the entire gateway processing time including all policies and the upstream call.

Policy spans (INTERNAL) — One child span per policy in the pipeline. Created automatically by the policy middleware wrapper. Records the policy name, priority, and execution duration.

Upstream span (CLIENT) — Created when dispatching to a URL or Service Binding upstream. The gateway propagates the W3C traceparent header with a fresh span ID so the upstream service can continue the trace.

[SERVER] GET /api/users (42ms)
├─ [INTERNAL] cors (0ms)
├─ [INTERNAL] jwt-auth (3ms)
├─ [INTERNAL] rate-limit (1ms)
└─ [CLIENT] upstream: https://api.internal (35ms)
└─ (continued by upstream service via traceparent header)

Head-based sampling is controlled by TracingConfig.sampleRate. The decision is made once per request before any spans are created.

sampleRateBehavior
1.0 (default)Every request is traced.
0.5Approximately 50% of requests are traced.
0.1Approximately 10% of requests are traced.
0.0No requests are traced (tracing disabled).

When a request is not sampled, no SpanBuilder instances are created and no export calls are made. The overhead for unsampled requests is a single Math.random() comparison.

Implement the SpanExporter interface to send spans to any backend:

import type { SpanExporter, ReadableSpan } from "@homegrower-club/stoma";
class DatadogSpanExporter implements SpanExporter {
private readonly apiKey: string;
private readonly endpoint: string;
constructor(config: { apiKey: string; site?: string }) {
this.apiKey = config.apiKey;
this.endpoint = `https://trace.agent.${config.site ?? "datadoghq.com"}/api/v0.2/traces`;
}
async export(spans: ReadableSpan[]): Promise<void> {
if (spans.length === 0) return;
// Transform ReadableSpan[] to Datadog's trace format
const ddSpans = spans.map((span) => ({
trace_id: span.traceId,
span_id: span.spanId,
parent_id: span.parentSpanId,
name: span.name,
start: span.startTimeMs * 1_000_000, // nanoseconds
duration: (span.endTimeMs - span.startTimeMs) * 1_000_000,
meta: Object.fromEntries(
Object.entries(span.attributes)
.filter(([, v]) => typeof v === "string")
),
error: span.status.code === "ERROR" ? 1 : 0,
}));
await fetch(this.endpoint, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"DD-API-KEY": this.apiKey,
},
body: JSON.stringify([[...ddSpans]]),
signal: AbortSignal.timeout(10_000),
});
}
async shutdown(): Promise<void> {
// Flush any buffered spans if needed
}
}

The shutdown() method is optional. If implemented, it is called when the gateway is shutting down to allow final span flushing.

Span export happens asynchronously. On Cloudflare Workers, the exporter’s export() call is dispatched via adapter.waitUntil() so it does not block the response. Configure a runtime adapter to enable this:

import { createGateway, OTLPSpanExporter } from "@homegrower-club/stoma";
import { cloudflareAdapter } from "@homegrower-club/stoma/adapters/cloudflare";
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const gateway = createGateway({
adapter: cloudflareAdapter({ ctx }),
tracing: {
exporter: new OTLPSpanExporter({
endpoint: env.OTEL_ENDPOINT,
headers: { Authorization: `Bearer ${env.OTEL_TOKEN}` },
}),
sampleRate: 0.1,
},
routes: [/* ... */],
});
return gateway.app.fetch(request, env, ctx);
},
};

Without waitUntil, span export still works but blocks the response until the export fetch completes.