Skip to main content

Next.js

Running this in production

Storing and querying this data at production volume is what base14 Scout does. Check out Scout APM.

Introduction

This is the default path for instrumenting a server-side Next.js app with base14 Scout. The Next.js Node runtime authenticates to Scout itself and exports OTLP over the public internet. There is no collector container, no sidecar, and no private network hop.

That matters most on a serverless host. On Vercel, Netlify Functions, AWS Lambda or Cloud Run, there is nowhere to put a collector next to the app and no private network to reach a shared one over. The application process is the only thing that exists, so it holds the credential and does the exporting.

The same code runs unchanged on a long-lived next start process, a container, or a VM. Nothing here is Vercel-specific: the deployment environment and build version come from your own variables, and the Vercel equivalents are only used as a fallback when you have not set them.

TL;DR

Put the Node SDK behind Next's instrumentation.ts register hook, guarded on NEXT_RUNTIME, NEXT_PHASE and the presence of credentials. Authenticate with OAuth2 client credentials against your tenant's realm and pass scoutAuthHeaders (an async function, not an object) as the exporter's headers, so every export carries a fresh bearer. Keep provider handles on globalThis; module scope does not survive Next's bundle split. Call flush() from after() in every route that produces telemetry, because a frozen function's batch timers never fire. Use delta temporality for metrics. List the OTel packages in serverExternalPackages.

Who This Guide Is For

This documentation is designed for:

  • Next.js developers shipping App Router apps who want traces, metrics and logs in Scout without running any additional infrastructure.
  • Teams on serverless platforms (Vercel, Lambda, Cloud Run) where a collector sidecar is not an option.
  • Platform engineers who want one credential per app rather than a collector fleet to operate, patch and monitor.
  • SRE and DevOps who need to know exactly where telemetry is dropped when credentials are absent, and why an unconfigured deployment must stay silent.

When to Use This Instead of a Collector

SituationUse
Serverless or PaaS with no sidecarThis guide - direct OTLP to Scout
One app, no existing collector fleetThis guide
You want a credential per app, rotated with the appThis guide
Many services on one network alreadyCollector setup
You need tail sampling, redaction or routingCollector setup
You need host, container or k8s metrics tooCollector setup
Browser RUM alongside server tracesNext.js Full-Stack

A collector is still the right answer when telemetry from many services needs common processing before it leaves your network. For a single Next.js app it adds a hop without adding any processing. The general tradeoffs are covered in Direct to Scout Backend.

Overview

Prerequisites

Before starting, ensure you have:

  • Node.js 22 or later.
  • Next.js 15.1 or later using the App Router. The instrumentation.ts register hook is stable from 15 and after() from 15.1. This guide is written against Next.js 16.
  • Scout tenant credentials: OTLP endpoint, token URL, client ID and client secret. Contact the base14 team if you do not have them.
  • Your service name registered in the tenant. Scout silently discards telemetry from an unregistered service.name, and both the exporter and the backend report success while it happens.

Compatibility Matrix

ComponentVersionNotes
Next.js16.1App Router; register hook stable since 15
React19.2
Node.js22Edge runtime is excluded by design
@opentelemetry/api1.9.1
@opentelemetry/sdk-trace-node2.10.0
@opentelemetry/sdk-metrics2.10.0
@opentelemetry/sdk-logs0.221.0
@opentelemetry/auto-instrumentations-node0.79.0
@opentelemetry/exporter-*-otlp-http0.221.0Async headers verified on this version
@opentelemetry/semantic-conventions1.43.0ATTR_* constants
Do not downgrade the exporter packages

@opentelemetry/otlp-exporter-base 0.221.0 accepts headers as an async function and awaits it inside every send - http-exporter-transport.js calls const headers = await this._parameters.headers();. Exporters that predate that read headers once at construction, so the first token is frozen into the exporter and every export 401s the moment it expires, silently. Verify this before downgrading, and pin the exporter packages to one release train.

Instrumented Components

getNodeAutoInstrumentations() patches these at require() time, and Next.js contributes its own spans on top once a tracer provider is registered.

ComponentSourceWhat you get
Incoming HTTP requestsinstrumentation-httpServer spans with method, route, status code
Route handlers and server componentsNext.js built-inexecuting api route, resolve page components, render route
Server actionsNext.js built-inOne span per action invocation
Outbound fetch() and http callsinstrumentation-http, instrumentation-undiciClient spans with traceparent propagated downstream
PostgreSQL, MySQL, MongoDB, Redisinstrumentation-pg, -mysql2, -mongodb, -ioredisQuery spans with obfuscated statements
Prisma, DrizzleTheir own OTel integrationsEnable separately; not part of the auto set
Winston, Pino, Bunyaninstrumentation-winston, -pino, -bunyanLog records with trace_id and span_id attached
Filesystem, DNS, netdisabled hereToo noisy on serverless; see the pipeline module
V8 heap, GC, event loopdisabled hereTurn on for a long-lived server

Anything not in this list needs a manual span. See Custom Instrumentation.

Architecture

NEXT.JS NODE RUNTIME SCOUT
service.name = your-app

instrumentation.ts
register() --> startTelemetry()
|
+-- NodeTracerProvider --+
+-- LoggerProvider --+--> getScoutToken()
+-- MeterProvider --+ client_credentials
| --> id.b14.dev
route handlers |
after(() => flush()) ---------------------+--> OTLP/HTTP + gzip
--> otel.<region>.base14.io
/<tenant>/otlp

The token is fetched once per process, cached, refreshed on age, and attached per export. Nothing exports on a timer; flush() triggers each export.

Installation

Install the Node SDK, the auto-instrumentation bundle and the three OTLP/HTTP exporters. All of them are runtime dependencies; the server bundle loads them on every cold start.

Install OpenTelemetry for Next.js
npm install --save \
@opentelemetry/api \
@opentelemetry/api-logs \
@opentelemetry/sdk-trace-node \
@opentelemetry/sdk-trace-base \
@opentelemetry/sdk-metrics \
@opentelemetry/sdk-logs \
@opentelemetry/instrumentation \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-logs-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/otlp-exporter-base \
@opentelemetry/resources \
@opentelemetry/semantic-conventions

Pin the four 0.221.0 packages together. They share an internal transport contract, and mixing release trains is what reintroduces the frozen-header bug described above.

Configuration

Environment Variables

VariableSecretPurpose
SCOUT_ENDPOINTnoOTLP base, no trailing slash. Signals append /v1/traces etc.
SCOUT_TOKEN_URLnoYour realm's token endpoint
SCOUT_CLIENT_IDnoOAuth2 client
SCOUT_CLIENT_SECRETyesOAuth2 client secret
SCOUT_AUDIENCEnoDefaults to b14collector
OTEL_SERVICE_NAMEnoMust match the service registered in your tenant
DEPLOYMENT_ENVIRONMENTnoDeployment environment. Falls back to VERCEL_ENV, then NODE_ENV
SERVICE_VERSIONnoBuild identifier. Falls back to VERCEL_GIT_COMMIT_SHA, then dev

Supply them however your host does secrets. The application code reads process.env and does not care which of these you use.

.env.local
SCOUT_ENDPOINT=https://otel.<region>.base14.io/<tenant-id>/otlp
SCOUT_TOKEN_URL=https://id.b14.dev/realms/<tenant>/protocol/openid-connect/token
SCOUT_CLIENT_ID=<client-id>
SCOUT_CLIENT_SECRET=<client-secret>
OTEL_SERVICE_NAME=your-app

These are the same four values a collector puts in its oauth2client extension, so an app instrumented this way and a collector in the same tenant rotate together.

Put real values in .env.local (which create-next-app gitignores) and in your host's environment settings. Never in .env, and never in a commit. A build-time check that fails when the secret's literal value appears in a client bundle is worth adding; one is shown under Security Considerations.

Never prefix any of these with NEXT_PUBLIC_

NEXT_PUBLIC_ values are inlined into the client bundle at build time. A NEXT_PUBLIC_SCOUT_CLIENT_SECRET is a published credential. The browser in this design needs no endpoint, tenant or credential at all - it talks to same-origin relative paths only. Add a build-time grep for NEXT_PUBLIC_ in your telemetry directory if you want that enforced rather than remembered.

If the secret contains a $, escape it as \$

Next expands $VAR references when it loads .env files. An unescaped $ in a client secret is silently truncated or mangled: the file looks correct, and the realm answers invalid_client. The token module below names this cause in its warning, because nothing in the error says it.

Connection Settings

src/lib/telemetry/config.ts
// Every read is inside a function, never at module scope. Module-scope reads
// are evaluated when the module is first imported, which during `next build`
// is the prerender pass rather than a request. Reading per call keeps one
// build artifact correct when it is promoted between environments.

export type ScoutConfig = {
/** OTLP base, no trailing slash. Signals append /v1/traces etc. */
endpoint: string;
tokenUrl: string;
clientId: string;
clientSecret: string;
audience: string;
};

/**
* Null when unconfigured, which must stay a clean no-op rather than an error.
* A fork, a credential-less CI build and a plain `next dev` all land here, and
* none of them should see a failed token request on every page load.
*/
export function scoutConfig(): ScoutConfig | null {
const endpoint = process.env.SCOUT_ENDPOINT;
const tokenUrl = process.env.SCOUT_TOKEN_URL;
const clientId = process.env.SCOUT_CLIENT_ID;
const clientSecret = process.env.SCOUT_CLIENT_SECRET;
if (!endpoint || !tokenUrl || !clientId || !clientSecret) return null;

return {
endpoint: endpoint.replace(/\/+$/, ''),
tokenUrl,
clientId,
clientSecret,
// Scout passes this as a form field on the token request, not a scope.
audience: process.env.SCOUT_AUDIENCE || 'b14collector',
};
}

export function serviceName(): string {
return process.env.OTEL_SERVICE_NAME || 'nextjs-app';
}

/**
* DEPLOYMENT_ENVIRONMENT first, then VERCEL_ENV, and NODE_ENV only as a floor.
*
* NODE_ENV only takes production/development/test and is "production" on
* PREVIEW deploys too, so deriving the environment from it files every preview
* under production and quietly corrupts the environment filter on every
* dashboard. Set DEPLOYMENT_ENVIRONMENT on any host that is not Vercel.
*/
export function environment(): string {
return (
process.env.DEPLOYMENT_ENVIRONMENT ||
process.env.VERCEL_ENV ||
process.env.NODE_ENV ||
'development'
);
}

export function serviceVersion(): string {
const explicit = process.env.SERVICE_VERSION;
if (explicit) return explicit;
// Only the commit SHA is shortened. An explicit version is used verbatim,
// so a tag like 1.2.3-rc.1 survives intact.
return (process.env.VERCEL_GIT_COMMIT_SHA || 'dev').slice(0, 7);
}

Resource Attributes

src/lib/telemetry/resource.ts
import { resourceFromAttributes } from '@opentelemetry/resources';
import type { Resource } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
import { environment, serviceName, serviceVersion } from './config';

/** Separates tiers of one service: server, browser, worker. */
export const ATTR_SERVICE_ROLE = 'service.role';

export type ServiceRole = 'browser' | 'server';

function base(role: ServiceRole): Record<string, string> {
const env = environment();
return {
[ATTR_SERVICE_NAME]: serviceName(),
[ATTR_SERVICE_VERSION]: serviceVersion(),
[ATTR_SERVICE_ROLE]: role,
// Both keys, same value. Scout's UI and its CLI --environment flag filter
// on the bare key; the OTel semantic convention is the dotted one. Setting
// both means a dashboard filter written either way works.
environment: env,
'deployment.environment': env,
};
}

export function serverResource(region?: string): Resource {
const attrs = base('server');
// service.instance.id is a resource attribute, so a per-request or
// per-visitor value partitions every metric time series. Use something
// bounded - a region, a pod name - or leave it unset.
if (region) attrs['service.instance.id'] = region;
attrs['os.type'] = process.platform;
return resourceFromAttributes(attrs);
}

export function browserResource(): Resource {
return resourceFromAttributes(base('browser'));
}

OAuth2 Token Manager

Scout's realm issues short-lived tokens - five minutes is typical. A warm serverless instance comfortably outlives that, so a token fetched once at startup will be expired while the instance is still serving requests. The cache below refreshes on age.

The cache is ordinary module state, not globalThis. Next's bundle split can give the route handlers their own copy, which costs one extra token fetch per copy and nothing else. Only the provider handles have to be shared, because those carry the queued spans.

src/lib/telemetry/token.ts
// OAuth2 client-credentials against the tenant's realm. This is the collector's
// oauth2client extension, ported, because there is no collector here.
//
// Deliberately not OTEL_EXPORTER_OTLP_HEADERS: that is parsed once when the
// exporter is constructed, so a rotated token silently becomes a 401 that
// nothing surfaces.

import { scoutConfig } from './config';

type Cached = { token: string; expiresAt: number };

// Module scope, so the cache is shared by every request this instance
// serves. A fresh instance pays one token fetch; a warm one pays none.
let cached: Cached | null = null;
let inflight: Promise<string | null> | null = null;
let warnedInvalidClient = false;

/** Refresh this long before the token actually expires. */
const SKEW_MS = 60_000;

async function fetchToken(): Promise<string | null> {
const cfg = scoutConfig();
if (!cfg) return null;

try {
const res = await fetch(cfg.tokenUrl, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
audience: cfg.audience,
}),
// Capped so a slow identity provider cannot delay a page. The batch is
// dropped instead.
signal: AbortSignal.timeout(4000),
});

if (!res.ok) {
const body = await res.text().catch(() => '');
// Warned once per process. This cause is non-obvious: Next
// expands $VAR references inside .env files, so a client secret
// containing a literal $ arrives mangled unless escaped as \$.
if (!warnedInvalidClient && body.includes('invalid_client')) {
warnedInvalidClient = true;
console.warn(
'[telemetry] Scout rejected the client credentials (invalid_client). ' +
"If SCOUT_CLIENT_SECRET contains a '$', escape it as '\\$' in .env " +
'files: Next expands $VAR references when loading them.',
);
}
return null;
}

const json = (await res.json()) as {
access_token?: string;
expires_in?: number;
};
if (!json.access_token) return null;

const ttlMs = (json.expires_in ?? 300) * 1000;
cached = { token: json.access_token, expiresAt: Date.now() + ttlMs };
return cached.token;
} catch {
// Network error, timeout, malformed JSON. Return null rather than
// throwing; the caller treats it as an export failure.
return null;
}
}

/**
* A valid bearer, or null if telemetry should be dropped.
*
* Single-flight: traces, logs and metrics all flush at the same moment in
* `after()`, and would otherwise each open their own token request on a cold
* start.
*/
export async function getScoutToken(): Promise<string | null> {
if (cached && Date.now() < cached.expiresAt - SKEW_MS) return cached.token;

if (!inflight) {
inflight = fetchToken().finally(() => {
inflight = null;
});
}
const fresh = await inflight;
if (fresh) return fresh;

// A failed refresh falls back to the cached token while it is still valid.
// Inside the skew window it has not actually expired, so a transient
// identity-provider failure does not immediately cost telemetry.
if (cached && Date.now() < cached.expiresAt) return cached.token;
return null;
}

/**
* Headers factory for the OTLP exporters.
*
* @opentelemetry/otlp-exporter-base 0.221+ accepts `headers` as
* `() => Promise<Record<string,string>>` and awaits it inside every send, so a
* rotating token needs no exporter wrapper and no instance swapping.
*
* Upstream contract: functions passed to the exporter must not throw.
* Returning {} on failure yields an unauthenticated request that Scout answers
* with 401, which the exporter treats as a normal export failure.
*/
export async function scoutAuthHeaders(): Promise<Record<string, string>> {
const token = await getScoutToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}

/** Test seam: drop the cache so the next call re-authenticates. */
export function resetScoutToken(): void {
cached = null;
inflight = null;
}

Exporters

src/lib/telemetry/exporters.ts
import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import {
AggregationTemporalityPreference,
OTLPMetricExporter,
} from '@opentelemetry/exporter-metrics-otlp-http';
import { scoutConfig } from './config';
import { scoutAuthHeaders } from './token';

/** Shared exporter options. `signal` is the OTLP path segment. */
function opts(signal: 'traces' | 'logs' | 'metrics') {
const cfg = scoutConfig();
return {
url: `${cfg?.endpoint ?? ''}/v1/${signal}`,
// Passing the function itself is what gives per-export token refresh.
// An object here would freeze the first token into the exporter.
headers: scoutAuthHeaders,
compression: CompressionAlgorithm.GZIP,
// Shorter than the platform's own limit, so a stalled export cannot be
// what holds an invocation open at flush time.
timeoutMillis: 5000,
};
}

export function traceExporter(): OTLPTraceExporter {
return new OTLPTraceExporter(opts('traces'));
}

export function logExporter(): OTLPLogExporter {
return new OTLPLogExporter(opts('logs'));
}

/**
* Delta temporality, which is not the default.
*
* A serverless platform freezes a function between requests and discards it
* without warning. Under the default cumulative temporality each new instance
* restarts its counters at zero, the backend sees a monotonic series jump
* backwards on every cold start, and those resets read as enormous negative
* rates. Delta reports only what happened since the last collection, which is
* the only temporality that survives this execution model.
*/
export function metricExporter(): OTLPMetricExporter {
return new OTLPMetricExporter({
...opts('metrics'),
temporalityPreference: AggregationTemporalityPreference.DELTA,
});
}
No insecure_skip_verify equivalent

Some sample collector configs disable TLS verification, including on the hop that carries the credential. There is no reason to do that here, and no option above turns it off.

The Telemetry Pipeline

This module creates the three providers, installs the auto-instrumentations and exposes flush(). Two details carry the setup: the provider handles live on globalThis, and flush() drains every provider. Both are marked in the comments below.

src/lib/telemetry/server.ts
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import type {
ReadableSpan,
Span,
SpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { metrics, type Context } from '@opentelemetry/api';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs';
import {
MeterProvider,
PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { logs } from '@opentelemetry/api-logs';
import { logExporter, metricExporter, traceExporter } from './exporters';
import { serverResource } from './resource';

type Registry = {
tracerProvider?: NodeTracerProvider;
serverLogs?: LoggerProvider;
meterProvider?: MeterProvider;
started: boolean;
};

/**
* Keep the provider handles on globalThis.
*
* Next compiles instrumentation.ts into a different bundle from the route
* handlers, so each gets its own copy of this module and its own module-level
* variables. As module state, startTelemetry() populates one copy while
* flush() reads another, empty one: `started` is false at flush time and
* nothing is ever exported. Spans still dribble out under `next start`,
* because the OTel global API is process-wide, so the bug does not show up in
* development.
*/
const SLOT = Symbol.for('app.telemetry.registry');

function registry(): Registry {
const g = globalThis as typeof globalThis & { [SLOT]?: Registry };
if (!g[SLOT]) g[SLOT] = { started: false };
return g[SLOT];
}

/**
* Drops spans for the telemetry endpoints themselves.
*
* ignoreIncomingRequestHook on instrumentation-http does not cover these:
* Next.js emits its own spans ("POST /api/otel", "executing api route",
* "resolve page components") from its built-in OpenTelemetry support the
* moment a tracer provider is registered, and that support does not consult
* the HTTP instrumentation's filters.
*/
class FilteringSpanProcessor implements SpanProcessor {
constructor(private readonly inner: SpanProcessor) {}

private noisy(span: ReadableSpan): boolean {
const route = String(
span.attributes['next.route'] ?? span.attributes['http.route'] ?? '',
);
return span.name.includes('/api/otel') || route.includes('/api/otel');
}

onStart(span: Span, parentContext: Context): void {
// Delegated, even though BatchSpanProcessor's onStart is a no-op today.
// A processor swapped in later may well need it.
this.inner.onStart(span, parentContext);
}

onEnd(span: ReadableSpan): void {
if (!this.noisy(span)) this.inner.onEnd(span);
}

forceFlush(): Promise<void> {
return this.inner.forceFlush();
}

shutdown(): Promise<void> {
return this.inner.shutdown();
}
}

export function startTelemetry(): void {
const reg = registry();
if (reg.started) return;
reg.started = true;

// Own the MeterProvider rather than letting NodeSDK build one: flush() needs
// a handle to force-collect it.
reg.meterProvider = new MeterProvider({
resource: serverResource(),
readers: [
new PeriodicExportingMetricReader({
exporter: metricExporter(),
// Deliberately long. flush() drives collection; this interval is only
// a fallback for a long-lived process.
exportIntervalMillis: 60_000,
exportTimeoutMillis: 10_000,
}),
],
});
metrics.setGlobalMeterProvider(reg.meterProvider);

const tracerProvider = new NodeTracerProvider({
resource: serverResource(),
spanProcessors: [
new FilteringSpanProcessor(new BatchSpanProcessor(traceExporter())),
],
});
tracerProvider.register();
reg.tracerProvider = tracerProvider;

const serverLogs = new LoggerProvider({
resource: serverResource(),
processors: [new BatchLogRecordProcessor({ exporter: logExporter() })],
});
logs.setGlobalLoggerProvider(serverLogs);
reg.serverLogs = serverLogs;

registerInstrumentations({
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-http': {
ignoreIncomingRequestHook: (req) => {
const url = req.url ?? '';
return (
url.startsWith('/_next') ||
url.startsWith('/api/otel') ||
url === '/favicon.ico'
);
},
// Without this, the exporter's own POST to Scout is traced, and
// each exported span produces another one.
ignoreOutgoingRequestHook: (opts) => {
const host =
typeof opts === 'string'
? opts
: String(opts.hostname ?? opts.host ?? '');
// b14.dev is the identity host and base14.io is ingest. Both
// are requests this pipeline makes on its own behalf.
return host.includes('base14.io') || host.includes('b14.dev');
},
},
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-net': { enabled: false },
// ~20 series per collection of V8 heap sizes, GC durations and event
// loop utilisation. On a platform that discards the instance between
// requests, these describe a process that no longer exists by the time
// anyone reads the chart. Enable it on a long-lived server.
'@opentelemetry/instrumentation-runtime-node': { enabled: false },
}),
],
});
}

/**
* Push everything to Scout before the instance freezes.
*
* Drain every provider, not only the tracer. Draining the tracer alone lets
* server spans arrive while logs and metrics are silently dropped.
*
* Never rejects. A flush failure must not turn into a 500 on a page.
*/
export async function flush(): Promise<void> {
const reg = registry();
if (!reg.started) return;

const jobs: Promise<unknown>[] = [];
if (reg.tracerProvider) jobs.push(reg.tracerProvider.forceFlush().catch(() => {}));
if (reg.serverLogs) jobs.push(reg.serverLogs.forceFlush().catch(() => {}));
if (reg.meterProvider) jobs.push(reg.meterProvider.forceFlush().catch(() => {}));
await Promise.all(jobs);
}

The Register Hook

src/instrumentation.ts
// Next's server bootstrap hook. Three guards, each for a different failure.
//
// 1. NEXT_RUNTIME. The Node SDK cannot run on the Edge runtime. The import is
// dynamic so the SDK is not even resolved at module-evaluation time and
// therefore never enters the Edge bundle.
//
// 2. NEXT_PHASE. register() runs whenever a Next server bootstraps, including
// the prerender pass of `next build`. Without this guard the SDK starts on
// the build machine, fetches a token from CI, and emits a burst of
// build-time spans indistinguishable from real traffic in the tenant.
//
// 3. Credentials. A fork, or CI without secrets, must be a silent no-op rather
// than a failed token request on every request. Guard on the whole config,
// not the secret alone: with a secret but no SCOUT_ENDPOINT the exporters
// are built with a relative URL and throw at construction, which turns the
// promised silent no-op into an error on every boot.
//
// Do Not Track belongs in the browser, not here: this hook has no request
// context to read it from.

export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
if (process.env.NEXT_PHASE === 'phase-production-build') return;

const { scoutConfig } = await import('./lib/telemetry/config');
if (!scoutConfig()) return;

const { startTelemetry } = await import('./lib/telemetry/server');
startTelemetry();
}

Next.js Configuration

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
// The OpenTelemetry Node SDK must stay external to the server bundle. Its
// instrumentations work by patching modules at require() time, so bundling
// them rewrites the very module identities they hook and the patches
// silently attach to nothing. This produces no error and no spans.
serverExternalPackages: [
'@opentelemetry/sdk-trace-node',
'@opentelemetry/instrumentation',
'@opentelemetry/auto-instrumentations-node',
'@opentelemetry/exporter-trace-otlp-http',
'@opentelemetry/exporter-logs-otlp-http',
'@opentelemetry/exporter-metrics-otlp-http',
],
};

export default nextConfig;

Export Timing

Call flush() from after() in every route that produces telemetry.

A serverless platform freezes the function the instant it responds, so a BatchSpanProcessor's timer never fires and a PeriodicExportingMetricReader's interval never elapses. Nothing exports on schedule. after() runs once the response is on the wire, and is the only window in which an export can start.

src/app/api/example/route.ts
import { after } from 'next/server';
import { trace } from '@opentelemetry/api';
import { flush } from '@/lib/telemetry/server';

export const runtime = 'nodejs';
// force-dynamic so process.env is read per request rather than frozen into the
// build, which is what lets one build artifact be promoted between
// environments.
export const dynamic = 'force-dynamic';

export async function GET(): Promise<Response> {
const span = trace.getActiveSpan();
span?.setAttribute('app.handler', 'example');

const body = await doWork();

// The only window before the instance freezes.
after(async () => {
await flush();
});

return Response.json(body);
}

Add this to every route that produces telemetry. On a long-lived server (next start, a container, a VM) the batch processors work normally and after() is simply harmless, so the same code is correct in both places.

Statically prerendered pages produce no server spans

If most of your routes are static, a near-empty trace view is the expected outcome, not a symptom. Only dynamic routes, route handlers and server actions run per request.

Production Configuration

Batch Tuning

The defaults are tuned for a long-lived process. On a host that freezes the instance the batch never fills and never times out, so the numbers that matter are the queue caps rather than the intervals.

src/lib/telemetry/server.ts (batch options)
new BatchSpanProcessor(traceExporter(), {
// A frozen instance never reaches this timer. It only does work on a
// long-lived server, where 5s keeps the trace view close to live.
scheduledDelayMillis: 5_000,
// Cap the queue rather than the batch. A burst that overruns this is
// dropped in memory, which bounds heap growth in a container with a hard
// memory limit.
maxQueueSize: 2_048,
maxExportBatchSize: 512,
// Below the exporter's own 5s, so a stalled export cannot stack.
exportTimeoutMillis: 4_000,
});
SettingServerlessLong-lived server
scheduledDelayMillisIrrelevant; flush() drives export5000
maxQueueSize20482048, raise if you see drops
maxExportBatchSize512512
exportIntervalMillis (metrics)60000 fallback only15000 to 30000
after(() => flush())Required on every routeHarmless, leave it in

Compression is already on for all three signals (CompressionAlgorithm.GZIP in the exporter options). OTLP payloads are highly repetitive, so gzip typically cuts them by more than half for a few milliseconds of CPU per export.

Dockerfile

For a container or VM rather than a serverless host, build the standalone output and keep the OTel packages unbundled.

Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# serverExternalPackages keeps @opentelemetry/* out of the bundle; standalone
# output copies them into .next/standalone/node_modules instead.
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

On a long-lived server, turn the runtime metrics back on:

src/lib/telemetry/server.ts (long-lived server)
'@opentelemetry/instrumentation-runtime-node': { enabled: true },

Multi-Service Tracing

Context propagates over the wire as a traceparent header, and instrumentation-http and instrumentation-undici both handle it. No code change is needed for a Next.js route that calls another instrumented service: the outbound span is a child of the incoming request, and the downstream service continues the same trace.

src/app/api/orders/route.ts
import { after } from 'next/server';
import { flush } from '@/lib/telemetry/server';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function GET(): Promise<Response> {
// traceparent is injected automatically. The billing service sees this
// request as part of the same trace.
const billing = await fetch('https://billing.internal/v1/summary').then((r) =>
r.json(),
);

after(async () => {
await flush();
});

return Response.json(billing);
}

Give every service its own OTEL_SERVICE_NAME and the same DEPLOYMENT_ENVIRONMENT, so a trace crosses services while dashboard filters still separate environments.

Framework-Specific Features

Route Handlers and Server Components

Next.js emits its own spans as soon as a tracer provider is registered, so route handlers, page renders and component resolution are covered without any code in the handler itself.

Span nameEmitted for
GET /api/ordersThe incoming request, from instrumentation-http
executing api route (app) /api/orders/routeThe route handler body
resolve page componentsApp Router component resolution
render route (app) /ordersServer rendering of a dynamic route
start responseTime to first byte

Set NEXT_OTEL_VERBOSE=1 to add Next's more granular internal spans. Leave it off in production; the extra spans are only useful while debugging.

Server Actions

Server actions are traced by the same built-in support. Add attributes to the active span rather than creating a new one, so the action stays attached to the request that triggered it.

src/app/orders/actions.ts
'use server';

import { after } from 'next/server';
import { trace } from '@opentelemetry/api';
import { flush } from '@/lib/telemetry/server';

export async function submitOrder(formData: FormData): Promise<void> {
const span = trace.getActiveSpan();
span?.setAttribute('app.action', 'submitOrder');
// A bounded value. Never the customer id, and never the raw form body.
span?.setAttribute('app.order.currency', String(formData.get('currency')));

await persist(formData);

after(async () => {
await flush();
});
}

Middleware Is Not Instrumented

Next.js middleware runs on the Edge runtime, where the Node SDK cannot run. That is why register() returns early unless NEXT_RUNTIME is nodejs. A request that is rewritten or redirected in middleware produces no span until it reaches a Node route.

If you need visibility there, propagate a header from middleware and read it in the route handler, where you can put it on the active span:

src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest): NextResponse {
// Set this on the request headers rather than the response. Only request
// headers reach the route handler; a response header would not.
const headers = new Headers(request.headers);
headers.set('x-mw-rule', request.nextUrl.pathname.split('/')[1] ?? '');
return NextResponse.next({ request: { headers } });
}

Then read it where a span already exists. headers() is async from Next.js 15:

src/app/[section]/page.tsx
import { headers } from 'next/headers';
import { trace } from '@opentelemetry/api';

export default async function Page() {
const rule = (await headers()).get('x-mw-rule');
if (rule) trace.getActiveSpan()?.setAttribute('app.mw.rule', rule);

return <section>...</section>;
}

Logs Correlated to Traces

instrumentation-pino and instrumentation-winston are both in the auto set. They inject trace_id and span_id into every record and forward records to the global LoggerProvider, which is why startTelemetry() registers one. No separate log exporter wiring is needed.

src/lib/logger.ts
import pino from 'pino';

// No transport and no OTLP config here. The instrumentation picks these
// records up and routes them through the LoggerProvider set in
// startTelemetry(), so they land in Scout with trace context attached.
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
// Redact before the record is ever created. The log bridge forwards
// whatever pino produces.
redact: ['req.headers.authorization', 'req.headers.cookie', '*.password'],
});

A log line emitted inside a request handler carries the trace id, so clicking through from a slow span to its logs works without any correlation id of your own.

Database Queries

instrumentation-pg, -mysql2, -mongodb and -ioredis are all in the auto set and produce query spans with the statement obfuscated. Prisma and Drizzle are not: they ship their own OpenTelemetry integrations and have to be enabled separately.

Register Prisma in the same registerInstrumentations() call as the auto set, inside startTelemetry(). Registering it at module scope in your database module would leave the ordering up to import order, and an instrumentation that loads before the tracer provider exists produces nothing.

src/lib/telemetry/server.ts (Prisma)
import { PrismaInstrumentation } from '@prisma/instrumentation';

// Inside startTelemetry(), replacing the registerInstrumentations() call
// shown earlier.
registerInstrumentations({
instrumentations: [
getNodeAutoInstrumentations({
// ... the options shown earlier
}),
new PrismaInstrumentation(),
],
});

Custom Instrumentation

Auto-instrumentation covers the request, the query and the outbound call. Anything specific to your domain needs a span or a metric you write yourself.

Manual Spans

src/lib/checkout.ts
import { SpanStatusCode, trace } from '@opentelemetry/api';

// Module scope is fine for a tracer: the OTel global API is process-wide, so
// this resolves to the real provider once startTelemetry() has run. Only the
// provider handles need to live on globalThis.
const tracer = trace.getTracer('app.checkout', '1.0.0');

export async function processCheckout(cartId: string, itemCount: number) {
return tracer.startActiveSpan('checkout.process', async (span) => {
try {
// Use bounded values. itemCount is a small integer. cartId has
// unbounded cardinality on a metric, but is fine on a span.
span.setAttribute('app.cart.id', cartId);
span.setAttribute('app.cart.item_count', itemCount);

const result = await chargeAndFulfil(cartId);

span.setAttribute('app.checkout.outcome', result.outcome);
return result;
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: 'checkout failed' });
throw error;
} finally {
// Always end the span. An unended span is never exported.
span.end();
}
});
}

Custom Metrics

src/lib/metrics.ts
import { metrics } from '@opentelemetry/api';

const meter = metrics.getMeter('app.checkout', '1.0.0');

export const checkoutsCompleted = meter.createCounter('app.checkouts.completed', {
description: 'Checkouts that reached a terminal success state',
unit: '{checkout}',
});

export const checkoutDuration = meter.createHistogram('app.checkout.duration', {
description: 'End-to-end checkout latency',
unit: 'ms',
});
Recording a measurement
import { checkoutDuration, checkoutsCompleted } from '@/lib/metrics';

const started = performance.now();
const result = await processCheckout(cartId, itemCount);

// Attributes here become metric dimensions. Keep them to a closed set:
// 'outcome' has three possible values, 'payment_method' has five. A cart id
// or a user id here would create one time series per customer.
checkoutsCompleted.add(1, {
outcome: result.outcome,
payment_method: result.paymentMethod,
});
checkoutDuration.record(performance.now() - started, { outcome: result.outcome });
Metric attributes are not span attributes

Every distinct combination of metric attribute values is a separate time series, stored and billed for as long as it is retained. A cart id is fine on a span. On a counter it creates one time series per cart. Collapse to route patterns and closed enumerations before a value becomes a metric dimension.

Trace ID in Responses

Returning the trace id lets a support ticket or a client-side error report point straight at the trace.

src/app/api/checkout/route.ts
import { after } from 'next/server';
import { trace } from '@opentelemetry/api';
import { flush } from '@/lib/telemetry/server';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function POST(request: Request): Promise<Response> {
const body = await request.json();
const result = await processCheckout(body.cartId, body.itemCount);

const ctx = trace.getActiveSpan()?.spanContext();

after(async () => {
await flush();
});

return Response.json(result, {
headers: ctx
? // A real span id, not zeroes: an all-zero span id is invalid under
// the W3C trace context spec. Server-Timing is readable from the
// browser via PerformanceObserver with no extra CORS configuration
// on a same-origin response.
{
'Server-Timing': `traceparent;desc="00-${ctx.traceId}-${ctx.spanId}-01"`,
}
: {},
});
}

Running Your Application

Development

Run locally against your tenant
# .env.local supplies the four Scout variables.
npm run dev

next dev is a long-lived process, so the batch processors export on their own timers and you do not need after() to see spans. This is why a missing flush() does not show up locally. With no credentials in the environment the register hook returns immediately and the app runs untouched.

Production

Build and run
npm run build
npm run start

The build emits no telemetry: register() returns early on phase-production-build. Telemetry starts with the first request to the running server.

Expected Span Hierarchy

A single request to an instrumented route handler produces roughly this:

GET /api/checkout (instrumentation-http, SERVER)
└── executing api route (app) /api/checkout/route (Next.js built-in)
└── checkout.process (your manual span)
├── pg.query:INSERT orders (instrumentation-pg)
└── POST payments.internal (instrumentation-undici, CLIENT)
└── ... continues in the payments service

Verify the Setup

Work through these three in order. Each one checks a different layer.

1. Check the credentials directly

Fetch a token
TOKEN=$(curl -s -X POST "$SCOUT_TOKEN_URL" \
-d grant_type=client_credentials \
-d client_id="$SCOUT_CLIENT_ID" \
-d client_secret="$SCOUT_CLIENT_SECRET" \
-d audience=b14collector | jq -r .access_token)

[ -n "$TOKEN" ] && [ "$TOKEN" != null ] && echo "token ok" || echo "no token"

An invalid_client here is a credential problem, not an instrumentation problem. Check the $-escaping note above first. Load the variables from .env.local (set -a; source .env.local; set +a) rather than typing the secret on the command line, where it lands in shell history.

2. Check the ingest path

Send an empty payload
curl -i -X POST "$SCOUT_ENDPOINT/v1/traces" \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"resourceSpans":[]}'

Expect 200 with {"partialSuccess":{}}. Without the bearer, expect 401. The same holds for /v1/logs and /v1/metrics.

3. Check the app

npm run build && npm run start
curl -s localhost:3000/api/example > /dev/null

Then look for your service.name in Scout. If the two curl checks pass and the app produces nothing, work through the troubleshooting table below.

Browser Telemetry

The browser holds no credential in this design. If you want RUM alongside these server traces, keep it that way: have the browser export to a same-origin route that attaches the bearer server-side.

First, a small guard module. The limiter is a cost cap, not a security boundary: a serverless platform runs many instances and freezes them arbitrarily, so the bucket is per instance and per lifetime. It stops one looping tab or a naive script from turning into unbounded ingest.

src/lib/telemetry/guard.ts
const WINDOW_MS = 60_000;
const MAX_PER_WINDOW = 120;
/** Bounded so the map cannot itself become the memory leak. */
const MAX_TRACKED = 5000;

const hits = new Map<string, { count: number; resetAt: number }>();

export function rateLimit(key: string): boolean {
const now = Date.now();
const entry = hits.get(key);

if (!entry || now > entry.resetAt) {
if (hits.size > MAX_TRACKED) hits.clear();
hits.set(key, { count: 1, resetAt: now + WINDOW_MS });
return true;
}
entry.count++;
return entry.count <= MAX_PER_WINDOW;
}

/** Best-effort client identity. Most hosts set x-forwarded-for at the edge. */
export function clientKey(req: Request): string {
const fwd = req.headers.get('x-forwarded-for');
return (fwd?.split(',')[0] ?? req.headers.get('x-real-ip') ?? 'unknown').trim();
}

Then the route itself:

src/app/api/otel/[...signal]/route.ts
import { after } from 'next/server';
import { scoutConfig } from '@/lib/telemetry/config';
import { clientKey, rateLimit } from '@/lib/telemetry/guard';
import { getScoutToken } from '@/lib/telemetry/token';
import { flush } from '@/lib/telemetry/server';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

/** Spans can be larger than a metrics batch, but not unboundedly so. */
const MAX_BYTES = 512 * 1024;

/** The two OTLP/HTTP encodings. Anything else is not a span payload. */
const CONTENT_TYPES = new Set(['application/json', 'application/x-protobuf']);

export async function POST(
request: Request,
{ params }: { params: Promise<{ signal: string[] }> },
): Promise<Response> {
const { signal } = await params;

// Traces only. Proxying /v1/logs and /v1/metrics would let anyone write
// arbitrary log bodies and arbitrary metric attributes into the tenant.
if ((signal ?? []).join('/') !== 'v1/traces') {
return new Response(null, { status: 404 });
}

// Unconfigured: accept and discard, so a fork or a local run behaves the
// same as production from the browser's point of view.
const cfg = scoutConfig();
if (!cfg) return new Response(null, { status: 202 });

if (!rateLimit(clientKey(request))) return new Response(null, { status: 429 });

const contentType = (request.headers.get('content-type') ?? '').split(';')[0];
if (!CONTENT_TYPES.has(contentType)) return new Response(null, { status: 415 });

// Checked before AND after reading: content-length is client-supplied.
const declared = Number(request.headers.get('content-length') ?? 0);
if (declared > MAX_BYTES) return new Response(null, { status: 413 });

let body: ArrayBuffer;
try {
body = await request.arrayBuffer();
} catch {
return new Response(null, { status: 400 });
}
if (body.byteLength > MAX_BYTES) return new Response(null, { status: 413 });

const token = await getScoutToken();
if (!token) return new Response(null, { status: 202 });

// Relayed byte for byte. The browser stamped service.role=browser on its
// resource, and re-resourcing it here would erase what separates browser
// spans from server spans in the tenant.
try {
await fetch(`${cfg.endpoint}/v1/traces`, {
method: 'POST',
headers: { 'content-type': contentType, authorization: `Bearer ${token}` },
body,
// Telemetry must never be what holds a function open.
signal: AbortSignal.timeout(5000),
});
} catch {
// Dropped. Never surface an upstream failure to the browser.
}

after(async () => {
await flush();
});

// Scout's response is deliberately not echoed: that would turn this route
// into a probe for whether the tenant and credential are valid.
return new Response(null, { status: 202 });
}

/** Only POST is valid on this route. A GET is a scanner or a misconfiguration. */
export function GET(): Response {
return new Response(null, { status: 405 });
}

Because the browser posts same-origin, connect-src 'self' in an existing Content-Security-Policy already permits it. No Scout origin, tenant id or credential is added to the CSP or to the client bundle.

This is an unauthenticated write path

/api/otel accepts POSTs from anyone who can reach your app. The route above rate-limits, caps the body, allow-lists the content type and rejects anything that is not v1/traces. Keep all four. If you later proxy logs or metrics too, validate every value that can become a metric attribute against a closed set on the server first; the browser cannot be trusted to bound its own cardinality, and one unbounded attribute degrades the whole tenant.

For the full browser side - web SDK setup, traceparent propagation, Core Web Vitals and error boundaries - see Next.js Full-Stack.

Troubleshooting

SymptomCauseFix
Nothing arrives, no errors anywhereservice.name is not registered in the tenant. Scout discards it silently and both the exporter and backend report successCheck the tenant's service list before touching code
Works on next start, nothing in productionflush() is not called from after(), so the frozen instance never exportsAdd after(() => flush()) to every telemetry-producing route
Spans arrive, logs and metrics do notflush() drains only the tracer providerDrain every provider you created
flush() runs but started is falseProvider handles are in module scope, and the route bundle has a different copy from instrumentation.tsKeep the registry on globalThis behind a Symbol.for
All exports 401 after a few minutesExporter packages below 0.221, so headers was read once at constructionUpgrade the exporter packages; pass headers as a function
Realm answers invalid_clientA $ in the client secret was expanded by Next when loading .envEscape it as \$
Build-time spans in the tenantMissing NEXT_PHASE guard, so the SDK started during prerenderReturn early on phase-production-build
No spans at all, no errorOTel packages were bundled, so the require-time patches attached to nothingAdd them to serverExternalPackages
Metric rates spike hugely negativeCumulative temporality plus cold starts resetting countersUse AggregationTemporalityPreference.DELTA
Trace view is mostly the /api/otel routeNext emits its own spans for that route regardless of HTTP instrumentation filtersAdd the FilteringSpanProcessor
Every export produces another spanThe exporter's own POST is being tracedSet ignoreOutgoingRequestHook for the Scout and identity hosts
Edge runtime build errorsThe Node SDK was statically importedImport it dynamically, inside the NEXT_RUNTIME guard

Security Considerations

  • The credential lives in one place. Only the Node runtime reads SCOUT_CLIENT_SECRET. No NEXT_PUBLIC_ prefix, ever, and no endpoint or tenant id in the client bundle.

  • Enforce that at build time. The rule is a one-word prefix and easy to miss in review, and breaking it ships the secret to every visitor and every CDN cache. Fail the build on either signal:

    scripts/check-no-secrets.mjs
    import { existsSync, globSync, readFileSync } from 'node:fs';

    let failed = false;

    // 1. No NEXT_PUBLIC_ anywhere in the telemetry code. The browser is
    // configured by same-origin relative paths and needs none.
    for (const file of globSync('src/lib/telemetry/*.ts')) {
    readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
    const t = line.trim();
    if (!t.startsWith('//') && t.includes('NEXT_PUBLIC_')) {
    console.error(`${file}:${i + 1} NEXT_PUBLIC_ in telemetry code`);
    failed = true;
    }
    });
    }

    // 2. No literal secret in a built client bundle. Read from the environment,
    // never hardcoded: this script is committed.
    const secret = process.env.SCOUT_CLIENT_SECRET;
    if (existsSync('.next/static') && secret && secret.length >= 12) {
    for (const file of globSync('.next/static/**/*.js')) {
    if (readFileSync(file, 'utf8').includes(secret)) {
    console.error(`${file} CONTAINS A CREDENTIAL`);
    failed = true;
    }
    }
    }

    if (failed) process.exit(1);

    Wire it into prebuild so next build cannot succeed past it.

  • The token is never logged. The only warning the token module prints is the invalid_client hint, and it prints no request or response body. Keep it that way. A token written to a log is stored for the life of that log.

  • Unconfigured is a clean no-op. With any of the four Scout variables missing, scoutConfig() returns null, the register hook returns immediately and the routes accept and discard. A fork, a preview build without secrets, and a plain next dev all behave identically and silently.

  • Rate-limit and size-cap any public write path you expose for browser telemetry, and proxy traces only.

  • Do not disable TLS verification on the hop that carries the credential.

  • Keep high-cardinality values off metric attributes. Collapse paths to route patterns (/blog/[slug], not /blog/why-otel) before they become attributes; the full path can still go on a span or log record.

Performance Considerations

  • Cold start: one token fetch, cached per instance. instrumentation-fs is disabled because its patching is expensive enough to show up in cold-start time.
  • Per export: scoutAuthHeaders() is a cache read in the common case, not a network call.
  • Payload size: gzip is on for every signal.
  • Request latency: unchanged. after() runs once the response is on the wire, and every timeout (4s token, 5s export) is set so telemetry can never be what holds an invocation open.
  • Runtime metrics are off by default here. On a long-lived server, turn instrumentation-runtime-node back on - that is where it earns its ~20 series per collection.

FAQ

Do I need an OpenTelemetry Collector to send Next.js telemetry to Scout?

No. The Next.js Node runtime can authenticate to Scout with OAuth2 client credentials and export OTLP directly, which is what this guide sets up. A collector is worth adding when several services need shared processing - tail sampling, redaction, routing - or when you also want host and container metrics. For a single app on a serverless platform, it has nowhere to run and nothing to add.

Why do my spans disappear in production but work locally?

Because a serverless platform freezes the function the moment it responds, so the batch processor's export timer never fires. Locally, next start is a long-lived process, the timers elapse normally, and everything looks correct. The fix is to call flush() from after() in every route that produces telemetry, which is the only window between the response being sent and the instance being frozen.

Why does the OTLP exporter start returning 401 after a few minutes?

The exporter captured a single bearer token at construction and the token expired. Scout's realm issues short-lived tokens - five minutes is typical - so a token fetched at startup expires while the instance is still serving. Pass headers as an async function rather than an object, and use @opentelemetry/otlp-exporter-base 0.221 or later, where the transport awaits that function on every send.

Should the browser export OTLP directly to Scout?

No, because it would need a credential to do so, and anything the browser has is public. Have the browser POST to a same-origin route in your own app that attaches the bearer server-side. That also removes the CORS preflight, needs no new origin in your CSP, and keeps your tenant id out of the client bundle.

Why must OpenTelemetry packages be listed in serverExternalPackages?

Because the instrumentations work by patching modules at require() time. Bundling them rewrites the very module identities they hook, so the patches attach to nothing. This fails silently: there is no error, there are simply no spans, which makes it one of the harder setup mistakes to diagnose.

Why delta temporality for metrics on serverless?

Because each new instance restarts its counters at zero. Under the default cumulative temporality the backend sees a monotonic series jump backwards on every cold start and reads those resets as enormous negative rates. Delta reports only what happened since the last collection, so a discarded instance takes nothing with it.

Does the SDK run during next build?

It will, unless you guard against it. register() runs whenever a Next server bootstraps, and that includes the prerender pass of next build - so the SDK starts on the build machine, fetches a token from CI, and emits build-time spans that are indistinguishable from real traffic once they are in the tenant. Return early when process.env.NEXT_PHASE === 'phase-production-build'.

How much overhead does OpenTelemetry add to a Next.js app?

Expect single-digit milliseconds on the request path and roughly 30 to 60MB of additional heap. The auto-instrumentation patches add about 1 to 3ms per request across HTTP and database spans, and the export itself costs nothing on the request path because after() runs once the response is on the wire. The larger cost is cold start: loading and patching the SDK adds roughly 150 to 300ms, which is why instrumentation-fs and the runtime metrics are disabled in this setup.

Can I use this on the Edge runtime or in middleware?

No. The OpenTelemetry Node SDK needs Node APIs that the Edge runtime does not provide, which is why register() returns early unless NEXT_RUNTIME is nodejs. Middleware therefore produces no spans. Move the work you need to see into a Node route handler, or pass a header from middleware and record it on the span there.

Do I need to call flush() in every route?

Yes, in every route that produces telemetry, if you deploy to a serverless host. The platform freezes the instance the moment the response is sent, so nothing that is still sitting in a batch queue will ever leave. On a long-lived server the batch processors handle it and after(() => flush()) is a harmless no-op, which is what lets the same code run correctly in both places.

What happens if Scout is unreachable or the credentials are wrong?

Telemetry is dropped and your application keeps serving. Every failure path in the token module and the exporters swallows its error: a failed token fetch returns null, scoutAuthHeaders() returns an empty object, and the resulting 401 is handled by the exporter as an ordinary export failure. The token fetch is capped at 4 seconds and each export at 5, so a slow or dead endpoint cannot hold a request or an invocation open.

What's Next?

Complete Example

The telemetry code is nine files, six of them under src/lib/telemetry/. Your route handlers change only to add after(() => flush()).

Project structure
your-app/
├── next.config.ts # serverExternalPackages
├── package.json
├── .env.example # variable names only, no values
├── .env.local # Scout credentials, gitignored
├── scripts/
│ └── check-no-secrets.mjs # prebuild guard
└── src/
├── instrumentation.ts # Next register hook, four guards
├── lib/
│ ├── logger.ts # pino, picked up by the log bridge
│ ├── metrics.ts # counters and histograms
│ └── telemetry/
│ ├── config.ts # env reads, all inside functions
│ ├── resource.ts # service.name, version, environment
│ ├── token.ts # OAuth2 client credentials + cache
│ ├── exporters.ts # OTLP/HTTP, gzip, delta temporality
│ ├── server.ts # providers on globalThis, flush()
│ └── guard.ts # rate limit for the browser proxy
└── app/
├── api/
│ ├── checkout/route.ts # after(() => flush())
│ └── otel/[...signal]/route.ts # browser trace proxy
└── orders/
└── actions.ts # server action
package.json (scripts)
{
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/check-no-secrets.mjs",
"build": "next build",
"start": "next start"
}
}
Run it end to end
npm install
cp .env.example .env.local # fill in the four SCOUT_* values
npm run build
npm run start

curl -s localhost:3000/api/checkout -X POST \
-H 'content-type: application/json' \
-d '{"cartId":"c_123","itemCount":2}' -i | grep -i server-timing

The Server-Timing header carries the trace id. Paste it into Scout's trace search to land on the exact request you just made.

If you would rather start from a collector-based project you can run with docker compose up, the Next.js (Collector) guide is backed by base-14/examples/nodejs/nextjs-api-mongodb. The telemetry modules above drop into that project in place of its collector wiring.

base14 Scout stores and queries this telemetry across services.

References

Was this page helpful?