OpenTelemetry lets you trace requests across multiple services, revealing the hidden dependencies and bottlenecks in your distributed system.

Let’s see it in action with a simple Next.js app making a call to a hypothetical api service.

First, we need to install the necessary OpenTelemetry packages in our Next.js project:

npm install --save @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/sdk-trace-base @opentelemetry/resources @opentelemetry/semantic-conventions

Next, we’ll create a basic OpenTelemetry configuration file, let’s call it otel.config.ts in the root of our project:

import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-otlp-proto-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'nextjs-app',
  }),
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:4318/v1/traces', // Jaeger or OTLP collector endpoint
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

Now, to ensure this configuration is loaded before our Next.js application starts, we can modify our package.json to use the --require flag:

{
  "name": "my-nextjs-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "node --require ./otel.config.ts node_modules/next/dist/bin/next dev",
    "build": "node --require ./otel.config.ts node_modules/next/dist/bin/next build",
    "start": "node --require ./otel.config.ts node_modules/next/dist/bin/next start",
    "lint": "next lint"
  },
  // ... other configurations
}

With this setup, any outgoing HTTP requests made by Next.js (e.g., to your backend API) will automatically be instrumented. Let’s simulate an API call within a page:

// pages/index.tsx
import { useEffect } from 'react';

function HomePage() {
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('/api/hello'); // This will be traced
        const data = await response.json();
        console.log(data);
      } catch (error) {
        console.error("Error fetching data:", error);
      }
    };
    fetchData();
  }, []);

  return <div>Welcome!</div>;
}

export default HomePage;

And a simple API route in pages/api/hello.ts:

// pages/api/hello.ts
export default function handler(req: any, res: any) {
  // Simulate some work
  setTimeout(() => {
    res.status(200).json({ message: 'Hello from API!' });
  }, 100);
}

When you run npm run dev and visit your homepage, the fetch call to /api/hello will generate a trace. This trace will be sent to your configured OTLP collector (e.g., Jaeger running on http://localhost:4318).

In Jaeger’s UI, you’d see a trace representing the incoming request to your Next.js app, and a child span for the fetch call to /api/hello. If /api/hello itself made further calls to other services, those would appear as further nested spans, building a complete picture of the request’s journey.

The core problem OpenTelemetry solves is the "black box" nature of microservices. Without it, if a request is slow or fails, you’re left guessing which service is at fault. OpenTelemetry provides the visibility to pinpoint the exact operation that’s causing the issue. The getNodeAutoInstrumentations() function is particularly powerful because it automatically injects instrumentation for common Node.js modules like http, fs, and express, meaning you often don’t need to manually wrap every single function call.

The Resource attribute, especially SemanticResourceAttributes.SERVICE_NAME, is crucial for identifying and filtering traces in your observability backend. It’s how you distinguish between traces originating from your "nextjs-app" and, say, a "user-service." The OTLPTraceExporter is the component responsible for serializing and sending these traces over the network to your chosen backend, be it Jaeger, Prometheus Tempo, or a cloud-managed service.

What most people don’t realize is the subtle but critical role of context propagation. When your Next.js app makes a request to another service, OpenTelemetry automatically injects trace context headers (like traceparent and tracestate) into the outgoing request. The receiving service, if also instrumented, reads these headers and continues the existing trace, creating a seamless span lineage. Without this, each service would start a new, unrelated trace, and you’d lose the distributed view.

The next concept you’ll likely explore is adding custom spans for specific business logic within your Next.js code, allowing you to track operations that aren’t automatically covered by the auto-instrumentations.

Want structured learning?

Take the full Nextjs course →