OpenTelemetry
intermediateOpen standard observability with OpenTelemetry. Works with Jaeger, Zipkin, and commercial backends.
monitoringobservabilitytracingopentelemetry
Tested on⬢20▲16⚛19TS5.9
$ bunx sinew add monitoring/opentelemetryInteractive demo coming soon
1The Problem
Vendor-specific observability tools create lock-in:
- Each tool has its own SDK
- Switching providers requires code changes
- No correlation between logs, traces, and metrics
2The Solution
Use OpenTelemetry for vendor-neutral observability that works with any backend.
3Files
lib/telemetry.ts
lib/telemetry.tsTypeScript
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
const resource = new Resource({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "my-app",
[ATTR_SERVICE_VERSION]: process.env.npm_package_version || "0.0.0",
environment: process.env.NODE_ENV,
});
const traceExporter = new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
});
const sdk = new NodeSDK({
resource,
traceExporter,
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false },
}),
],
});
export function startTelemetry() {
sdk.start();
console.info("OpenTelemetry initialized");
process.on("SIGTERM", () => {
sdk.shutdown().then(() => process.exit(0));
});
}lib/tracing.ts
lib/tracing.tsTypeScript
import { trace, SpanStatusCode, context } from "@opentelemetry/api";
const tracer = trace.getTracer("app");
export function withSpan<T>(
name: string,
fn: () => Promise<T>,
attributes?: Record<string, string | number | boolean>
): Promise<T> {
return tracer.startActiveSpan(name, async (span) => {
if (attributes) {
span.setAttributes(attributes);
}
try {
const result = await fn();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : "Unknown error",
});
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}
export function addSpanEvent(name: string, attributes?: Record<string, string>) {
const span = trace.getActiveSpan();
if (span) {
span.addEvent(name, attributes);
}
}
export function setSpanAttribute(key: string, value: string | number | boolean) {
const span = trace.getActiveSpan();
if (span) {
span.setAttribute(key, value);
}
}
export function getTraceId(): string | undefined {
const span = trace.getActiveSpan();
return span?.spanContext().traceId;
}instrumentation.ts
instrumentation.tsTypeScript
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startTelemetry } = await import("./lib/telemetry");
startTelemetry();
}
}lib/data/users.ts
lib/data/users.tsTypeScript
import { prisma } from "@/lib/db";
import { withSpan, addSpanEvent } from "@/lib/tracing";
export async function getUser(id: string) {
return withSpan(
"getUser",
async () => {
addSpanEvent("database.query.start");
const user = await prisma.user.findUnique({ where: { id } });
addSpanEvent("database.query.end", { found: user ? "true" : "false" });
return user;
},
{ "user.id": id }
);
}
export async function createUser(data: { name: string; email: string }) {
return withSpan(
"createUser",
async () => {
const user = await prisma.user.create({ data });
addSpanEvent("user.created", { userId: user.id });
return user;
},
{ "user.email": data.email }
);
}app/api/users/[id]/route.ts
app/api/users/[id]/route.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import { getUser } from "@/lib/data/users";
import { withSpan, getTraceId } from "@/lib/tracing";
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withSpan(
"GET /api/users/[id]",
async () => {
const user = await getUser(id);
if (!user) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(user, {
headers: { "x-trace-id": getTraceId() || "" },
});
},
{ "http.route": "/api/users/[id]", "user.id": id }
);
}.env.example
.env.exampleBash
# OpenTelemetry Configuration
OTEL_SERVICE_NAME="my-app"
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
# For Honeycomb
# OTEL_EXPORTER_OTLP_ENDPOINT="https://api.honeycomb.io"
# OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=your-api-key"4Dependencies
$ bun add @opentelemetry/api @opentelemetry/sdk-node5Configuration
Local Development with Jaeger
# Run Jaeger
docker run -d --name jaeger \
-p 16686:16686 \
-p 4318:4318 \
jaegertracing/all-in-one:latestBash
Then visit http://localhost:16686 to view traces.
Honeycomb Setup
- Create an account at honeycomb.io
- Get your API key
- Set environment variables:
OTEL_EXPORTER_OTLP_ENDPOINT="https://api.honeycomb.io"
OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=your-api-key"Bash
6Usage
Manual Spans
import { withSpan, addSpanEvent, setSpanAttribute } from "@/lib/tracing";
const result = await withSpan("processOrder", async () => {
setSpanAttribute("order.id", orderId);
addSpanEvent("validation.start");
await validateOrder(order);
addSpanEvent("validation.complete");
addSpanEvent("payment.start");
await processPayment(order);
addSpanEvent("payment.complete");
return { success: true };
});TypeScript
Correlating Logs with Traces
import { getTraceId } from "@/lib/tracing";
import { logger } from "@/lib/logger";
logger.info({ traceId: getTraceId(), event: "order_created" });TypeScript
7Troubleshooting
Traces not appearing
- Verify the OTLP endpoint is reachable
- Check that instrumentation.ts is in your project root
- Ensure NEXT_RUNTIME check is correct
Missing spans
- Wrap async operations with
withSpan - Check that auto-instrumentation is enabled
- Verify the span is ended with
span.end()