Skip to content

Commit ab5d663

Browse files
authored
CX-24943: add support for NodeJS 24 (#73)
Adapted the `instrumentation.ts` and `cx-wrapper` to handle some changes introduced in `contrib`: - For Node 24 (and any runtime where callbacks are absent by default) still accept a user-provided callback. - Added support for promise-only handlers so cold-start failures surface consistently. - Improve module path resolution (newer NodeJS versions no longer append `js` variants). This avoids issues like: ```json { "errorType": "Runtime.ImportModuleError", "errorMessage": "Error: Cannot find module 'UserFunction.js'\nRequire stack:\n- /opt/nodejs/node_modules/cx-wrapper/loader.js\n- /opt/nodejs/node_modules/cx-wrapper/index.js\n- /var/runtime/index.mjs", "trace": [ "Runtime.ImportModuleError: Error: Cannot find module 'UserFunction.js'", "Require stack:", "- /opt/nodejs/node_modules/cx-wrapper/loader.js", "- /opt/nodejs/node_modules/cx-wrapper/index.js", "- /var/runtime/index.mjs", " at loadModule (file:///var/runtime/index.mjs:573:13)", " at async UserFunctionLoader.load (file:///var/runtime/index.mjs:504:20)", " at async createRuntime (file:///var/runtime/index.mjs:1217:52)", " at async ignition (file:///var/runtime/index.mjs:1633:21)" ] } ``` Tested manually for Node JS 18 and 24. Tested if the times increased due to the new changes. Nothing found.
2 parents b92884b + 45b41a0 commit ab5d663

8 files changed

Lines changed: 209 additions & 46 deletions

File tree

.github/workflows/publish-nodejs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,5 +104,5 @@ jobs:
104104
- name: Publish layer
105105
env:
106106
LAYER_NAME: "coralogix-opentelemetry-nodejs-wrapper"
107-
COMPATIBLE_RUNTIMES: "nodejs18.x nodejs20.x nodejs22.x"
107+
COMPATIBLE_RUNTIMES: "nodejs18.x nodejs20.x nodejs22.x nodejs24.x"
108108
run: ./ci-scripts/publish_${{ inputs.environment }}.sh

dev/deploy-nodejs.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ fi
2323
output=$(aws lambda publish-layer-version \
2424
--layer-name "$LAMBDA_LAYER_PREFIX-coralogix-opentelemetry-nodejs-wrapper-development" \
2525
--compatible-architectures x86_64 arm64 \
26-
--compatible-runtimes nodejs18.x nodejs20.x nodejs22.x \
26+
--compatible-runtimes nodejs18.x nodejs20.x nodejs22.x nodejs24.x \
2727
--zip-file fileb://nodejs/packages/layer/build/layer.zip \
2828
--region eu-west-1 \
2929
--profile "$AWS_PROFILE" \

nodejs/packages/cx-wrapper/index.ts

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getEnv } from '@opentelemetry/core';
55
diag.setLogger(new DiagConsoleLogger(), getEnv().OTEL_LOG_LEVEL);
66

77
import { Callback, Context } from 'aws-lambda';
8-
import { Handler } from "aws-lambda/handler.js";
8+
import { Handler } from 'aws-lambda/handler.js';
99
import { load } from './loader.js';
1010
import { initializeInstrumentations } from './instrumentation-init.js';
1111
import { initializeProvider } from './provider-init.js';
@@ -50,28 +50,83 @@ if (parseBooleanEnvvar("OTEL_WARM_UP_EXPORTER") ?? true) {
5050
} catch (e) {}
5151
}
5252

53-
export const handler = (event: any, context: Context, callback: Callback) => {
54-
diag.debug(`Loading original handler ${process.env.CX_ORIGINAL_HANDLER}`);
55-
load(
56-
process.env.LAMBDA_TASK_ROOT,
57-
process.env.CX_ORIGINAL_HANDLER
58-
).then(
59-
(originalHandler) => {
60-
try {
61-
diag.debug(`Instrumenting handler`);
62-
const patchedHandler = lambdaInstrumentation.getPatchHandler(originalHandler) as any as Handler;
63-
diag.debug(`Running CX handler and redirecting to ${process.env.CX_ORIGINAL_HANDLER}`)
64-
patchedHandler(event, context, callback);
65-
} catch (err: any) {
66-
context.callbackWaitsForEmptyEventLoop = false;
67-
callback(err, null);
53+
async function invokePatchedHandler(
54+
patchedHandler: Handler,
55+
event: any,
56+
context: Context
57+
) {
58+
// Handlers that declare a callback parameter should only resolve via that callback;
59+
// short‑circuiting their returned promise causes API Gateway to receive `undefined`.
60+
const expectsCallback = patchedHandler.length >= 3;
61+
62+
return new Promise((resolve, reject) => {
63+
let settled = false;
64+
const wrappedCallback: Callback = (err, result) => {
65+
if (settled) {
66+
return;
67+
}
68+
settled = true;
69+
if (err) {
70+
reject(err);
71+
} else {
72+
resolve(result);
73+
}
74+
};
75+
76+
try {
77+
const maybePromise = patchedHandler(event, context, wrappedCallback);
78+
if (
79+
maybePromise &&
80+
typeof (maybePromise as Promise<unknown>).then === 'function'
81+
) {
82+
(maybePromise as Promise<unknown>).then(
83+
value => {
84+
if (!settled && !expectsCallback) {
85+
settled = true;
86+
resolve(value);
87+
}
88+
},
89+
err => {
90+
if (!settled) {
91+
settled = true;
92+
reject(err);
93+
}
94+
}
95+
);
96+
} else if (!expectsCallback) {
97+
settled = true;
98+
resolve(maybePromise);
99+
}
100+
} catch (err) {
101+
if (!settled) {
102+
settled = true;
103+
reject(err);
68104
}
69-
},
70-
(err: Error | string) => {
71-
context.callbackWaitsForEmptyEventLoop = false;
72-
callback(err, null)
73105
}
74-
);
106+
});
75107
}
76108

77-
diag.debug('OpenTelemetry instrumentation is ready');
109+
export const handler = async (event: any, context: Context) => {
110+
diag.debug(`Loading original handler ${process.env.CX_ORIGINAL_HANDLER}`);
111+
try {
112+
const originalHandler = await load(
113+
process.env.LAMBDA_TASK_ROOT,
114+
process.env.CX_ORIGINAL_HANDLER
115+
);
116+
117+
diag.debug(`Instrumenting handler`);
118+
const patchedHandler = lambdaInstrumentation.getPatchHandler(
119+
originalHandler
120+
) as unknown as Handler;
121+
diag.debug(
122+
`Running CX handler and redirecting to ${process.env.CX_ORIGINAL_HANDLER}`
123+
);
124+
return await invokePatchedHandler(patchedHandler, event, context);
125+
} catch (err) {
126+
context.callbackWaitsForEmptyEventLoop = false;
127+
diag.error('CX handler failed to execute', err as Error);
128+
throw err;
129+
}
130+
};
131+
132+
diag.debug('OpenTelemetry instrumentation is ready');
Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,107 @@
1-
const { load } = require('UserFunction.js'); // https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js
1+
const fs = require('fs');
2+
const path = require('path');
3+
const { pathToFileURL } = require('url');
24

3-
module.exports.load = load
5+
const DEBUG = process.env.CX_DEBUG_USER_FUNCTION_RESOLUTION === 'true';
6+
7+
function logDebug(message) {
8+
if (DEBUG) {
9+
// eslint-disable-next-line no-console
10+
console.debug(`[cx-wrapper] ${message}`);
11+
}
12+
}
13+
14+
// Break "module.sub.handler" into the module path and exported symbol chain.
15+
function splitHandlerString(handler) {
16+
const lastDot = handler.lastIndexOf('.');
17+
if (lastDot === -1 || lastDot === handler.length - 1) {
18+
throw new Error(
19+
`CX handler "${handler}" must be in "module.submodule.handler" format.`
20+
);
21+
}
22+
return [handler.slice(0, lastDot), handler.slice(lastDot + 1)];
23+
}
24+
25+
// Walk the export tree (foo.bar.baz) to grab the final handler reference.
26+
function resolveHandler(userApp, handlerPath) {
27+
return handlerPath.split('.').reduce((acc, key) => {
28+
if (acc == null) {
29+
return undefined;
30+
}
31+
return acc[key];
32+
}, userApp);
33+
}
34+
35+
// Resolve the handler module even if Node's default lookup no longer includes it.
36+
function resolveModuleFile(modulePath) {
37+
try {
38+
const resolved = require.resolve(modulePath);
39+
logDebug(`Resolved handler module via require.resolve: ${resolved}`);
40+
return resolved;
41+
} catch (err) {
42+
if (err?.code !== 'MODULE_NOT_FOUND') {
43+
throw err;
44+
}
45+
}
46+
47+
const extensions = ['', '.js', '.cjs', '.mjs'];
48+
for (const ext of extensions) {
49+
const candidate = modulePath + ext;
50+
if (fs.existsSync(candidate)) {
51+
logDebug(`Resolved handler module via fs lookup: ${candidate}`);
52+
return candidate;
53+
}
54+
}
55+
56+
logDebug(`Falling back to unresolved handler module path: ${modulePath}`);
57+
return modulePath;
58+
}
59+
60+
// Require the user module, retrying via dynamic import when the file is ESM-only.
61+
async function loadUserModule(resolvedPath) {
62+
try {
63+
logDebug(`Attempting to require handler module: ${resolvedPath}`);
64+
// eslint-disable-next-line global-require, import/no-dynamic-require
65+
return require(resolvedPath);
66+
} catch (err) {
67+
if (err?.code === 'ERR_REQUIRE_ESM') {
68+
const url = pathToFileURL(resolvedPath).href;
69+
logDebug(
70+
`Handler module is ESM. Retrying with dynamic import: ${url} (${err.message})`
71+
);
72+
return import(url);
73+
}
74+
logDebug(
75+
`Failed to require handler module (${resolvedPath}): ${err?.message}`
76+
);
77+
throw err;
78+
}
79+
}
80+
81+
// Resolve and return the original Lambda handler function.
82+
async function load(appRoot = process.env.LAMBDA_TASK_ROOT, handlerString) {
83+
const finalHandler = handlerString ?? process.env.CX_ORIGINAL_HANDLER;
84+
if (!appRoot) {
85+
throw new Error('LAMBDA_TASK_ROOT is not defined');
86+
}
87+
if (!finalHandler) {
88+
throw new Error('CX_ORIGINAL_HANDLER is not defined');
89+
}
90+
91+
const [modulePath, handlerPath] = splitHandlerString(finalHandler);
92+
const absoluteModulePath = path.resolve(appRoot, modulePath);
93+
const resolvedModulePath = resolveModuleFile(absoluteModulePath);
94+
const userApp = await loadUserModule(resolvedModulePath);
95+
const handler = resolveHandler(userApp, handlerPath);
96+
97+
if (typeof handler !== 'function') {
98+
throw new Error(
99+
`Handler "${finalHandler}" resolved to "${handler}" instead of a function`
100+
);
101+
}
102+
103+
logDebug(`Successfully loaded handler ${finalHandler}`);
104+
return handler;
105+
}
106+
107+
module.exports = { load };

nodejs/packages/cx-wrapper/package-lock.json

Lines changed: 10 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodejs/packages/cx-wrapper/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"@opentelemetry/exporter-metrics-otlp-proto": "0.57.2",
3737
"@opentelemetry/exporter-trace-otlp-proto": "0.57.2",
3838
"@opentelemetry/instrumentation": "file:../../../opentelemetry-js/experimental/packages/opentelemetry-instrumentation/opentelemetry-instrumentation-0.207.0.tgz",
39-
"@opentelemetry/instrumentation-aws-lambda": "file:../../../opentelemetry-js-contrib-cx/packages/instrumentation-aws-lambda/opentelemetry-instrumentation-aws-lambda-0.60.1.tgz",
39+
"@opentelemetry/instrumentation-aws-lambda": "file:../../../opentelemetry-js-contrib-cx/packages/instrumentation-aws-lambda/opentelemetry-instrumentation-aws-lambda-0.61.0.tgz",
4040
"@opentelemetry/instrumentation-aws-sdk": "file:../../../opentelemetry-js-contrib-cx/packages/instrumentation-aws-sdk/opentelemetry-instrumentation-aws-sdk-0.64.0.tgz",
4141
"@opentelemetry/instrumentation-dns": "0.43.1",
4242
"@opentelemetry/instrumentation-express": "0.47.1",

0 commit comments

Comments
 (0)