Skip to content

Commit c3f2cc9

Browse files
authored
perf(runtime): use dd-trace loader registration (#819)
* perf(runtime): use dd-trace loader registration Direct Module.register() forces a loader worker even when the installed tracer supports in-process hooks. Loading the function-local tracer registration entry preserves preload and older-runtime fallbacks. Paired Lambda trials reduced module load by 79.549 ms and startup total by 65.788 ms (21.47%). * fix(runtime): preserve legacy ESM loader registration Function-local dd-trace v5.5 has loader-hook.mjs but no register.js. The fallback preserves ESM instrumentation while newer tracers use their optimized registration entry point.
1 parent b17f2f3 commit c3f2cc9

11 files changed

Lines changed: 750 additions & 44 deletions

‎.github/workflows/build.yml‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,6 @@ jobs:
6969
- name: Install dependencies
7070
run: yarn install
7171

72-
- name: Build
73-
run: yarn build
74-
7572
- name: Run tests
7673
run: yarn test
7774

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
"license": "Apache-2.0",
1313
"scripts": {
1414
"build": "tsc",
15-
"test": "jest",
16-
"test:watch": "jest --watch",
15+
"test": "node scripts/prepare_tests.js && jest",
16+
"test:watch": "node scripts/prepare_tests.js && jest --watch",
1717
"lint": "tslint --project tsconfig.json",
1818
"check-formatting": "prettier --check src/**",
1919
"format": "prettier --write src/**",

‎scripts/prepare_tests.js‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const { spawnSync } = require("node:child_process");
2+
const path = require("node:path");
3+
4+
const yarn = process.platform === "win32" ? "yarn.cmd" : "yarn";
5+
6+
/**
7+
* @param {string[]} arguments_ Yarn arguments.
8+
* @param {string} [cwd] Working directory.
9+
*/
10+
function runYarn(arguments_, cwd) {
11+
const result = spawnSync(yarn, arguments_, { cwd, stdio: "inherit" });
12+
if (result.error !== undefined) {
13+
throw result.error;
14+
}
15+
if (result.status !== 0) {
16+
process.exit(result.status ?? 1);
17+
}
18+
}
19+
20+
runYarn(["build"]);
21+
22+
const nodeMajor = Number(process.versions.node.split(".")[0]);
23+
if (nodeMajor >= 22) {
24+
runYarn(["install", "--frozen-lockfile"], path.join(__dirname, "..", "src", "runtime", "fixtures"));
25+
}

‎src/handler.mjs‎

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import * as Module from "module";
2-
import { dirname } from "path";
3-
import { pathToFileURL } from "url";
41
import {
52
datadog,
63
datadogHandlerEnvVar,
@@ -18,41 +15,8 @@ if (process.env.DD_TRACE_DISABLED_PLUGINS === undefined) {
1815
logDebug("disabled the dd-trace plugin 'fs'");
1916
}
2017

21-
// True when dd-trace's ESM loader hook is already active via NODE_OPTIONS or
22-
// execArgv. Covers every preload entry point that registers the hook:
23-
// --import dd-trace/initialize.mjs (documented ESM setup)
24-
// --loader dd-trace/initialize.mjs (legacy)
25-
// --require dd-trace/register.js (documented CJS setup; also registers the hook)
26-
// module.register() does not dedupe — a second registration chains another hooks
27-
// worker and every module would be rewritten twice.
28-
function esmLoaderAlreadyRegistered() {
29-
const sources = [process.env.NODE_OPTIONS || "", ...process.execArgv];
30-
return sources.some((source) => /dd-trace[\\/](?:[^\s]*\.mjs|register\.js)/.test(source));
31-
}
32-
3318
if (getEnvValue("DD_TRACE_ENABLED", "true").toLowerCase() === "true") {
34-
const tracer = initTracer();
35-
36-
// Register dd-trace's ESM loader hooks programmatically so that ESM imports
37-
// (e.g. @aws/durable-execution-sdk-js) are rewritten for instrumentation.
38-
// Normally this happens via --import dd-trace/initialize.mjs, but the AWS
39-
// durable runtime ignores NODE_OPTIONS so the loader is never registered.
40-
// This mirrors what dd-trace/initialize.mjs does at lines 77-84, including
41-
// only registering when the tracer initialized — a bail-out leaves the hooks
42-
// worker with nothing to instrument and can keep the process from exiting.
43-
if (tracer && typeof Module.register === "function" && !esmLoaderAlreadyRegistered()) {
44-
try {
45-
const require = Module.createRequire(import.meta.url);
46-
const ddTraceEntry = require.resolve("dd-trace", {
47-
paths: ["/var/task/node_modules", ...(require.resolve.paths("dd-trace") || [])],
48-
});
49-
const ddTraceRoot = pathToFileURL(dirname(ddTraceEntry) + "/").href;
50-
Module.register("./loader-hook.mjs", ddTraceRoot);
51-
logDebug("registered dd-trace ESM loader hook for ESM instrumentation");
52-
} catch (error) {
53-
logDebug("failed to register dd-trace ESM loader hook", { error });
54-
}
55-
}
19+
initTracer();
5620
}
5721

5822
const taskRootEnv = getEnvValue(lambdaTaskRootEnvVar, "");

‎src/handler.spec.ts‎

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { spawn } from "node:child_process";
2+
import { once } from "node:events";
3+
import * as http from "node:http";
4+
import * as Module from "node:module";
5+
import * as path from "node:path";
6+
import { pathToFileURL } from "node:url";
7+
import * as zlib from "node:zlib";
8+
9+
const fixtureDirectory = path.join(__dirname, "runtime", "fixtures");
10+
const handlerEntry = pathToFileURL(path.join(__dirname, "..", "dist", "handler.mjs")).href;
11+
const runnerPath = path.join(fixtureDirectory, "published-handler-runner.mjs");
12+
const tracePayloads: Buffer[] = [];
13+
14+
let agent: http.Server;
15+
let resolveTrace: (() => void) | undefined;
16+
let traceReceived: Promise<void>;
17+
18+
/**
19+
* @param {Buffer} body Encoded trace body.
20+
* @param {string | string[] | undefined} encoding Content encoding from the trace request.
21+
*/
22+
function recordTrace(body: Buffer, encoding: string | string[] | undefined): void {
23+
let payload = body;
24+
if (encoding === "gzip") {
25+
payload = zlib.gunzipSync(body);
26+
} else if (encoding === "deflate") {
27+
payload = zlib.inflateSync(body);
28+
}
29+
30+
tracePayloads.push(payload);
31+
resolveTrace?.();
32+
}
33+
34+
/**
35+
* @param {http.IncomingMessage} request Agent request.
36+
* @param {http.ServerResponse} response Agent response.
37+
*/
38+
function respond(request: http.IncomingMessage, response: http.ServerResponse): void {
39+
const chunks: Buffer[] = [];
40+
41+
/** @param {Buffer} chunk Request body chunk. */
42+
function recordChunk(chunk: Buffer): void {
43+
chunks.push(chunk);
44+
}
45+
46+
request.on("data", recordChunk);
47+
request.once("end", () => {
48+
response.setHeader("content-type", "application/json");
49+
50+
if (request.url === "/info") {
51+
response.end(JSON.stringify({ endpoints: ["/v0.4/traces", "/v0.5/traces"] }));
52+
return;
53+
}
54+
55+
if (request.url?.startsWith("/v0.4/traces") || request.url?.startsWith("/v0.5/traces")) {
56+
recordTrace(Buffer.concat(chunks), request.headers["content-encoding"]);
57+
}
58+
59+
response.end(JSON.stringify({ rate_by_service: {} }));
60+
});
61+
}
62+
63+
/** @param {() => void} resolve Trace promise resolver. */
64+
function captureTraceResolver(resolve: () => void): void {
65+
resolveTrace = resolve;
66+
}
67+
68+
/** @param {Promise<[unknown]>} messagePromise Child IPC message signal. */
69+
async function readChildMessage(messagePromise: Promise<[unknown]>): Promise<unknown> {
70+
const [message] = await messagePromise;
71+
return message;
72+
}
73+
74+
/** @param {boolean} traceEnabled Whether tracing is enabled for the child process. */
75+
async function runPublishedHandler(traceEnabled: boolean): Promise<unknown> {
76+
const address = agent.address();
77+
if (address === null || typeof address === "string") {
78+
throw new Error("Mock agent is not listening on a TCP port");
79+
}
80+
81+
const env: NodeJS.ProcessEnv = {
82+
...process.env,
83+
AWS_ACCESS_KEY_ID: "test-access-key",
84+
AWS_EXECUTION_ENV: `AWS_Lambda_nodejs${process.versions.node.split(".")[0]}.x`,
85+
AWS_LAMBDA_FUNCTION_MEMORY_SIZE: "1024",
86+
AWS_LAMBDA_FUNCTION_VERSION: "$LATEST",
87+
AWS_REGION: "us-east-1",
88+
AWS_SECRET_ACCESS_KEY: "test-secret-key",
89+
DD_COLD_START_TRACING: "false",
90+
DD_INSTRUMENTATION_TELEMETRY_ENABLED: "false",
91+
DD_LAMBDA_HANDLER: "esm-instrumentation-handler.handle",
92+
DD_PROFILING_ENABLED: "false",
93+
DD_REMOTE_CONFIGURATION_ENABLED: "false",
94+
DD_RUNTIME_METRICS_ENABLED: "false",
95+
DD_TELEMETRY_ENABLED: "false",
96+
DD_TRACE_AGENT_URL: `http://127.0.0.1:${address.port}`,
97+
DD_TRACE_ENABLED: String(traceEnabled),
98+
DD_TRACE_STARTUP_LOGS: "false",
99+
LAMBDA_TASK_ROOT: fixtureDirectory,
100+
HANDLER_TEST_ENTRY: handlerEntry,
101+
};
102+
delete env.NODE_OPTIONS;
103+
delete env.OTEL_LOGS_EXPORTER;
104+
delete env.OTEL_METRICS_EXPORTER;
105+
delete env.OTEL_TRACES_EXPORTER;
106+
107+
const child = spawn(process.execPath, [runnerPath], {
108+
env,
109+
stdio: ["ignore", "pipe", "pipe", "ipc"],
110+
});
111+
const closePromise = once(child, "close") as Promise<[number | null, NodeJS.Signals | null]>;
112+
const messagePromise = once(child, "message") as Promise<[unknown]>;
113+
let standardOutput = "";
114+
let standardError = "";
115+
116+
child.stdout?.setEncoding("utf8");
117+
child.stderr?.setEncoding("utf8");
118+
119+
/** @param {string} chunk Standard output chunk. */
120+
function recordStandardOutput(chunk: string): void {
121+
standardOutput += chunk;
122+
}
123+
124+
/** @param {string} chunk Standard error chunk. */
125+
function recordStandardError(chunk: string): void {
126+
standardError += chunk;
127+
}
128+
129+
async function failOnEarlyClose(): Promise<never> {
130+
const [exitCode, signal] = await closePromise;
131+
throw new Error(
132+
`Published handler exited before completing the test: code=${exitCode}, signal=${signal}` +
133+
`\nstdout:\n${standardOutput}\nstderr:\n${standardError}`,
134+
);
135+
}
136+
137+
child.stdout?.on("data", recordStandardOutput);
138+
child.stderr?.on("data", recordStandardError);
139+
const watchdog = setTimeout(() => child.kill("SIGKILL"), 15_000);
140+
watchdog.unref();
141+
142+
try {
143+
const message = await Promise.race([readChildMessage(messagePromise), failOnEarlyClose()]);
144+
145+
if (traceEnabled) {
146+
await Promise.race([traceReceived, failOnEarlyClose()]);
147+
}
148+
149+
child.send("exit");
150+
const [exitCode, signal] = await closePromise;
151+
if (exitCode !== 0 || signal !== null) {
152+
throw new Error(`Published handler failed: code=${exitCode}, signal=${signal}\n${standardError}`);
153+
}
154+
155+
return message;
156+
} finally {
157+
clearTimeout(watchdog);
158+
if (child.exitCode === null && child.signalCode === null) {
159+
child.kill("SIGKILL");
160+
}
161+
}
162+
}
163+
164+
const moduleWithRegister = Module as typeof Module & { register?: unknown };
165+
const nodeMajor = Number(process.versions.node.split(".")[0]);
166+
const supportsDurableFixture = nodeMajor >= 22 && typeof moduleWithRegister.register === "function";
167+
const describeWithESMLoader = supportsDurableFixture ? describe : describe.skip;
168+
169+
describeWithESMLoader("published ESM handler", () => {
170+
jest.setTimeout(30_000);
171+
172+
beforeAll(async () => {
173+
agent = http.createServer(respond);
174+
const listening = once(agent, "listening");
175+
agent.listen(0, "127.0.0.1");
176+
await listening;
177+
});
178+
179+
afterAll(async () => {
180+
const closed = once(agent, "close");
181+
agent.close();
182+
await closed;
183+
});
184+
185+
beforeEach(() => {
186+
tracePayloads.length = 0;
187+
traceReceived = new Promise(captureTraceResolver);
188+
});
189+
190+
it("instruments an ESM durable handler through the published handler", async () => {
191+
const message = await runPublishedHandler(true);
192+
const payload = Buffer.concat(tracePayloads);
193+
194+
expect(message).toEqual({
195+
registerLoaded: true,
196+
result: expect.objectContaining({ Status: "SUCCEEDED" }),
197+
});
198+
expect(payload.includes(Buffer.from("aws.lambda"))).toBe(true);
199+
expect(payload.includes(Buffer.from("aws.durable.execute"))).toBe(true);
200+
});
201+
202+
it("does not register tracing when tracing is disabled", async () => {
203+
const message = await runPublishedHandler(false);
204+
205+
expect(message).toEqual({
206+
registerLoaded: false,
207+
result: expect.objectContaining({ Status: "SUCCEEDED" }),
208+
});
209+
expect(tracePayloads).toHaveLength(0);
210+
});
211+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { withDurableExecution } from "@aws/durable-execution-sdk-js";
2+
3+
const durableExecutionClient = {
4+
getExecutionState() {
5+
return Promise.resolve({ Operations: [] });
6+
},
7+
checkpoint() {
8+
return Promise.resolve({
9+
CheckpointToken: "next-checkpoint-token",
10+
NewExecutionState: { Operations: [] },
11+
});
12+
},
13+
};
14+
15+
async function customerHandler() {
16+
return { statusCode: 200 };
17+
}
18+
19+
export const handle = withDurableExecution(customerHandler, { durableExecutionClient });

‎src/runtime/fixtures/package.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "datadog-lambda-js-durable-handler-fixture",
3+
"private": true,
4+
"type": "module",
5+
"dependencies": {
6+
"@aws/durable-execution-sdk-js": "2.3.0"
7+
}
8+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { once } from "node:events";
2+
import { createRequire } from "node:module";
3+
4+
const handlerEntry = process.env.HANDLER_TEST_ENTRY;
5+
if (handlerEntry === undefined) {
6+
throw new Error("HANDLER_TEST_ENTRY is required");
7+
}
8+
if (process.send === undefined) {
9+
throw new Error("The published handler runner requires an IPC channel");
10+
}
11+
12+
const { handler } = await import(handlerEntry);
13+
const require = createRequire(handlerEntry);
14+
const registerPath = require.resolve("dd-trace/register.js");
15+
const result = await handler(
16+
{
17+
DurableExecutionArn:
18+
"arn:aws:lambda:us-east-1:123456789012:function:loader-instrumentation-test:$LATEST/durable-execution/test/test-id",
19+
CheckpointToken: "checkpoint-token",
20+
InitialExecutionState: { Operations: [] },
21+
},
22+
{
23+
callbackWaitsForEmptyEventLoop: false,
24+
functionName: "loader-instrumentation-test",
25+
functionVersion: "$LATEST",
26+
invokedFunctionArn: "arn:aws:lambda:us-east-1:123456789012:function:loader-instrumentation-test",
27+
memoryLimitInMB: "1024",
28+
awsRequestId: "test-request",
29+
logGroupName: "/aws/lambda/loader-instrumentation-test",
30+
logStreamName: "2026/08/28/[$LATEST]test",
31+
getRemainingTimeInMillis() {
32+
return 30_000;
33+
},
34+
},
35+
);
36+
37+
const exitSignal = once(process, "message");
38+
process.send({
39+
registerLoaded: require.cache[registerPath] !== undefined,
40+
result,
41+
});
42+
await exitSignal;
43+
process.disconnect();

0 commit comments

Comments
 (0)