DEV Community

Cover image for OpenTelemetry JS 3.0 just shipped. I tested both lines side by side. Here's what broke.
Albert Alov
Albert Alov

Posted on

OpenTelemetry JS 3.0 just shipped. I tested both lines side by side. Here's what broke.

TL;DR - On October 20, OpenTelemetry JS shipped @opentelemetry/api 1.10 and SDK 3.0. I was building a library that has to work with whatever OTel setup an app already has, so I ran the same test suite against both lines. I found three breaking changes, a peer range split that one dependency can't span, an app quietly running two copies of the OTel API, and a Node 24 behavior that contradicts the Node 24 docs. Everything below is reproduced in code.


Why I had to care about both lines

I'm building a test library for AI agents. It records OpenTelemetry gen_ai.* spans while a test runs and asserts on what the agent did. The hard requirement: never break the app's own telemetry. No replaced providers, no fighting over globals, and the app should export exactly the same spans with or without my library.

That means I don't get to pick an OTel version. The app picks it. And as of this week, apps are split:

Line API SDK sdk-node
Legacy ≤ 1.9 2.x (2.12) 0.2xx (0.223)
Modern 1.10 3.0 0.300

So I wrote a spike that runs the same 27 scenarios on both lines, with Vercel AI SDK 7 as the instrumentation. Here's what came out.

1. Three breaking changes in SDK 3.0

SimpleSpanProcessor takes an options object

// SDK 2.x
new SimpleSpanProcessor(exporter);

// SDK 3.0
new SimpleSpanProcessor({ exporter });
Enter fullscreen mode Exit fullscreen mode

TypeScript catches this. Plain JavaScript doesn't. With the old call shape on 3.0, nothing throws at construction. Spans just never arrive, and the only trace of the problem is a diag log entry:

TypeError: Cannot read properties of undefined (reading 'export')
Enter fullscreen mode Exit fullscreen mode

forceFlush() then fails with the same error for forceFlush. If your diag logger is off, your traces silently vanish.

sdk-trace-base became sdk-trace, and the provider was renamed

The 3.0 tracing SDK lives in a new package, @opentelemetry/sdk-trace. The old @opentelemetry/sdk-trace-base tops out at 2.12, which is already a thin shim re-exporting from the new package. In 3.0, BasicTracerProvider is gone. The class is now TracerProvider:

// SDK 2.x
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';

// SDK 3.0
import { TracerProvider } from '@opentelemetry/sdk-trace';
Enter fullscreen mode Exit fullscreen mode

sdk-node dropped its re-exports

sdk-node 0.2xx re-exported the whole SDK as namespaces, so a lot of setup code looks like this:

import { NodeSDK, tracing } from '@opentelemetry/sdk-node';
const exporter = new tracing.InMemorySpanExporter();
Enter fullscreen mode Exit fullscreen mode

In 0.300, the package exports exactly two things: NodeSDK and startNodeSDK. tracing is undefined, so the line above throws Cannot read properties of undefined (reading 'InMemorySpanExporter').

2. One dependency can't span both lines

These are the peer ranges as published:

Package @opentelemetry/api peer range
sdk-trace-base 2.12 >=1.3.0 <1.10.0
sdk-node 0.223 >=1.3.0 <1.10.0
sdk-trace 3.0 >=1.10.0 <1.11.0
sdk-node 0.300 >=1.10.0 <1.11.0
context-async-hooks 3.0 >=1.0.0 <1.11.0

The ranges don't overlap. If a library depends on any SDK package, it supports exactly one line, and every app on the other line gets peer warnings or a second SDK.

My fix was to depend on no SDK at all. The library ships a small in-memory Tracer written against @opentelemetry/api only, and takes the API as a peer dependency, so it always shares the app's copy. That last part turned out to matter more than I expected.

3. Your app may be running two copies of the OTel API

Vercel's @ai-sdk/otel 1.0.150 lists the API as a regular dependency, pinned to an exact version:

"dependencies": {
  "@opentelemetry/api": "1.9.1"
}
Enter fullscreen mode Exit fullscreen mode

Put that in an app on the modern line and you get two copies:

node_modules/@opentelemetry/api                                   → 1.10.0  (the app)
node_modules/.pnpm/@ai-sdk+otel@…/node_modules/@opentelemetry/api → 1.9.1   (@ai-sdk/otel)
Enter fullscreen mode Exit fullscreen mode

It still works, and the reason is worth knowing. OTel keeps its globals (tracer provider, context manager, propagator) on a shared global object, and every API copy checks the registered version before using it. Within a major version, the rule in api/src/internal/semver.ts boils down to:

// A copy may use a global only if the global was registered by the same or a NEWER minor.
ownVersion.minor <= globalVersion.minor
Enter fullscreen mode Exit fullscreen mode

So an older copy can read globals registered by a newer one, but not the other way around. I checked both directions with two API copies side by side:

Global registered by Read by Result
API 1.10 API 1.9 ✅ sees the registered context manager
API 1.9 API 1.10 ❌ gets a no-op manager, the value is silently lost

The modern line plus @ai-sdk/otel is the safe direction: the app registers with 1.10, and the SDK reads with 1.9. Now flip it. An app on the legacy line registers with 1.9, and any library that brings its own 1.10 copy gets no-ops. It throws no error and logs no warning. Its spans and context just aren't there.

Check what you have:

pnpm why @opentelemetry/api   # or: npm ls @opentelemetry/api
Enter fullscreen mode Exit fullscreen mode

If you maintain a library: make @opentelemetry/api a peer dependency, never a direct one. That's how you guarantee one copy.

4. Node 24: AsyncLocalStorage.disable() doesn't do what the docs say

OTel's context manager for Node is a thin wrapper around AsyncLocalStorage, and its disable() calls AsyncLocalStorage.disable(). I wrote my own small manager to drop the context-async-hooks dependency (its peer range caps the API below 1.11). CI went green on Node 22 and red on Node 24.

Here's a minimal reproduction:

import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';

const als = new AsyncLocalStorage();
const inFlight = als.run('ctx', async () => {
  await sleep(5);
  return als.getStore();
});
await sleep(1);
als.disable();
console.log(await inFlight);
Enter fullscreen mode Exit fullscreen mode
Runtime Output
Node 22.23 undefined
Node 24.22 'ctx'
Node 24.22 with --no-async-context-frame undefined

The Node 24 docs say that after disable(), "all current contexts linked to the instance will be exited." On Node 24, an operation that's already in flight keeps its store anyway. The cause is AsyncContextFrame, which became the default AsyncLocalStorage implementation in Node 24. Turn it off and you get the documented behavior back.

Who cares? Mostly test suites. A common pattern is afterEach(() => context.disable()). On Node 24, async work that's still running from the previous test can keep its context. My manager now keeps its own disabled flag, so active() returns ROOT_CONTEXT after disable() on every runtime, until the next with().

Two smaller gotchas

  • NodeSDK + SimpleSpanProcessor exports asynchronously. It waits for async resource detection before exporting. A test that reads an InMemorySpanExporter right after a span ends sees zero spans. Call processor.forceFlush() first. I lost a while on this one: my "app exports the same spans" check reported a baseline of 0.
  • pnpm 12 won't install anything published less than a day ago. ai@7.0.153 came out the same afternoon I needed it, and I got ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION. Don't bypass it; pin the previous release. That's the supply-chain policy working as intended.

Checklist

  • [ ] Run pnpm why @opentelemetry/api and confirm a single copy, or at least that the app's copy is the newest one.
  • [ ] If you write a library, make the OTel API a peer dependency and don't depend on an SDK package unless you must.
  • [ ] Moving to SDK 3.0: switch to new SimpleSpanProcessor({ exporter }), @opentelemetry/sdk-trace and TracerProvider, and stop importing tracing from sdk-node.
  • [ ] Turn on the diag logger in tests (diag.setLogger). Silent export failures only show up there.
  • [ ] On Node 24, don't rely on AsyncLocalStorage.disable() to cut off in-flight async work.
  • [ ] Compare the shape of exported spans (names, parents, attributes), not just their names. Name-only checks let a changed parent slip through. I learned that from my own code review.

What I'm building

All of this came out of the first spike for toadwright 🐸, a Playwright-style test library for AI agents. You assert on the agent's trajectory (tools called, order, arguments, cost), and every test runs N times, because one green run proves nothing. It's early. The spike, the matrix that runs both OTel lines in CI, and the design decision behind it are public: ADR-001 · repo.

The next post will be about the part that actually matters: what happens when you run the same agent test ten times.

Have you hit the two-copies problem in a real app? I'd love to hear how it showed up. 🐸

Top comments (0)