Describe the bug
When an application is started with Elastic APM's ESM loader, the entry module observes import.meta.main === false.
This differs from plain Node.js execution and from starting the agent with only -r elastic-apm-node/start. An application that uses import.meta.main to guard its startup code can evaluate the entry module without running the guarded startup path.
For example, an ESM entrypoint might use this pattern:
if (import.meta.main) {
server.listen(...)
}
With the Elastic loader active, the condition is false, so guarded entrypoint startup code does not run.
To Reproduce
Given main.js:
console.log(import.meta.main)
Run:
node --experimental-loader elastic-apm-node/loader.mjs main.js
Observed:
The same happens when using both the ESM loader and the preload entrypoint:
node --experimental-loader elastic-apm-node/loader.mjs -r elastic-apm-node/start.js main.js
# false
Control cases:
node main.js
# true
node -r elastic-apm-node/start.js main.js
# true
A no-op passthrough loader also preserves import.meta.main === true, so this appears to be caused by the Elastic/import-in-the-middle loader behavior rather than by Node's --experimental-loader option alone.
Expected behavior
An ESM entrypoint run as:
node --experimental-loader elastic-apm-node/loader.mjs main.js
should observe:
import.meta.main === true
unless the loader intentionally replaces the entry module in a way that makes preserving Node's main-module semantics impossible.
Environment (please complete the following information)
- OS: Linux/Windows
- Node.js version: v22.22.3
- APM Server version: not relevant; failure happens before intake
- Agent version: current
main branch / 4.15.0 package metadata
How are you starting the agent? (please tick one of the boxes)
This also reproduces when using both the ESM loader and preload entrypoint:
node --experimental-loader elastic-apm-node/loader.mjs -r elastic-apm-node/start.js main.js
Additional context
Root cause finding:
loader.mjs currently re-exports import-in-the-middle/hook.mjs:
export * from 'import-in-the-middle/hook.mjs'
import-in-the-middle wraps modules by resolving them to a URL with an iitm query parameter and then loading a generated wrapper module. For the process entrypoint, Node marks the generated wrapper as the main module. The user's real entry module is then imported by the wrapper, so it sees import.meta.main === false.
Proposed fix:
Wrap the IITM loader exports in elastic-apm-node/loader.mjs and bypass IITM only for top-level entrypoint resolution, while delegating all non-entry resolves and loader hooks to IITM:
import * as iitmHook from 'import-in-the-middle/hook.mjs'
export const initialize = iitmHook.initialize
export const load = iitmHook.load
export const getFormat = iitmHook.getFormat
export const getSource = iitmHook.getSource
export function resolve(specifier, context, nextResolve) {
if (!context.parentURL) {
return nextResolve(specifier, context, nextResolve)
}
return iitmHook.resolve(specifier, context, nextResolve)
}
Agent config options:
Click to expand
package.json dependencies:
Click to expand
Describe the bug
When an application is started with Elastic APM's ESM loader, the entry module observes
import.meta.main === false.This differs from plain Node.js execution and from starting the agent with only
-r elastic-apm-node/start. An application that usesimport.meta.mainto guard its startup code can evaluate the entry module without running the guarded startup path.For example, an ESM entrypoint might use this pattern:
With the Elastic loader active, the condition is false, so guarded entrypoint startup code does not run.
To Reproduce
Given
main.js:Run:
Observed:
falseThe same happens when using both the ESM loader and the preload entrypoint:
node --experimental-loader elastic-apm-node/loader.mjs -r elastic-apm-node/start.js main.js # falseControl cases:
A no-op passthrough loader also preserves
import.meta.main === true, so this appears to be caused by the Elastic/import-in-the-middle loader behavior rather than by Node's--experimental-loaderoption alone.Expected behavior
An ESM entrypoint run as:
should observe:
unless the loader intentionally replaces the entry module in a way that makes preserving Node's main-module semantics impossible.
Environment (please complete the following information)
mainbranch /4.15.0package metadataHow are you starting the agent? (please tick one of the boxes)
agent.start()directly (e.g.require('elastic-apm-node').start(...))elastic-apm-node/startfrom within the source code-r elastic-apm-node/startThis also reproduces when using both the ESM loader and preload entrypoint:
Additional context
Root cause finding:
loader.mjscurrently re-exportsimport-in-the-middle/hook.mjs:import-in-the-middlewraps modules by resolving them to a URL with aniitmquery parameter and then loading a generated wrapper module. For the process entrypoint, Node marks the generated wrapper as the main module. The user's real entry module is then imported by the wrapper, so it seesimport.meta.main === false.Proposed fix:
Wrap the IITM loader exports in
elastic-apm-node/loader.mjsand bypass IITM only for top-level entrypoint resolution, while delegating all non-entry resolves and loader hooks to IITM:Agent config options:
Click to expand
package.jsondependencies:Click to expand