Skip to main content
Back to Blog
Tutorials
3 min read
December 2, 2024

How to Add OpenTelemetry Tracing to Next.js

Instrument your Next.js app with OpenTelemetry for distributed tracing, request monitoring, and performance insights.

Ryel Banfield

Founder & Lead Developer

OpenTelemetry provides standardized observability for distributed systems. Here is how to add tracing to your Next.js application.

Step 1: Install Dependencies

pnpm add @opentelemetry/sdk-node @opentelemetry/api \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/resources @opentelemetry/semantic-conventions

Step 2: Instrumentation File

Next.js supports OpenTelemetry via the instrumentation hook.

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { NodeSDK } = await import("@opentelemetry/sdk-node");
    const { OTLPTraceExporter } = await import(
      "@opentelemetry/exporter-trace-otlp-http"
    );
    const { Resource } = await import("@opentelemetry/resources");
    const {
      ATTR_SERVICE_NAME,
      ATTR_SERVICE_VERSION,
    } = await import("@opentelemetry/semantic-conventions");
    const { getNodeAutoInstrumentations } = await import(
      "@opentelemetry/auto-instrumentations-node"
    );

    const sdk = new NodeSDK({
      resource: new Resource({
        [ATTR_SERVICE_NAME]: "rcb-software-web",
        [ATTR_SERVICE_VERSION]: "1.0.0",
        environment: process.env.NODE_ENV,
      }),
      traceExporter: new OTLPTraceExporter({
        url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318/v1/traces",
      }),
      instrumentations: [
        getNodeAutoInstrumentations({
          "@opentelemetry/instrumentation-http": { enabled: true },
          "@opentelemetry/instrumentation-fetch": { enabled: true },
        }),
      ],
    });

    sdk.start();

    process.on("SIGTERM", () => {
      sdk.shutdown().catch(console.error);
    });
  }
}

Step 3: Enable in Next.js Config

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    instrumentationHook: true,
  },
};

export default nextConfig;

Step 4: Custom Spans

Add custom spans to track specific operations.

// lib/tracing.ts
import { trace, SpanStatusCode, type Span } from "@opentelemetry/api";

const tracer = trace.getTracer("rcb-software-web");

export function createSpan(name: string) {
  return tracer.startSpan(name);
}

export async function withSpan<T>(
  name: string,
  fn: (span: Span) => 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);
      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();
    }
  });
}

Step 5: Trace Database Queries

// lib/db.ts
import { withSpan } from "./tracing";

export async function findUserById(id: string) {
  return withSpan(
    "db.findUserById",
    async (span) => {
      span.setAttribute("db.system", "postgresql");
      span.setAttribute("db.operation", "SELECT");
      span.setAttribute("db.table", "users");
      span.setAttribute("user.id", id);

      // Your actual query
      const user = await db.select().from(users).where(eq(users.id, id));
      span.setAttribute("db.rows_returned", user.length);

      return user[0] ?? null;
    }
  );
}

Step 6: Trace API Routes

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { withSpan } from "@/lib/tracing";
import { findUserById } from "@/lib/db";

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  return withSpan(
    "api.getUser",
    async (span) => {
      span.setAttribute("http.method", "GET");
      span.setAttribute("user.id", id);

      const user = await findUserById(id);

      if (!user) {
        span.setAttribute("http.status_code", 404);
        return NextResponse.json({ error: "Not found" }, { status: 404 });
      }

      span.setAttribute("http.status_code", 200);
      return NextResponse.json({ user });
    }
  );
}

Step 7: Trace External API Calls

// lib/external.ts
import { withSpan } from "./tracing";

export async function fetchFromExternalAPI(endpoint: string) {
  return withSpan(
    "external.api.request",
    async (span) => {
      span.setAttribute("http.url", endpoint);
      span.setAttribute("http.method", "GET");

      const start = Date.now();
      const response = await fetch(endpoint);
      const duration = Date.now() - start;

      span.setAttribute("http.status_code", response.status);
      span.setAttribute("http.duration_ms", duration);

      if (!response.ok) {
        throw new Error(`External API error: ${response.status}`);
      }

      return response.json();
    }
  );
}

Step 8: Trace Server Components

// app/dashboard/page.tsx
import { withSpan } from "@/lib/tracing";

async function loadDashboardData() {
  return withSpan("dashboard.loadData", async (span) => {
    const [stats, recentActivity, notifications] = await Promise.all([
      withSpan("dashboard.getStats", async () => getStats()),
      withSpan("dashboard.getActivity", async () => getRecentActivity()),
      withSpan("dashboard.getNotifications", async () => getNotifications()),
    ]);

    span.setAttribute("stats.count", Object.keys(stats).length);
    span.setAttribute("activity.count", recentActivity.length);
    span.setAttribute("notifications.count", notifications.length);

    return { stats, recentActivity, notifications };
  });
}

export default async function DashboardPage() {
  const data = await loadDashboardData();

  return (
    <div>
      {/* Render dashboard with traced data */}
    </div>
  );
}

Step 9: Local Development with Jaeger

# docker-compose.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # UI
      - "4318:4318"   # OTLP HTTP
    environment:
      COLLECTOR_OTLP_ENABLED: true
docker compose up -d
# Open http://localhost:16686 for the Jaeger UI

Set your environment variable:

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318

Step 10: Production with Honeycomb

For production, export to Honeycomb or a similar service:

OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=YOUR_API_KEY

Best Practices

  • Name spans descriptively: db.findUserById, api.createOrder
  • Add meaningful attributes for filtering and grouping
  • Record exceptions with span.recordException()
  • Use withSpan for clean, consistent instrumentation
  • Avoid tracing trivial operations that add noise
  • Set sampling rates in production to control costs

Need Observability for Your App?

We implement monitoring, tracing, and alerting to keep your applications healthy. Contact us for production-grade observability.

OpenTelemetrytracingobservabilitymonitoringNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles