OpenTelemetry JavaScript SDK 3.0: What Changed and How to Migrate
OpenTelemetry JS SDK 3.0 stabilizes Logs, consolidates tracing packages, requires Node.js 22.15+, and ships dual ESM/CJS exports. Migrate a Node SaaS service and export traces, metrics, and logs over OTLP.

Your Node.js checkout API already emits traces. Metrics land in Grafana. Logs still arrive through a separate Winston shipper with a different request id. On November 5, 2026, the OpenTelemetry JavaScript project announced SDK 3.0 — and with it, the Logs signal became stable in JavaScript. Traces, metrics, and logs now share one API surface under @opentelemetry/api. That is the release worth planning a migration weekend around, not because the changelog is fashionable, but because the package graph and the Node.js floor both moved.
This guide covers what actually broke in OpenTelemetry JS SDK 3.0, how to migrate a typical SaaS Node service, and a practical setup that exports traces, metrics, and logs over OTLP. Every package version and runtime check below comes from a lab we ran on Node.js 22.15.0 the day after the announcement — not from invented benchmarks.
The problem SDK 3.0 is solving
OpenTelemetry in JavaScript grew into a forest of packages. Tracing alone lived in sdk-trace-base, sdk-trace-node, and sdk-trace-web. Logs sat in a separate @opentelemetry/api-logs package while the rest of the API lived in @opentelemetry/api. Jaeger-specific exporters and propagators lingered beside OTLP. Deep imports into internal package paths worked until they did not. Teams that started on Node 18 or 20 kept shipping, even after those runtimes reached end-of-life.
SDK 3.0 is the annual major that the JavaScript SIG has aimed for since SDK 2.0 in March 2025: raise the Node floor, ship proper dual ESM and CommonJS exports, fold stabilized APIs together, and delete deprecated paths. Per OpenTelemetry’s own support policy, stable 2.x packages remain supported for one year after 3.0.0 — enough time to migrate, not enough time to ignore Node 22 forever.
What changed in OpenTelemetry JavaScript SDK 3.0
The announcement and the 3.x migration guide are the authoritative sources. The practical summary for application teams:
- Node.js minimum is
>=22.15.0. Support for Node 18 and 20 is gone from the SDK packages (the API and semantic-conventions packages keep wider ranges). - Proper ESM and CommonJS exports on every package. Package-name imports still work; deep imports and
package.jsonsubpath imports do not. - Logs are stable.
@opentelemetry/api-logsis folded into@opentelemetry/api(1.10.0).@opentelemetry/sdk-logsis now a stable 3.x package. All three signals are stable in JavaScript. - Tracing packages consolidated into
@opentelemetry/sdk-trace.NodeTracerProvider/WebTracerProvider/sdk-trace-baseare gone in favor ofTracerProvider. - Complex attributes (OTEP 4485) are allowed on spans and log records; resource and metric attributes stay simple.
- Deprecated packages removed:
propagator-jaeger,exporter-jaeger,shim-opentracing,shim-opencensus. - Constructor shapes cleaned up. Processors take an options object (
{ exporter }).NodeSDKprefers pluralspanProcessors,metricReaders, andlogRecordProcessors.
Stable SDK packages version at >=3.0.0. Experimental packages jumped to >=0.300.0 (the 0.300 line signals the 3.x generation). In our lab on 2026-11-06 we resolved @opentelemetry/api@1.10.0, sdk-trace / sdk-metrics / sdk-logs@3.1.0, and sdk-node@0.301.0.

Who needs to change code — and who does not
If you only use @opentelemetry/auto-instrumentations-node the way the zero-code docs describe, you usually do not rewrite instrumentation. You still must run Node.js 22.15.0 or newer, and you still bump the SDK packages that auto-instrumentations pull in.
If you construct providers, processors, or exporters by hand — the common path for SaaS services that already have a custom instrumentation.mjs — expect import path edits, constructor changes, and a Jaeger cleanup if you still use those packages.
Migration checklist for a Node.js SaaS service

1. Raise Node.js first
Confirm the runtime before you touch package.json. SDK packages declare the new engines floor; installing on Node 20 will fail or warn depending on your npm settings. Node.js 22.15.0 is the documented minimum. In containers and CI, pin the image tag explicitly (node:22.15 or newer) so a floating node:20 base cannot sneak back in.
node -v
# expect v22.15.0 or newer
2. Bump packages together
SDK 2.x packages peer-depend on @opentelemetry/api below 1.10.0. You cannot mix API 1.10.0 with SDK 2.x. Upgrade the API and the SDK line in one change:
npm install @opentelemetry/api@^1.10.0 \
@opentelemetry/sdk-node@^0.300.0 \
@opentelemetry/sdk-trace@^3 \
@opentelemetry/sdk-metrics@^3 \
@opentelemetry/sdk-logs@^3 \
@opentelemetry/exporter-trace-otlp-http@^0.300.0 \
@opentelemetry/exporter-metrics-otlp-http@^0.300.0 \
@opentelemetry/exporter-logs-otlp-http@^0.300.0 \
@opentelemetry/resources@^3 \
@opentelemetry/semantic-conventions@latest \
@opentelemetry/auto-instrumentations-node@latest
npm uninstall @opentelemetry/api-logs \
@opentelemetry/sdk-trace-node \
@opentelemetry/sdk-trace-base \
@opentelemetry/sdk-trace-web \
@opentelemetry/exporter-jaeger \
@opentelemetry/propagator-jaeger
3. Fix imports that moved
// before
import { logs, SeverityNumber } from '@opentelemetry/api-logs';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
// after
import { logs, SeverityNumber } from '@opentelemetry/api';
import { TracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace';
logs.setGlobalLoggerProvider(provider) now returns a boolean (whether registration succeeded), not the provider. Call logs.getLoggerProvider() when you need the instance. Our lab saw true on a successful register.
4. Update processor constructors
// before
new BatchSpanProcessor(exporter)
new SimpleSpanProcessor(exporter)
// after
new BatchSpanProcessor({ exporter })
new SimpleSpanProcessor({ exporter })
The same object-form pattern applies to log record processors. For NodeSDK, prefer the plural options:
import { NodeSDK } from '@opentelemetry/sdk-node';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs';
const sdk = new NodeSDK({
spanProcessors: [/* ... */],
metricReaders: [new PeriodicExportingMetricReader({ exporter: metricsExporter })],
logRecordProcessors: [new SimpleLogRecordProcessor({ exporter: logsExporter })],
instrumentations: [getNodeAutoInstrumentations()],
});
Singular keys such as metricReader still constructed in our 0.301.0 lab build, but the migration guide documents the plurals as the supported shape — write new code against those.
5. Replace Jaeger-specific pieces with OTLP and W3C
Jaeger’s custom Thrift protocols are deprecated; Jaeger accepts OTLP natively. Swap exporters and propagators, and roll the change across every service that participates in a trace. A half-migrated mesh that still emits uber-trace-id while others only read traceparent will orphan spans. If you need a transition window, finish it on SDK 2.x with a composite propagator — the Jaeger propagator package cannot install beside SDK 3.x / API 1.10.0.
// before
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
// after
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
const exporter = new OTLPTraceExporter({
url: 'https://otel-collector.example.com:4318/v1/traces',
});
6. Stop deep-importing package internals
With the new exports map, require('@opentelemetry/api/package.json') throws ERR_PACKAGE_PATH_NOT_EXPORTED — confirmed in the lab. Use public entry points only. For the SDK version string, the migration guide points at SDK_INFO from @opentelemetry/core.
Practical Node.js SaaS setup: traces, metrics, and logs
Here is a minimal instrumentation.mjs aimed at a long-running Node HTTP service (Express, Fastify, Nest, or a plain http server). Load it before application code with node --import ./instrumentation.mjs server.js. Adjust the collector URL through environment variables in every environment.
/* instrumentation.mjs — OpenTelemetry JS SDK 3.x */
import { NodeSDK } from '@opentelemetry/sdk-node';
import { resourceFromAttributes } from '@opentelemetry/resources';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
if (process.env.OTEL_LOG_LEVEL === 'debug') {
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
}
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://127.0.0.1:4318';
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'checkout-api',
[ATTR_SERVICE_VERSION]: process.env.SERVICE_VERSION || '0.0.0',
'deployment.environment': process.env.NODE_ENV || 'development',
});
const sdk = new NodeSDK({
resource,
spanProcessors: [
new BatchSpanProcessor({
exporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
}),
],
metricReaders: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${endpoint}/v1/metrics` }),
exportIntervalMillis: 60_000,
}),
],
logRecordProcessors: [
new BatchLogRecordProcessor({
exporter: new OTLPLogExporter({ url: `${endpoint}/v1/logs` }),
}),
],
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
sdk.start();
const shutdown = async () => {
try {
await sdk.shutdown();
} finally {
process.exit(0);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
A few choices in that file matter more than the imports:
- Resource attributes identify the service. Without a stable
service.name, every dashboard becomes a guessing game across replicas. - Disable noisy instrumentations deliberately. File-system auto-instrumentation can drown a trace in irrelevant spans; turn it off unless you are debugging disk I/O.
- Batch processors in production. Simple processors are fine for local console debugging; they are a latency and loss foot-gun under load.
- Shut down on signal. Otherwise the last batch of spans and logs disappears when Kubernetes sends SIGTERM.
Emit a correlated application log
With Logs stable in the API, application code can emit log records that share the active trace context — the missing link when Winston and Jaeger disagreed on request ids:
import { logs, SeverityNumber, trace } from '@opentelemetry/api';
const logger = logs.getLogger('checkout');
export function recordPayment(orderId, amount) {
const span = trace.getActiveSpan();
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: 'INFO',
body: 'payment accepted',
attributes: {
'order.id': orderId,
'payment.amount': amount,
// complex attribute (span/log only — not for metrics)
'payment.method': { type: 'card', brand: 'visa' },
},
});
span?.addEvent('payment.accepted', { 'order.id': orderId });
}
Keep metric attributes simple. The migration guide is explicit: complex values must not be used with the Metrics SDK, and sdk-metrics does not guard against invalid attributes for performance reasons.
Bundlers, ESM, and the new package exports
Dual ESM and CJS exports are a quality-of-life win for Vite, esbuild, and webpack users — if the bundler understands exports and imports conditions. Packages with platform-specific code resolve the Node implementation through the node condition. A server bundle built with platform: 'neutral' (esbuild) or a webpack target that mixes web and node without node in resolve.conditionNames can silently ship the browser implementation into a Node process.
For applications that run as native ESM, remember the instrumentation loader hook documented in the Node.js getting-started guide. Auto-instrumentation still needs to patch modules before they load; --import alone is not always enough under pure ESM.
How this fits a broader SaaS observability stack
OpenTelemetry does not replace the rest of your reliability work. Rate-limit decisions still need clear metrics and traces when clients retry — see our guide on API rate limiting for SaaS. Outbound webhooks still need idempotency keys and retry budgets; correlated traces make those failures diagnosable, which is why patterns in SaaS webhook reliability pair naturally with OTLP export. Services that sit on AWS Lambda Web Functions or expose stateless remote MCP servers benefit from the same three-signal pipeline, with short-lived runtimes paying extra attention to force-flush before freeze or exit. Caching layers described in Redis caching and invalidation are another place custom spans earn their keep: a cache miss that looks like an API regression is usually a span attribute away from an obvious answer.
Common migration mistakes
- Upgrading packages before Node. You will chase peer-dependency errors that are really an engines mismatch.
- Migrating the Jaeger propagator on only one service. Distributed traces break at the boundary. Cut over together, or dual-propagate on 2.x first.
- Leaving deep imports in place. They fail loudly under the new exports map — better in CI than in production, but still a surprise if you never grepped for them.
- Putting complex objects on metric attributes. Allowed-looking TypeScript will not save you; keep metrics cardinality and types boring.
- Assuming auto-instrumentation means zero ops. You still own sampling, exporter endpoints, resource attributes, and shutdown hooks.
- Skipping collector TLS and auth. OTLP over plain HTTP to a public endpoint is a telemetry exfiltration bug waiting for a ticket.
Recommendations
- Treat Node 22.15+ as a platform prerequisite for any new OpenTelemetry work in JavaScript.
- Migrate one service end-to-end (runtime → packages → collector → dashboards) before rolling the fleet.
- Prefer OTLP HTTP/protobuf or gRPC to a collector you control; let the collector fan out to vendors.
- Turn on Logs through the API once, and retire the parallel request-id field you have been stitching by hand.
- Keep 2.x only as a temporary bridge — the one-year support window is a deadline, not a comfort blanket.
- Add a CI smoke test that boots
instrumentation.mjson the same Node version you run in production.
FAQ
Is @opentelemetry/api breaking in 1.10.0?
The announcement states the API package does not have breaking changes for ordinary users. The notable additions are Logs in the main API and complex attribute typing. SDK implementors and TypeScript code that narrowed Attributes aggressively may need updates.
Do I need sdk-node?
No, but it is the least painful way to get environment-variable configuration, resource detection, and wiring for all three signals. Teams that build providers manually should still read the sdk-node migration notes — several namespace re-exports (tracing, metrics, logs, api) were removed.
What about browsers?
SDK 3.x consolidates web tracing into @opentelemetry/sdk-trace as well. Register a StackContextManager and W3C propagators yourself; the old WebTracerProvider.register() convenience is gone. Browser-specific packages under the Browser SIG will follow onto the 3.x line.
Where CodeSapient Labs can help
If you are mid-fleet on Node 20 with a mix of Jaeger and OTLP exporters, the migration is mostly mechanical — until sampling, multi-tenant resource attributes, and collector capacity show up. CodeSapient’s engineering services include observability rollouts for Node.js and Python SaaS backends. Tell us what you run and we will map a cutover that does not orphan traces between services.
Lab note (2026-11-06, Asia/Calcutta): Node.js v22.15.0; @opentelemetry/api@1.10.0; sdk-trace/sdk-metrics/sdk-logs@3.1.0; sdk-node@0.301.0; auto-instrumentations-node@0.83.0. Confirmed: api.logs and SeverityNumber present; api-logs package absent; TracerProvider exported and NodeTracerProvider absent; ERR_PACKAGE_PATH_NOT_EXPORTED on api/package.json; logs.setGlobalLoggerProvider returns boolean.
