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:
- Install and configure OpenTelemetry in a Next.js app.
- Define typed attribute schemas for spans and metrics.
- Wrap the SDK with helper functions that enforce those schemas.
- Instrument a real‑world checkout API route.
- 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 | Tracer → Span |
| Metrics | Quantitative health data | Meter → Counter, 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
startTypedSpancall requires an object that satisfiesCheckoutSpanAttributes. incRequestCounteronly acceptsApiRequestLabels; a typo likestatuscodewould be caught.- The request body is explicitly typed, preventing accidental
anyusage.
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.
Member discussion