Your Next.js app is in production. Users are complaining about slow checkouts. Your error tracker shows nothing. Your logs show nothing. The request just... takes forever, sometimes. This is the blind spot that observability is designed to eliminate — and logging alone will never close it.

Observability is not monitoring. Monitoring tells you something is wrong. Observability tells you why — by letting you ask arbitrary questions about your system's internal state from its external outputs. The three pillars that make this possible are traces, metrics, and logs, and OpenTelemetry (OTel) is the open standard that unifies all three without tying you to any single vendor.

Why Next.js Needs More Than an Error Tracker

Next.js applications are deceptively complex at runtime. A single user request can touch a Server Component render, a Route Handler, an Edge middleware function, a database query, and one or two third-party API calls — all in sequence or in parallel. An error tracker captures uncaught exceptions. A logger captures whatever you remember to log. Neither tool shows you the shape of that request across all those hops, how long each segment took, or where a cascade of slow database calls is hiding under an acceptable average response time.

Distributed tracing solves this by attaching a unique trace ID to every request and propagating it across every function, service, and external call it touches. The result is a flame graph — a visual timeline of exactly what your system did and how long each piece took.

Setting Up the OpenTelemetry SDK in Next.js

Next.js has first-class support for OpenTelemetry via its instrumentation.ts file convention. This file runs once when the server starts and is the correct place to bootstrap your OTel SDK.

Install the required packages:

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

Create instrumentation.ts at the project root:

import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';

export function register() {
  const sdk = new NodeSDK({
    resource: new Resource({
      [SEMRESATTRS_SERVICE_NAME]: 'my-nextjs-app',
      [SEMRESATTRS_SERVICE_VERSION]: process.env.APP_VERSION ?? '1.0.0',
    }),
    traceExporter: new OTLPTraceExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/traces',
      headers: {
        Authorization: `Bearer ${process.env.GRAFANA_CLOUD_TOKEN}`,
      },
    }),
    metricReader: new PeriodicExportingMetricReader({
      exporter: new OTLPMetricExporter({
        url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/metrics',
        headers: {
          Authorization: `Bearer ${process.env.GRAFANA_CLOUD_TOKEN}`,
        },
      }),
      exportIntervalMillis: 15000,
    }),
    instrumentations: [getNodeAutoInstrumentations()],
  });

  sdk.start();
}

The register() function is called automatically by Next.js when the instrumentation experimental feature is enabled in next.config.js:

// next.config.js
module.exports = {
  experimental: {
    instrumentationHook: true,
  },
};

getNodeAutoInstrumentations() is doing substantial work here. It automatically patches http, fetch, dns, popular database clients (Prisma, pg, mongoose), and more — so you get spans for those calls without a single line of manual instrumentation.

Wiring Up Grafana Cloud as the Backend

Grafana Cloud's free tier supports up to 50 GB of traces, 10,000 metrics series, and 50 GB of logs per month — more than enough for a production SaaS at early scale. Crucially, it accepts data over the OpenTelemetry Protocol (OTLP), which means your instrumentation is 100% portable. Swap Grafana Cloud for Honeycomb, Tempo self-hosted, or Jaeger tomorrow with nothing more than an environment variable change.

To connect:

  1. Create a free Grafana Cloud account and navigate to My Account → Stack → Configure OpenTelemetry.
  2. Copy the OTLP endpoint (it looks like https://otlp-gateway-prod-<region>.grafana.net/otlp).
  3. Generate an API token with MetricsPublisher and TracesPublisher scopes.
  4. Set OTEL_EXPORTER_OTLP_ENDPOINT and GRAFANA_CLOUD_TOKEN in your environment.

Your traces will appear in Grafana Tempo, metrics in Grafana Mimir, and logs (when forwarded via a Loki-compatible exporter) in Grafana Loki — all queryable together in a single Grafana dashboard.

Adding Custom Spans for Business Logic

Auto-instrumentation handles infrastructure. For business logic — a payment flow, a complex data transformation, a multi-step form submission — you want custom spans.

import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('checkout-service');

export async function processOrder(orderId: string) {
  return tracer.startActiveSpan('process-order', async (span) => {
    try {
      span.setAttribute('order.id', orderId);
      const result = await runOrderPipeline(orderId);
      span.setAttribute('order.status', result.status);
      return result;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    } finally {
      span.end();
    }
  });
}

The setAttribute calls are what turn a generic span into a queryable signal. You can filter all traces where order.status = failed or order.id = X directly in Grafana Tempo's TraceQL query language.

Correlating Logs to Traces

The final piece of the puzzle is log correlation. A log line that includes its parent trace ID stops being an isolated event and becomes a node in the full request graph.

If you use a structured logger like pino, you can pull the active trace context and inject it:

import { context, trace } from '@opentelemetry/api';

function getTraceContext() {
  const span = trace.getActiveSpan();
  if (!span) return {};
  const { traceId, spanId } = span.spanContext();
  return { traceId, spanId };
}

// Merge into every log call
logger.info({ ...getTraceContext(), message: 'Order processed' });

When these logs are shipped to Loki, Grafana can automatically pivot from a log line to the full trace that produced it — and back.

Observability Gaps This Eliminates

Once the stack is running, you gain the ability to:

  • Pinpoint slow database queries by filtering traces where db.statement duration exceeds a threshold.
  • Identify N+1 query patterns in React Server Components — a common Next.js performance trap that never shows up in error trackers.
  • Correlate a spike in p99 latency to a specific deployment by comparing trace timelines before and after a release.
  • Debug user-reported issues in production using their session's trace ID without needing to reproduce the problem locally.

These are questions that logs and error trackers structurally cannot answer. Traces can.

Why This Matters for Your Project

If you are building a SaaS product or a client-facing platform on Next.js, instrumentation is not a "nice to have" you add after scale. The cheapest time to wire in observability is before the first user complains about something you cannot reproduce. OpenTelemetry gives you a vendor-neutral foundation that grows with your stack — whether you add microservices, swap your database, or migrate to a different cloud provider. The investment is a few hours of setup; the return is the ability to debug production with confidence rather than guesswork.