OpenTelemetry

intermediate

Open standard observability with OpenTelemetry. Works with Jaeger, Zipkin, and commercial backends.

monitoringobservabilitytracingopentelemetry
Tested on201619TS5.9
$ bunx sinew add monitoring/opentelemetry
Interactive 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-node

5Configuration

Local Development with Jaeger

# Run Jaeger
docker run -d --name jaeger \
  -p 16686:16686 \
  -p 4318:4318 \
  jaegertracing/all-in-one:latest
Bash

Then visit http://localhost:16686 to view traces.

Honeycomb Setup

  1. Create an account at honeycomb.io
  2. Get your API key
  3. 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()

Related patterns