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

Open
#5,148 0 comments 0 reactions 0 assignees View on GitHub
community triage
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.