elastic / elastic/apm-agent-nodejs
`span.sync` incorrectly `true` for async spans on Node.js >= 24 — AsyncContextFrame makes `executionAsyncId()` return 0 in promise continuations, so every DB/HTTP span shows a "blocking" badge in Kibana
- Dominant language
- JavaScript
- Stars
- 594
- Forks
- 244
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 16
Description
**Describe the bug**
After upgrading our services from Node.js 20 to Node.js 24 (agent unchanged), virtually every span — `pg` queries, outgoing HTTP, manual exit spans — is now reported with `sync: true`. In the Kibana APM trace waterfall this renders a **"blocking"** badge on nearly every span of every trace, which misleadingly suggests widespread event-loop blocking. Span durations and trace structure are unaffected; only the `sync` flag changed.
**Root cause**
`span.sync` is computed by comparing `async_hooks.executionAsyncId()` at span start and end:
- start: [`lib/instrumentation/span.js#L70`](https://github.com/elastic/apm-agent-nodejs/blob/main/lib/instrumentation/span.js) — `this._startXid = executionAsyncId();`
- end: `if (executionAsyncId() !== this._startXid) { this.sync = false; }`
On Node <= 22 this worked as a *side effect* of the agent's `AsyncLocalStorageRunContextManager`: `AsyncLocalStorage` was implemented on top of `async_hooks`, which enabled the V8 promise hook, so every promise continuation had a distinct async ID.
Node.js 24 enables `AsyncContextFrame` as the default `AsyncLocalStorage` implementation. No promise hook is installed anymore, so `executionAsyncId()` returns `0` inside promise continuations. Any span that starts inside an `await` chain and ends in a promise continuation (i.e. essentially every instrumented span in an async application — the `pg` instrumentation, for example, ends its span in a promise continuation in `lib/instrumentation/modules/pg.js`) compares `0 === 0` and keeps its initial `sync = true`.
**Steps to reproduce**
```js
'use strict';
const apm = require('elastic-apm-node').start({
serviceName: 'sync-repro',
disableSend: true,
centralConfig: false,
cloudProvider: 'none',
metricsInterval: '0s',
});
const { setTimeout: sleep } = require('timers/promises');
async function main() {
const tx = apm.startTransaction('tx');
await sleep(1); // be inside a promise continuation, like any real request handler
const span = apm.startSpan('SELECT FROM foo', 'db', 'postgresql', 'query', {
exitSpan: true,
});
await sleep(5); // async I/O — this span must NOT be reported as sync/blocking
span.end();
tx.end();
console.log(`${process.version} span.sync = ${span.sync}`);
process.exit(0);
}
main();
```
Output:
```
$ node20 repro.js
v20.20.0 span.sync = false # correct
$ node24 repro.js
v24.13.0 span.sync = true # BUG — async span reported as blocking
$ node24 --no-async-context-frame repro.js
v24.13.0 span.sync = false # correct again, isolating the cause to AsyncContextFrame
```
The `--no-async-context-frame` run pinpoints the trigger: it is exactly Node's switch of `AsyncLocalStorage` to AsyncContextFrame.
**Expected behavior**
Spans covering async I/O report `sync: false` (or omit the optional `sync` field), as they did on Node <= 22, so the Kibana "blocking" badge only appears on genuinely synchronous spans.
**Environment**
- OS: reproduced on Windows 11 and Linux (EKS) containers
- Node.js version: v24.13.0 (works correctly on v20.20.0, and on v24.13.0 with `--no-async-context-frame`)
- APM Server version: 8.x
- Agent version: 4.15.0 (also reproduced on 4.8.0)
**How are you starting the agent?**
- [x] Calling `agent.start()` directly (e.g. `require('elastic-apm-node').start(...)`)
- [ ] Requiring `elastic-apm-node/start` from within the source code
- [ ] Starting node with `-r elastic-apm-node/start`
Contributor guide
Research direction
Start with lib/instrumentation/span.js#L70 and the span end logic, then inspect lib/instrumentation/modules/pg.js to understand the promise-continuation case. Run the provided reproduction on Node.js 20 and 24, including --no-async-context-frame, and verify that asynchronous spans no longer report sync=true while genuinely synchronous spans retain correct behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- observability-sre
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 70/100