6 min read

Type‑Safe Observability in Next.js: Harnessing OpenTelemetry with TypeScript

Learn how to add fully typed tracing, metrics, and logs to a Next.js app using OpenTelemetry and TypeScript—no magic strings, just compile‑time safety.
Type‑Safe Observability in Next.js: Harnessing OpenTelemetry with TypeScript

Introduction

Observability is the backbone of reliable web applications. In a Next.js project you typically juggle API routes, Server‑Side Rendering (SSR), and Edge Functions—each with its own execution context. Adding tracing, metrics, and logs is straightforward with OpenTelemetry, but the default JavaScript SDK leaves you with untyped strings for span names, attribute keys, and metric labels.

When those strings drift out of sync with your domain model, you get runtime errors that are hard to trace (pun intended). By leveraging TypeScript’s type system we can capture observability contracts at compile time, turning “nice‑to‑have” logs into a first‑class part of the codebase.

This article walks through a practical, end‑to‑end setup:

  1. Install and configure OpenTelemetry in a Next.js app.
  2. Define typed attribute schemas for spans and metrics.
  3. Wrap the SDK with helper functions that enforce those schemas.
  4. Instrument a real‑world checkout API route.
  5. Propagate context across serverless/edge boundaries.

No marketing fluff—just code you can copy into your own project.


1. OpenTelemetry Primer for TypeScript

OpenTelemetry defines three core signals:

Signal Purpose Primary API
Traces End‑to‑end request flow TracerSpan
Metrics Quantitative health data MeterCounter, Histogram
Logs Unstructured events (optional) Logger (experimental)

All three expose generic interfaces that can be typed. The @opentelemetry/api package ships with TypeScript definitions, but they are deliberately permissive:

span.setAttribute('userId', 123); // attribute key is a plain string

Our goal is to replace those loose strings with typed constants that the compiler can verify.


2. Project Setup

# Core OpenTelemetry packages
npm i @opentelemetry/api @opentelemetry/sdk-node \
        @opentelemetry/instrumentation-http \
        @opentelemetry/instrumentation-express \
        @opentelemetry/exporter-trace-otlp-http \
        @opentelemetry/exporter-metrics-otlp-http

# Next.js (if not already present)
npm i next react react-dom

Create a file otel.ts at the project root. This module will bootstrap the SDK once, even when Next.js hot‑reloads.

// otel.ts
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';

// Enable debug logging while developing
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);

// Tracer provider
const provider = new NodeTracerProvider();
const exporter = new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT });
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
provider.register();

// Auto‑instrument HTTP & Express (used by Next.js API routes)
registerInstrumentations({
  instrumentations: [new HttpInstrumentation(), new ExpressInstrumentation()],
});

export const tracer = provider.getTracer('nextjs-app');

Import tracer wherever you need a span. The rest of the article shows how to type‑guard its usage.


3. Defining Typed Attribute Schemas

3.1. Attribute Interfaces

Create a central file observability/types.ts:

// observability/types.ts
export interface CheckoutSpanAttributes {
  /** Unique order identifier */
  orderId: string;
  /** Authenticated user identifier */
  userId: string;
  /** Total amount in cents */
  amountCents: number;
  /** Payment method (enum) */
  paymentMethod: 'card' | 'paypal' | 'apple_pay';
  /** Whether the checkout succeeded */
  success: boolean;
}

/** Metric label set for request counters */
export interface ApiRequestLabels {
  route: string;          // e.g. "/api/checkout"
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  statusCode: number;
}

These interfaces are source‑of‑truth for all observability data related to the checkout flow.

3.2. Helper to Enforce Types

// observability/helpers.ts
import { Span, SpanOptions, trace } from '@opentelemetry/api';
import { CheckoutSpanAttributes, ApiRequestLabels } from './types';
import { tracer } from '../otel';

/**
 * Creates a span whose attributes are constrained by `Attrs`.
 */
export function startTypedSpan<Attrs extends object>(
  name: string,
  options?: SpanOptions,
  attrs?: Attrs
): Span {
  const span = tracer.startSpan(name, options);
  if (attrs) {
    // `Object.entries` preserves key types, but we cast to any because
    // OpenTelemetry expects `string | number | boolean`.
    span.setAttributes(attrs as Record<string, unknown>);
  }
  return span;
}

/**
 * Typed metric counter wrapper.
 */
export function incRequestCounter(labels: ApiRequestLabels, increment = 1) {
  // Lazy‑load the meter to avoid circular imports
  const { meter } = require('../otelMetrics') as typeof import('../otelMetrics');
  const counter = meter.createCounter('api_requests_total', {
    description: 'Number of API requests',
  });
  counter.add(increment, labels);
}

The generic startTypedSpan forces the caller to supply an object that satisfies the chosen attribute interface. If you accidentally miss a key or use the wrong type, TypeScript will raise an error before the code runs.


4. Typed Metrics Setup

Create otelMetrics.ts:

// otelMetrics.ts
import { MeterProvider } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';

const exporter = new OTLPMetricExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
});

const metricReader = new PeriodicExportingMetricReader({
  exporter,
  exportIntervalMillis: 60000,
});

export const meterProvider = new MeterProvider();
meterProvider.addMetricReader(metricReader);
export const meter = meterProvider.getMeter('nextjs-metrics');

Now you have a typed meter that can be used throughout the app.


5. Real‑World Example: Checkout API Route

File: pages/api/checkout.ts

import type { NextApiRequest, NextApiResponse } from 'next';
import { startTypedSpan } from '../../observability/helpers';
import { CheckoutSpanAttributes } from '../../observability/types';
import { incRequestCounter } from '../../observability/helpers';
import { prisma } from '../../lib/prisma'; // pretend ORM

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Increment request metric (labels are typed)
  incRequestCounter({
    route: '/api/checkout',
    method: req.method as 'GET' | 'POST' | 'PUT' | 'DELETE',
    statusCode: 0, // placeholder, will be updated later
  });

  // Start a typed span for the whole checkout flow
  const span = startTypedSpan<CheckoutSpanAttributes>('checkout.process', undefined, {
    orderId: '',
    userId: '',
    amountCents: 0,
    paymentMethod: 'card',
    success: false,
  });

  try {
    if (req.method !== 'POST') {
      res.setHeader('Allow', 'POST');
      res.status(405).end('Method Not Allowed');
      return;
    }

    const { orderId, userId, amountCents, paymentMethod } = req.body as {
      orderId: string;
      userId: string;
      amountCents: number;
      paymentMethod: 'card' | 'paypal' | 'apple_pay';
    };

    // Update span attributes with real values (type‑checked)
    span.setAttributes({
      orderId,
      userId,
      amountCents,
      paymentMethod,
      success: false, // will be flipped on success
    });

    // Simulate a DB call
    await prisma.order.create({
      data: { id: orderId, userId, total: amountCents, paymentMethod },
    });

    // Business logic succeeded
    span.setAttribute('success', true);
    res.status(200).json({ status: 'ok' });
  } catch (err) {
    span.recordException(err as Error);
    res.status(500).json({ error: 'checkout_failed' });
  } finally {
    // Close the span and update metric label
    span.end();

    // Update the metric with the final status code
    incRequestCounter({
      route: '/api/checkout',
      method: req.method as 'GET' | 'POST' | 'PUT' | 'DELETE',
      statusCode: res.statusCode,
    });
  }
}

What makes this type‑safe?

  • The startTypedSpan call requires an object that satisfies CheckoutSpanAttributes.
  • incRequestCounter only accepts ApiRequestLabels; a typo like statuscode would be caught.
  • The request body is explicitly typed, preventing accidental any usage.

6. Propagating Context Across Edge Functions

Next.js can run API routes on Vercel Edge Runtime, which does not support Node’s async hooks. OpenTelemetry provides a context manager that works with the Web API AsyncLocalStorage polyfill.

// otelEdge.ts
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import { propagation, trace } from '@opentelemetry/api';

export const contextManager = new AsyncLocalStorageContextManager();
trace.setGlobalTracerProvider(trace.getTracerProvider());
propagation.setGlobalPropagator(new propagation.W3CTraceContextPropagator());

// In your edge handler:
export default async function handler(request: Request) {
  const ctx = propagation.extract(contextManager.active(), request.headers);
  return contextManager.run(ctx, async () => {
    const span = tracer.startSpan('edge.handler');
    // ... business logic
    span.end();
    return new Response('ok');
  });
}

By extracting the incoming traceparent header and re‑entering the context, you keep the same trace across serverless and edge boundaries.


7. Testing Typed Observability

Because the helpers are pure TypeScript functions, you can unit‑test them without a running collector.

// __tests__/helpers.test.ts
import { startTypedSpan } from '../observability/helpers';
import { CheckoutSpanAttributes } from '../observability/types';

test('startTypedSpan enforces attribute types', () => {
  const attrs: CheckoutSpanAttributes = {
    orderId: 'ord_123',
    userId: 'usr_456',
    amountCents: 1999,
    paymentMethod: 'paypal',
    success: false,
  };
  const span = startTypedSpan('test.span', undefined, attrs);
  expect(span).toBeDefined();
  span.end();
});

If you try to pass paymentMethod: 'bitcoin', TypeScript will refuse to compile, guaranteeing that only supported enum values ever reach the exporter.


8. Best Practices & Common Pitfalls

✅ Good Practice ❌ Pitfall
Centralize attribute schemas – one source of truth per domain. Scattering ad‑hoc setAttribute('foo', ...) strings throughout the codebase.
Wrap the SDK – expose only the typed helpers you need. Directly calling tracer.startSpan everywhere, losing compile‑time guarantees.
Export metrics lazily – avoid circular imports in Next.js hot reload. Instantiating a MeterProvider inside a request handler (creates a new exporter per request).
Propagate context – always extract from inbound headers before starting a new span. Forgetting to extract, resulting in separate traces for each micro‑service.
Keep span lifetimes short – end them in finally blocks. Leaving spans open, which can cause memory leaks in long‑running serverless containers.

9. Conclusion

By marrying OpenTelemetry’s powerful observability model with TypeScript’s static type system, you gain compile‑time confidence that every trace, metric, and log adheres to the contract you defined. The pattern shown here—typed attribute interfaces, generic span helpers, and a thin metric wrapper—scales from a single API route to a full‑blown micro‑service architecture, while keeping the developer experience ergonomic.

Give it a try in your next Next.js project: start with the minimal otel.ts bootstrap, add the typed schemas for your most critical flows, and watch your observability data become as reliable as the code that produces it.