diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f85d1846..750661c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,9 +69,6 @@ jobs: - name: Install dependencies run: yarn install - - name: Build - run: yarn build - - name: Run tests run: yarn test diff --git a/package.json b/package.json index 477a3a02..3e04d3c4 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "license": "Apache-2.0", "scripts": { "build": "tsc", - "test": "jest", - "test:watch": "jest --watch", + "test": "node scripts/prepare_tests.js && jest", + "test:watch": "node scripts/prepare_tests.js && jest --watch", "lint": "tslint --project tsconfig.json", "check-formatting": "prettier --check src/**", "format": "prettier --write src/**", diff --git a/scripts/prepare_tests.js b/scripts/prepare_tests.js new file mode 100644 index 00000000..7cca539d --- /dev/null +++ b/scripts/prepare_tests.js @@ -0,0 +1,25 @@ +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +const yarn = process.platform === "win32" ? "yarn.cmd" : "yarn"; + +/** + * @param {string[]} arguments_ Yarn arguments. + * @param {string} [cwd] Working directory. + */ +function runYarn(arguments_, cwd) { + const result = spawnSync(yarn, arguments_, { cwd, stdio: "inherit" }); + if (result.error !== undefined) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +runYarn(["build"]); + +const nodeMajor = Number(process.versions.node.split(".")[0]); +if (nodeMajor >= 22) { + runYarn(["install", "--frozen-lockfile"], path.join(__dirname, "..", "src", "runtime", "fixtures")); +} diff --git a/src/handler.mjs b/src/handler.mjs index ae586010..8e6ea3db 100644 --- a/src/handler.mjs +++ b/src/handler.mjs @@ -1,6 +1,3 @@ -import * as Module from "module"; -import { dirname } from "path"; -import { pathToFileURL } from "url"; import { datadog, datadogHandlerEnvVar, @@ -18,41 +15,8 @@ if (process.env.DD_TRACE_DISABLED_PLUGINS === undefined) { logDebug("disabled the dd-trace plugin 'fs'"); } -// True when dd-trace's ESM loader hook is already active via NODE_OPTIONS or -// execArgv. Covers every preload entry point that registers the hook: -// --import dd-trace/initialize.mjs (documented ESM setup) -// --loader dd-trace/initialize.mjs (legacy) -// --require dd-trace/register.js (documented CJS setup; also registers the hook) -// module.register() does not dedupe — a second registration chains another hooks -// worker and every module would be rewritten twice. -function esmLoaderAlreadyRegistered() { - const sources = [process.env.NODE_OPTIONS || "", ...process.execArgv]; - return sources.some((source) => /dd-trace[\\/](?:[^\s]*\.mjs|register\.js)/.test(source)); -} - if (getEnvValue("DD_TRACE_ENABLED", "true").toLowerCase() === "true") { - const tracer = initTracer(); - - // Register dd-trace's ESM loader hooks programmatically so that ESM imports - // (e.g. @aws/durable-execution-sdk-js) are rewritten for instrumentation. - // Normally this happens via --import dd-trace/initialize.mjs, but the AWS - // durable runtime ignores NODE_OPTIONS so the loader is never registered. - // This mirrors what dd-trace/initialize.mjs does at lines 77-84, including - // only registering when the tracer initialized — a bail-out leaves the hooks - // worker with nothing to instrument and can keep the process from exiting. - if (tracer && typeof Module.register === "function" && !esmLoaderAlreadyRegistered()) { - try { - const require = Module.createRequire(import.meta.url); - const ddTraceEntry = require.resolve("dd-trace", { - paths: ["/var/task/node_modules", ...(require.resolve.paths("dd-trace") || [])], - }); - const ddTraceRoot = pathToFileURL(dirname(ddTraceEntry) + "/").href; - Module.register("./loader-hook.mjs", ddTraceRoot); - logDebug("registered dd-trace ESM loader hook for ESM instrumentation"); - } catch (error) { - logDebug("failed to register dd-trace ESM loader hook", { error }); - } - } + initTracer(); } const taskRootEnv = getEnvValue(lambdaTaskRootEnvVar, ""); diff --git a/src/handler.spec.ts b/src/handler.spec.ts new file mode 100644 index 00000000..af3d2cc8 --- /dev/null +++ b/src/handler.spec.ts @@ -0,0 +1,211 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import * as http from "node:http"; +import * as Module from "node:module"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; +import * as zlib from "node:zlib"; + +const fixtureDirectory = path.join(__dirname, "runtime", "fixtures"); +const handlerEntry = pathToFileURL(path.join(__dirname, "..", "dist", "handler.mjs")).href; +const runnerPath = path.join(fixtureDirectory, "published-handler-runner.mjs"); +const tracePayloads: Buffer[] = []; + +let agent: http.Server; +let resolveTrace: (() => void) | undefined; +let traceReceived: Promise; + +/** + * @param {Buffer} body Encoded trace body. + * @param {string | string[] | undefined} encoding Content encoding from the trace request. + */ +function recordTrace(body: Buffer, encoding: string | string[] | undefined): void { + let payload = body; + if (encoding === "gzip") { + payload = zlib.gunzipSync(body); + } else if (encoding === "deflate") { + payload = zlib.inflateSync(body); + } + + tracePayloads.push(payload); + resolveTrace?.(); +} + +/** + * @param {http.IncomingMessage} request Agent request. + * @param {http.ServerResponse} response Agent response. + */ +function respond(request: http.IncomingMessage, response: http.ServerResponse): void { + const chunks: Buffer[] = []; + + /** @param {Buffer} chunk Request body chunk. */ + function recordChunk(chunk: Buffer): void { + chunks.push(chunk); + } + + request.on("data", recordChunk); + request.once("end", () => { + response.setHeader("content-type", "application/json"); + + if (request.url === "/info") { + response.end(JSON.stringify({ endpoints: ["/v0.4/traces", "/v0.5/traces"] })); + return; + } + + if (request.url?.startsWith("/v0.4/traces") || request.url?.startsWith("/v0.5/traces")) { + recordTrace(Buffer.concat(chunks), request.headers["content-encoding"]); + } + + response.end(JSON.stringify({ rate_by_service: {} })); + }); +} + +/** @param {() => void} resolve Trace promise resolver. */ +function captureTraceResolver(resolve: () => void): void { + resolveTrace = resolve; +} + +/** @param {Promise<[unknown]>} messagePromise Child IPC message signal. */ +async function readChildMessage(messagePromise: Promise<[unknown]>): Promise { + const [message] = await messagePromise; + return message; +} + +/** @param {boolean} traceEnabled Whether tracing is enabled for the child process. */ +async function runPublishedHandler(traceEnabled: boolean): Promise { + const address = agent.address(); + if (address === null || typeof address === "string") { + throw new Error("Mock agent is not listening on a TCP port"); + } + + const env: NodeJS.ProcessEnv = { + ...process.env, + AWS_ACCESS_KEY_ID: "test-access-key", + AWS_EXECUTION_ENV: `AWS_Lambda_nodejs${process.versions.node.split(".")[0]}.x`, + AWS_LAMBDA_FUNCTION_MEMORY_SIZE: "1024", + AWS_LAMBDA_FUNCTION_VERSION: "$LATEST", + AWS_REGION: "us-east-1", + AWS_SECRET_ACCESS_KEY: "test-secret-key", + DD_COLD_START_TRACING: "false", + DD_INSTRUMENTATION_TELEMETRY_ENABLED: "false", + DD_LAMBDA_HANDLER: "esm-instrumentation-handler.handle", + DD_PROFILING_ENABLED: "false", + DD_REMOTE_CONFIGURATION_ENABLED: "false", + DD_RUNTIME_METRICS_ENABLED: "false", + DD_TELEMETRY_ENABLED: "false", + DD_TRACE_AGENT_URL: `http://127.0.0.1:${address.port}`, + DD_TRACE_ENABLED: String(traceEnabled), + DD_TRACE_STARTUP_LOGS: "false", + LAMBDA_TASK_ROOT: fixtureDirectory, + HANDLER_TEST_ENTRY: handlerEntry, + }; + delete env.NODE_OPTIONS; + delete env.OTEL_LOGS_EXPORTER; + delete env.OTEL_METRICS_EXPORTER; + delete env.OTEL_TRACES_EXPORTER; + + const child = spawn(process.execPath, [runnerPath], { + env, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + const closePromise = once(child, "close") as Promise<[number | null, NodeJS.Signals | null]>; + const messagePromise = once(child, "message") as Promise<[unknown]>; + let standardOutput = ""; + let standardError = ""; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + /** @param {string} chunk Standard output chunk. */ + function recordStandardOutput(chunk: string): void { + standardOutput += chunk; + } + + /** @param {string} chunk Standard error chunk. */ + function recordStandardError(chunk: string): void { + standardError += chunk; + } + + async function failOnEarlyClose(): Promise { + const [exitCode, signal] = await closePromise; + throw new Error( + `Published handler exited before completing the test: code=${exitCode}, signal=${signal}` + + `\nstdout:\n${standardOutput}\nstderr:\n${standardError}`, + ); + } + + child.stdout?.on("data", recordStandardOutput); + child.stderr?.on("data", recordStandardError); + const watchdog = setTimeout(() => child.kill("SIGKILL"), 15_000); + watchdog.unref(); + + try { + const message = await Promise.race([readChildMessage(messagePromise), failOnEarlyClose()]); + + if (traceEnabled) { + await Promise.race([traceReceived, failOnEarlyClose()]); + } + + child.send("exit"); + const [exitCode, signal] = await closePromise; + if (exitCode !== 0 || signal !== null) { + throw new Error(`Published handler failed: code=${exitCode}, signal=${signal}\n${standardError}`); + } + + return message; + } finally { + clearTimeout(watchdog); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + } +} + +const moduleWithRegister = Module as typeof Module & { register?: unknown }; +const nodeMajor = Number(process.versions.node.split(".")[0]); +const supportsDurableFixture = nodeMajor >= 22 && typeof moduleWithRegister.register === "function"; +const describeWithESMLoader = supportsDurableFixture ? describe : describe.skip; + +describeWithESMLoader("published ESM handler", () => { + jest.setTimeout(30_000); + + beforeAll(async () => { + agent = http.createServer(respond); + const listening = once(agent, "listening"); + agent.listen(0, "127.0.0.1"); + await listening; + }); + + afterAll(async () => { + const closed = once(agent, "close"); + agent.close(); + await closed; + }); + + beforeEach(() => { + tracePayloads.length = 0; + traceReceived = new Promise(captureTraceResolver); + }); + + it("instruments an ESM durable handler through the published handler", async () => { + const message = await runPublishedHandler(true); + const payload = Buffer.concat(tracePayloads); + + expect(message).toEqual({ + registerLoaded: true, + result: expect.objectContaining({ Status: "SUCCEEDED" }), + }); + expect(payload.includes(Buffer.from("aws.lambda"))).toBe(true); + expect(payload.includes(Buffer.from("aws.durable.execute"))).toBe(true); + }); + + it("does not register tracing when tracing is disabled", async () => { + const message = await runPublishedHandler(false); + + expect(message).toEqual({ + registerLoaded: false, + result: expect.objectContaining({ Status: "SUCCEEDED" }), + }); + expect(tracePayloads).toHaveLength(0); + }); +}); diff --git a/src/runtime/fixtures/esm-instrumentation-handler.mjs b/src/runtime/fixtures/esm-instrumentation-handler.mjs new file mode 100644 index 00000000..90116d1a --- /dev/null +++ b/src/runtime/fixtures/esm-instrumentation-handler.mjs @@ -0,0 +1,19 @@ +import { withDurableExecution } from "@aws/durable-execution-sdk-js"; + +const durableExecutionClient = { + getExecutionState() { + return Promise.resolve({ Operations: [] }); + }, + checkpoint() { + return Promise.resolve({ + CheckpointToken: "next-checkpoint-token", + NewExecutionState: { Operations: [] }, + }); + }, +}; + +async function customerHandler() { + return { statusCode: 200 }; +} + +export const handle = withDurableExecution(customerHandler, { durableExecutionClient }); diff --git a/src/runtime/fixtures/package.json b/src/runtime/fixtures/package.json new file mode 100644 index 00000000..84e9bdb7 --- /dev/null +++ b/src/runtime/fixtures/package.json @@ -0,0 +1,8 @@ +{ + "name": "datadog-lambda-js-durable-handler-fixture", + "private": true, + "type": "module", + "dependencies": { + "@aws/durable-execution-sdk-js": "2.3.0" + } +} diff --git a/src/runtime/fixtures/published-handler-runner.mjs b/src/runtime/fixtures/published-handler-runner.mjs new file mode 100644 index 00000000..fa21d331 --- /dev/null +++ b/src/runtime/fixtures/published-handler-runner.mjs @@ -0,0 +1,43 @@ +import { once } from "node:events"; +import { createRequire } from "node:module"; + +const handlerEntry = process.env.HANDLER_TEST_ENTRY; +if (handlerEntry === undefined) { + throw new Error("HANDLER_TEST_ENTRY is required"); +} +if (process.send === undefined) { + throw new Error("The published handler runner requires an IPC channel"); +} + +const { handler } = await import(handlerEntry); +const require = createRequire(handlerEntry); +const registerPath = require.resolve("dd-trace/register.js"); +const result = await handler( + { + DurableExecutionArn: + "arn:aws:lambda:us-east-1:123456789012:function:loader-instrumentation-test:$LATEST/durable-execution/test/test-id", + CheckpointToken: "checkpoint-token", + InitialExecutionState: { Operations: [] }, + }, + { + callbackWaitsForEmptyEventLoop: false, + functionName: "loader-instrumentation-test", + functionVersion: "$LATEST", + invokedFunctionArn: "arn:aws:lambda:us-east-1:123456789012:function:loader-instrumentation-test", + memoryLimitInMB: "1024", + awsRequestId: "test-request", + logGroupName: "/aws/lambda/loader-instrumentation-test", + logStreamName: "2026/08/28/[$LATEST]test", + getRemainingTimeInMillis() { + return 30_000; + }, + }, +); + +const exitSignal = once(process, "message"); +process.send({ + registerLoaded: require.cache[registerPath] !== undefined, + result, +}); +await exitSignal; +process.disconnect(); diff --git a/src/runtime/fixtures/yarn.lock b/src/runtime/fixtures/yarn.lock new file mode 100644 index 00000000..9b7ab6e6 --- /dev/null +++ b/src/runtime/fixtures/yarn.lock @@ -0,0 +1,264 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@aws-sdk/client-lambda@^3.1014.0": + version "3.1120.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-lambda/-/client-lambda-3.1120.0.tgz#3f1ceb4d7175c2b65c9f549095f7448fd0602d59" + integrity sha512-Fs2f5458ZppIPAieXQMhE4PrDJ0Z7xMGoFgyGLcsmMUkK/UD4madPjlwc8J1qR71OaTX8zl3gAzuEgVJM9x6ew== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/credential-provider-node" "^3.972.81" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/fetch-http-handler" "^5.7.2" + "@smithy/node-http-handler" "^4.11.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/core@^3.977.9": + version "3.977.9" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.977.9.tgz#c3d6b4bcce3df3c86831510f18bd2ce32172b136" + integrity sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA== + dependencies: + "@aws-sdk/types" "^3.974.5" + "@aws-sdk/xml-builder" "^3.972.40" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.33.3" + "@smithy/signature-v4" "^5.6.12" + "@smithy/types" "^4.17.2" + bowser "^2.11.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-env@^3.972.70": + version "3.972.70" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz#631ef02069bae65ac7d58c6878a09a42c24cb485" + integrity sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-http@^3.972.72": + version "3.972.72" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz#9b737cc3879d900e703b219777e26d6bb542ffec" + integrity sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/fetch-http-handler" "^5.7.2" + "@smithy/node-http-handler" "^4.11.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-ini@^3.973.15": + version "3.973.15" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz#e185c40ba6b3eec90bf3cf7c364c79f639b15cca" + integrity sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/credential-provider-env" "^3.972.70" + "@aws-sdk/credential-provider-http" "^3.972.72" + "@aws-sdk/credential-provider-login" "^3.972.77" + "@aws-sdk/credential-provider-process" "^3.972.70" + "@aws-sdk/credential-provider-sso" "^3.973.14" + "@aws-sdk/credential-provider-web-identity" "^3.972.76" + "@aws-sdk/nested-clients" "^3.997.44" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/credential-provider-imds" "^4.4.16" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-login@^3.972.77": + version "3.972.77" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz#6ee6341077a6b920fc9322cfc37142a5b5bf2660" + integrity sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/nested-clients" "^3.997.44" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-node@^3.972.81": + version "3.972.81" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz#f803fd0627194e94d673416820577c38a2684b2e" + integrity sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.70" + "@aws-sdk/credential-provider-http" "^3.972.72" + "@aws-sdk/credential-provider-ini" "^3.973.15" + "@aws-sdk/credential-provider-process" "^3.972.70" + "@aws-sdk/credential-provider-sso" "^3.973.14" + "@aws-sdk/credential-provider-web-identity" "^3.972.76" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/credential-provider-imds" "^4.4.16" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-process@^3.972.70": + version "3.972.70" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz#ca9f5b334442e2ee04712840864563b89cdd5360" + integrity sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-sso@^3.973.14": + version "3.973.14" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz#064acc04b4d0ef5ae879f2c2633a33c5a9238dda" + integrity sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/nested-clients" "^3.997.44" + "@aws-sdk/token-providers" "3.1116.0" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-web-identity@^3.972.76": + version "3.972.76" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz#93a1913bfc7671008831cc80ba072470cba03bd1" + integrity sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/nested-clients" "^3.997.44" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/nested-clients@^3.997.44": + version "3.997.44" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz#ba02b5e0b12b57be425a7306a8c7b82f5eec4e0f" + integrity sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/signature-v4-multi-region" "^3.996.46" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/fetch-http-handler" "^5.7.2" + "@smithy/node-http-handler" "^4.11.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@^3.996.46": + version "3.996.46" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz#ee57afe5282da4b57968061ef8706d32890267ab" + integrity sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ== + dependencies: + "@aws-sdk/types" "^3.974.5" + "@smithy/signature-v4" "^5.6.12" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.1116.0": + version "3.1116.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz#79c23d5cfeaf63f13a49f25035e8954083e09b6a" + integrity sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q== + dependencies: + "@aws-sdk/core" "^3.977.9" + "@aws-sdk/nested-clients" "^3.997.44" + "@aws-sdk/types" "^3.974.5" + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/types@^3.974.5": + version "3.974.5" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.5.tgz#a52691531738d191b8b3fd1ae7757c2eeec7afee" + integrity sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ== + dependencies: + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws-sdk/xml-builder@^3.972.40": + version "3.972.40" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz#3cdb5f3400860ad91301aecb87fb5148a66d6ff7" + integrity sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA== + dependencies: + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@aws/durable-execution-sdk-js@2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@aws/durable-execution-sdk-js/-/durable-execution-sdk-js-2.3.0.tgz#44ee785d62ffb788fc7d3224bbfe457f5abd2936" + integrity sha512-9ppdEpGQYDEMO6rRsO1rzgLAXMH7lXjTR6qaLabdJiefnekiT59Dna1uMLG2wL1JHol86z0caYt4pGqe5/0dNA== + dependencies: + "@aws-sdk/client-lambda" "^3.1014.0" + +"@aws/lambda-invoke-store@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz#708802d987f8e17bdf4af4de1031660ce1cdd65f" + integrity sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ== + +"@smithy/core@^3.33.2", "@smithy/core@^3.33.3": + version "3.33.3" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.33.3.tgz#c1e801fe17160bcbf6c714cf16e832057e6bf79e" + integrity sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg== + dependencies: + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^4.4.16": + version "4.5.2" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz#da3ea9636b5566b36b0761382d841a6d8fdde14f" + integrity sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg== + dependencies: + "@smithy/core" "^3.33.2" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@smithy/fetch-http-handler@^5.7.2": + version "5.7.2" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz#29e95cd74fe91495225e26a60569617fecae48cc" + integrity sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw== + dependencies: + "@smithy/core" "^3.33.2" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@smithy/node-http-handler@^4.11.3": + version "4.11.3" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz#27e91a61b6de8a13e9b552f22975d7dd369cb397" + integrity sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA== + dependencies: + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@smithy/signature-v4@^5.6.12": + version "5.7.3" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.7.3.tgz#76603bf1ac9aa44e1d2f4f358b0cc0cf84961e46" + integrity sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow== + dependencies: + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + +"@smithy/types@^4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.17.2.tgz#ad71f6f62460a6409f0e8efc173e2e0f883ea4e6" + integrity sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA== + dependencies: + tslib "^2.6.2" + +bowser@^2.11.0: + version "2.14.1" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" + integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== + +tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== diff --git a/src/runtime/module-importer.spec.ts b/src/runtime/module-importer.spec.ts new file mode 100644 index 00000000..3c572a11 --- /dev/null +++ b/src/runtime/module-importer.spec.ts @@ -0,0 +1,125 @@ +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ddTracePath = require.resolve("dd-trace"); +const ddTraceRegisterPath = path.join(path.dirname(ddTracePath), "register.js"); + +type Tracer = { + use: jest.Mock; +}; + +describe("module importer", () => { + let init: jest.Mock; + let logDebug: jest.Mock; + let moduleRegister: jest.Mock; + let registerLoader: jest.Mock; + let tracer: Tracer; + let updateDDTags: jest.Mock]>; + const originalExecArgv = [...process.execArgv]; + const originalNodeOptions = process.env.NODE_OPTIONS; + + beforeEach(() => { + jest.resetModules(); + process.execArgv.splice(0, process.execArgv.length); + delete process.env.NODE_OPTIONS; + + logDebug = jest.fn(); + moduleRegister = jest.fn(); + registerLoader = jest.fn(); + tracer = { use: jest.fn() }; + init = jest.fn().mockReturnValue(tracer); + updateDDTags = jest.fn().mockReturnValue("_dd.origin:lambda"); + + jest.doMock("../utils", () => ({ logDebug, updateDDTags })); + jest.doMock(ddTracePath, () => ({ init })); + jest.doMock(ddTraceRegisterPath, () => { + registerLoader(); + return {}; + }); + jest.doMock("node:module", () => ({ + ...jest.requireActual("node:module"), + register: moduleRegister, + })); + }); + + afterEach(() => { + process.execArgv.splice(0, process.execArgv.length, ...originalExecArgv); + if (originalNodeOptions === undefined) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = originalNodeOptions; + } + }); + + it("initializes the tracer and loads its ESM registration entry point", () => { + const { initTracer } = require("./module_importer"); + + expect(initTracer()).toBe(tracer); + expect(updateDDTags).toHaveBeenCalledWith({ "_dd.origin": "lambda" }); + expect(init).toHaveBeenCalledWith({ tags: "_dd.origin:lambda" }); + expect(tracer.use).toHaveBeenCalledWith("http", { blocklist: expect.any(RegExp) }); + expect(registerLoader).toHaveBeenCalledTimes(1); + }); + + it("does not register again when NODE_OPTIONS preloads dd-trace", () => { + process.env.NODE_OPTIONS = "--import dd-trace/initialize.mjs"; + const { initTracer } = require("./module_importer"); + + expect(initTracer()).toBe(tracer); + expect(registerLoader).not.toHaveBeenCalled(); + }); + + it("does not register again when execArgv preloads dd-trace", () => { + process.execArgv.push("--require", "dd-trace/register.js"); + const { initTracer } = require("./module_importer"); + + expect(initTracer()).toBe(tracer); + expect(registerLoader).not.toHaveBeenCalled(); + }); + + it("registers when the Node arguments do not preload dd-trace", () => { + process.env.NODE_OPTIONS = "--enable-source-maps"; + process.execArgv.push("--trace-warnings"); + const { initTracer } = require("./module_importer"); + + expect(initTracer()).toBe(tracer); + expect(registerLoader).toHaveBeenCalledTimes(1); + }); + + it("falls back to the legacy loader when dd-trace has no registration entry point", () => { + const error = Object.assign(new Error(`Cannot find module '${ddTraceRegisterPath}'`), { code: "MODULE_NOT_FOUND" }); + jest.doMock(ddTraceRegisterPath, () => { + throw error; + }); + const { initTracer } = require("./module_importer"); + + expect(initTracer()).toBe(tracer); + expect(moduleRegister).toHaveBeenCalledWith( + pathToFileURL(path.join(path.dirname(ddTracePath), "loader-hook.mjs")), + ); + expect(logDebug).toHaveBeenCalledWith("registered dd-trace ESM loader hooks for ESM instrumentation"); + }); + + it("does not load the registration entry point without module.register", () => { + jest.doMock("node:module", () => ({ + ...jest.requireActual("node:module"), + register: undefined, + })); + const { initTracer } = require("./module_importer"); + + expect(initTracer()).toBe(tracer); + expect(registerLoader).not.toHaveBeenCalled(); + }); + + it("keeps the initialized tracer when loader registration fails", () => { + const error = new Error("registration failed"); + jest.doMock(ddTraceRegisterPath, () => { + throw error; + }); + const { initTracer } = require("./module_importer"); + + expect(initTracer()).toBe(tracer); + expect(moduleRegister).not.toHaveBeenCalled(); + expect(logDebug).toHaveBeenCalledWith("failed to register dd-trace ESM loader hooks", { error }); + }); +}); diff --git a/src/runtime/module_importer.js b/src/runtime/module_importer.js index 9732e022..401678e3 100644 --- a/src/runtime/module_importer.js +++ b/src/runtime/module_importer.js @@ -1,6 +1,53 @@ +const Module = require("node:module"); +const { dirname, join } = require("node:path"); +const { pathToFileURL } = require("node:url"); const { logDebug, updateDDTags } = require("../utils"); +const ddTraceLoaderPattern = /dd-trace[\\/](?:[^\s]*\.mjs|register\.js)/; + +function esmLoaderAlreadyRegistered() { + const nodeOptions = process.env.NODE_OPTIONS; + if (nodeOptions !== undefined && ddTraceLoaderPattern.test(nodeOptions)) { + return true; + } + + for (const argument of process.execArgv) { + if (ddTraceLoaderPattern.test(argument)) { + return true; + } + } + + return false; +} + +/** + * @param {string} tracerPath + */ +function registerESMLoaderHooks(tracerPath) { + if (typeof Module.register !== "function" || esmLoaderAlreadyRegistered()) { + return; + } + + const tracerDirectory = dirname(tracerPath); + const registerPath = join(tracerDirectory, "register.js"); + try { + try { + require(registerPath); + } catch (error) { + const registerMissing = error?.code === "MODULE_NOT_FOUND" && + error.message?.startsWith(`Cannot find module '${registerPath}'`); + if (!registerMissing) { + throw error; + } + Module.register(pathToFileURL(join(tracerDirectory, "loader-hook.mjs"))); + } + logDebug("registered dd-trace ESM loader hooks for ESM instrumentation"); + } catch (error) { + logDebug("failed to register dd-trace ESM loader hooks", { error }); + } +} + // Currently no way to prevent typescript from auto-transpiling import into require, // so we expose a wrapper in js exports.import = function (path) { @@ -10,16 +57,19 @@ exports.import = function (path) { exports.initTracer = function () { // Looks for the function local version of dd-trace first, before using // the version provided by the layer - const path = require.resolve("dd-trace", { paths: ["/var/task/node_modules", ...module.paths] }); + const searchPaths = ["/var/task/node_modules", ...module.paths]; + const tracerPath = require.resolve("dd-trace", { paths: searchPaths }); // tslint:disable-next-line:no-var-requires // add lambda tags to DD_TAGS environment variable const ddtags = updateDDTags({"_dd.origin": "lambda"}) - const tracer = require(path).init({tags: ddtags}); + const tracer = require(tracerPath).init({tags: ddtags}); logDebug("automatically initialized dd-trace"); // Configure the tracer to ignore HTTP calls made from the Lambda Library to the Extension tracer.use("http", { blocklist: /:8124\/lambda/, }); + // The durable runtime ignores NODE_OPTIONS, so install hooks explicitly. + registerESMLoaderHooks(tracerPath); return tracer; }