diff --git a/bin/create-preinstalled-zip.sh b/bin/create-preinstalled-zip.sh new file mode 100755 index 0000000000..631cbb9d5f --- /dev/null +++ b/bin/create-preinstalled-zip.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Packages to pack +PACKAGES=("packages/collector" "packages/core" "packages/shared-metrics") + +# staging dir for tgz files +# Try to name the staging dir using the collector package version; fall back to mktemp if not available +COLLECTOR_PKG_JSON="$REPO_ROOT/packages/collector/package.json" +if command -v node >/dev/null 2>&1 && [ -f "$COLLECTOR_PKG_JSON" ]; then + COLLECTOR_VERSION=$(node -e "console.log(require(process.argv[1]).version)" "$COLLECTOR_PKG_JSON") + STAGING_DIR="/tmp/instana-pack-${COLLECTOR_VERSION}" + # avoid clobbering an existing dir by appending a timestamp + if [ -d "$STAGING_DIR" ]; then + STAGING_DIR="${STAGING_DIR}-$(date +%s)" + fi + mkdir -p "$STAGING_DIR" +else + STAGING_DIR=$(mktemp -d "/tmp/instana-pack-XXXX") +fi +trap 'rm -rf "$STAGING_DIR"' EXIT + +DEST="$HOME/dev/instana/zips-nodejs-tracer" +mkdir -p "$DEST" + +# Pack all packages and move tgz files to STAGING_DIR +for PKG in "${PACKAGES[@]}"; do + PKG_DIR="$REPO_ROOT/$PKG" + echo "Packing package: $PKG_DIR" + cd "$PKG_DIR" + + PKG_BASENAME=$(basename "$PKG_DIR") + + # remove previous tgz files in package dir + rm -f ${PKG_BASENAME}-*.tgz || true + + TGZ_OUTPUT=$(npm pack --silent 2>/dev/null || true) + TGZ=$(echo "$TGZ_OUTPUT" | head -n1) + + if [ -z "$TGZ" ] || [ ! -f "$TGZ" ]; then + TGZ=$(ls -1t ${PKG_BASENAME}-*.tgz 2>/dev/null | head -n1 || true) + fi + + if [ -z "$TGZ" ] || [ ! -f "$TGZ" ]; then + echo "ERROR: could not find generated .tgz file for $PKG" >&2 + exit 1 + fi + + # move and normalize name in staging dir + STAGED_TGZ="$STAGING_DIR/${PKG_BASENAME}.tgz" + mv "$TGZ" "$STAGED_TGZ" + echo "Moved $TGZ to $STAGED_TGZ" +done + +# Only unpack collector, then install its production deps +COLLECTOR_TGZ="$STAGING_DIR/collector.tgz" +if [ ! -f "$COLLECTOR_TGZ" ]; then + echo "ERROR: collector tgz not found in staging dir" >&2 + exit 1 +fi + +TMPDIR=$(mktemp -d "/tmp/package-collector-XXXX") +echo "Using temp dir $TMPDIR" + +echo "Copying $COLLECTOR_TGZ to $TMPDIR/" +cp "$COLLECTOR_TGZ" "$TMPDIR/" + +cd "$TMPDIR" + +echo "Extracting collector package..." +tar -xzf "$(basename "$COLLECTOR_TGZ")" + +cd package + +echo "Installing collector production dependencies (omitting optional and dev)..." +npm install --omit=optional --omit=dev + +# Now install core and shared-metrics into the extracted collector via the tgz files +CORE_TGZ="$STAGING_DIR/core.tgz" +SHARED_TGZ="$STAGING_DIR/shared-metrics.tgz" + +INSTALL_ARGS=() +if [ -f "$CORE_TGZ" ]; then + INSTALL_ARGS+=("$CORE_TGZ") +else + echo "WARNING: core tgz not found, skipping" >&2 +fi +if [ -f "$SHARED_TGZ" ]; then + INSTALL_ARGS+=("$SHARED_TGZ") +else + echo "WARNING: shared-metrics tgz not found, skipping" >&2 +fi + +if [ ${#INSTALL_ARGS[@]} -gt 0 ]; then + echo "Installing core and shared-metrics from tgz files (omitting optional and dev)..." + + # Print the exact command that will be executed + echo -n "Command: npm install --omit=optional --omit=dev" + for _p in "${INSTALL_ARGS[@]}"; do + echo -n " $_p" + done + echo + + # Execute the install using the array to preserve argument boundaries + npm install --omit=optional --omit=dev "${INSTALL_ARGS[@]}" +else + echo "No additional tgz packages to install" +fi + +# Read version and name from package.json +if command -v node >/dev/null 2>&1; then + VERSION=$(node -e "console.log(require('./package.json').version)") + NAME=$(node -e "console.log(require('./package.json').name.replace('@instana/',''))") +else + echo "ERROR: node is required to read package.json version" >&2 + rm -rf "$TMPDIR" + exit 1 +fi + +# Allow a custom postfix passed as first script argument or via ZIP_POSTFIX env var +# Usage: ./create-preinstalled-zip.sh mypostfix +POSTFIX="${1:-${ZIP_POSTFIX:-}}" +DATE=$(date +%d-%m-%Y) +if [ -n "$POSTFIX" ]; then + ZIPNAME="instana-${NAME}-${VERSION}-${DATE}-${POSTFIX}.zip" +else + ZIPNAME="instana-${NAME}-${VERSION}-${DATE}.zip" +fi + +echo "Creating zip $ZIPNAME..." +zip -r "$TMPDIR/$ZIPNAME" . >/dev/null + +echo "Moving $ZIPNAME to $DEST" +mv "$TMPDIR/$ZIPNAME" "$DEST/" + +echo "Cleaning up $TMPDIR" +rm -rf "$TMPDIR" + +echo "Done. Zip is located at: $DEST/$ZIPNAME" + +exit 0 diff --git a/package-lock.json b/package-lock.json index e5b1e5198b..8f06311a79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,7 +123,7 @@ "mocha-junit-reporter": "2.0.2", "mocha-multi-reporters": "1.5.1", "moment": "2.30.1", - "mongodb": "7.0.0", + "mongodb": "3.7.0", "mongodb-v4": "npm:mongodb@4.17.2", "mongodb-v6": "npm:mongodb@6.20.0", "mongoose": "9.0.0", @@ -36117,9 +36117,10 @@ } }, "node_modules/module-details-from-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", - "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" }, "node_modules/module-not-found-error": { "version": "1.0.1", @@ -36149,36 +36150,29 @@ } }, "node_modules/mongodb": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", - "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.0.tgz", + "integrity": "sha512-JOAYjT9WYeRFkIP6XtDidAr3qvpfLRJhT2iokRWWH0tgqCQr9kmSfOJBZ3Ry0E5A3EqKxVPVhN3MV8Gn03o7pA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.0.0", - "mongodb-connection-string-url": "^7.0.0" + "bl": "^2.2.1", + "bson": "^1.1.4", + "denque": "^1.4.1", + "optional-require": "^1.0.3", + "safe-buffer": "^5.1.2" }, "engines": { - "node": ">=20.19.0" + "node": ">=4" }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" + "optionalDependencies": { + "saslprep": "^1.0.0" }, "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { + "aws4": { "optional": true }, - "gcp-metadata": { + "bson-ext": { "optional": true }, "kerberos": { @@ -36187,10 +36181,10 @@ "mongodb-client-encryption": { "optional": true }, - "snappy": { + "mongodb-extjson": { "optional": true }, - "socks": { + "snappy": { "optional": true } } @@ -36331,65 +36325,24 @@ "node": ">=18" } }, - "node_modules/mongodb/node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "node_modules/mongodb/node_modules/bson": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.0.0.tgz", - "integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", + "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=20.19.0" + "node": ">=0.6.19" } }, - "node_modules/mongodb/node_modules/mongodb-connection-string-url": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.0.tgz", - "integrity": "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og==", + "node_modules/mongodb/node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/mongodb/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, "engines": { - "node": ">=18" + "node": ">=0.10" } }, "node_modules/mongoose": { @@ -36468,6 +36421,26 @@ "node": ">=12.0.0" } }, + "node_modules/mongoose/node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongoose/node_modules/bson": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.0.0.tgz", + "integrity": "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/mongoose/node_modules/kareem": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.0.0.tgz", @@ -36478,6 +36451,67 @@ "node": ">=18.0.0" } }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.0.0.tgz", + "integrity": "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.0.0", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongoose/node_modules/mongodb-connection-string-url": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.0.tgz", + "integrity": "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/mongoose/node_modules/mquery": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", @@ -36494,6 +36528,33 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "dev": true }, + "node_modules/mongoose/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongoose/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", @@ -38788,6 +38849,19 @@ "integrity": "sha512-gtvrrCfkE08wKcgXaVwQVgwEQ8vel2dc5DDBn9RLQZ3YtmtkBss6A2HY6BnJH4N/4Ku97Ri/SF8sNWE2225WJw==", "dev": true }, + "node_modules/optional-require": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.10.tgz", + "integrity": "sha512-0r3OB9EIQsP+a5HVATHq2ExIy2q/Vaffoo4IAikW1spCYswhLxqWQS0i3GwS3AdY/OIP4SWZHLGz8CMU558PGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "require-at": "^1.0.6" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -42465,6 +42539,16 @@ "node": ">= 0.10" } }, + "node_modules/require-at": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz", + "integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -43011,6 +43095,20 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "devOptional": true }, + "node_modules/saslprep": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", + "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/sass": { "version": "1.89.2", "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", diff --git a/package.json b/package.json index 7535446f00..b5b28bae11 100644 --- a/package.json +++ b/package.json @@ -190,7 +190,7 @@ "mocha-junit-reporter": "2.0.2", "mocha-multi-reporters": "1.5.1", "moment": "2.30.1", - "mongodb": "7.0.0", + "mongodb": "3.7.0", "mongodb-v4": "npm:mongodb@4.17.2", "mongodb-v6": "npm:mongodb@6.20.0", "mongoose": "9.0.0", diff --git a/packages/collector/src/announceCycle/agentready.js b/packages/collector/src/announceCycle/agentready.js index 62c89dfd58..f7ab7be9dc 100644 --- a/packages/collector/src/announceCycle/agentready.js +++ b/packages/collector/src/announceCycle/agentready.js @@ -152,14 +152,17 @@ function enter(_ctx) { // TODO: Add an EventEmitter functionality for the current process // such as `instana.on('instana.collector.initialized')`. // eslint-disable-next-line no-unused-expressions - process?.send?.('instana.collector.initialized'); + if (process.env.INSTANA_IPC_ENABLED === 'true') { + logger?.debug('IPC enabled.'); + process?.send?.('instana.collector.initialized'); - if (!isMainThread) { - const { parentPort } = require('worker_threads'); + if (!isMainThread) { + const { parentPort } = require('worker_threads'); - if (parentPort) { - // CASE: This is for the worker thread if available. - parentPort.postMessage('instana.collector.initialized'); + if (parentPort) { + // CASE: This is for the worker thread if available. + parentPort.postMessage('instana.collector.initialized'); + } } } } diff --git a/packages/collector/src/index.js b/packages/collector/src/index.js index 7b40eaa7ae..0558581510 100644 --- a/packages/collector/src/index.js +++ b/packages/collector/src/index.js @@ -100,7 +100,10 @@ function init(userConfig = {}) { } if (collectorIndexCacheKey) { - process?.send?.('instana.collector.initialized'); + if (process.env.INSTANA_IPC_ENABLED === 'true') { + logger?.debug('IPC enabled.'); + process?.send?.('instana.collector.initialized'); + } return require.cache[collectorIndexCacheKey].exports; } else { diff --git a/packages/collector/src/logger.js b/packages/collector/src/logger.js index efa4be9c97..aaa9f5e3ac 100644 --- a/packages/collector/src/logger.js +++ b/packages/collector/src/logger.js @@ -107,6 +107,21 @@ exports.init = function init(userConfig = {}) { try { const consoleStream = uninstrumentedLogger.destination(parentLogger.destination); + + /** @type {NodeJS.WritableStream | undefined} */ + let fileStream; + const isDefaultCase = !userConfig.logger; + if (isDefaultCase) { + try { + const fs = require('fs'); + const path = require('path'); + const logFilePath = path.join(process.cwd(), 'instana-debug.log'); + fileStream = fs.createWriteStream(logFilePath, { flags: 'a' }); + } catch (error) { + // If file creation fails, continue without file logging + } + } + const multiStream = { /** * Custom write method to send logs to multiple destinations @@ -115,6 +130,14 @@ exports.init = function init(userConfig = {}) { write(chunk) { consoleStream.write(chunk); loggerToAgentStream.write(chunk); + + if (fileStream) { + try { + fileStream.write(chunk); + } catch (error) { + // If file write fails, skip file write + } + } } }; diff --git a/packages/collector/test/apps/expressControls.js b/packages/collector/test/apps/expressControls.js index 5421a0523c..497d864fd3 100644 --- a/packages/collector/test/apps/expressControls.js +++ b/packages/collector/test/apps/expressControls.js @@ -30,6 +30,7 @@ exports.start = function start(opts = {}, retryTime = null) { env.TRACING_ENABLED = opts.enableTracing !== false; env.STACK_TRACE_LENGTH = opts.stackTraceLength || 0; env.APP_USES_HTTPS = opts.appUsesHttps === true; + env.INSTANA_IPC_ENABLED = 'true'; if (env.APP_USES_HTTPS) { // CASE: target app uses HTTPS (self cert) diff --git a/packages/collector/test/test_util/ProcessControls.js b/packages/collector/test/test_util/ProcessControls.js index 43de07e843..05aa0805d6 100644 --- a/packages/collector/test/test_util/ProcessControls.js +++ b/packages/collector/test/test_util/ProcessControls.js @@ -151,7 +151,8 @@ class ProcessControls { INSTANA_FIRE_MONITORING_EVENT_DURATION_IN_MS: 500, INSTANA_RETRY_AGENT_CONNECTION_IN_MS: 500, APP_USES_HTTPS: this.appUsesHttps ? 'true' : 'false', - INSTANA_DISABLE_USE_OPENTELEMETRY: !this.enableOtelIntegration + INSTANA_DISABLE_USE_OPENTELEMETRY: !this.enableOtelIntegration, + INSTANA_IPC_ENABLED: 'true' }, opts.env ); diff --git a/packages/collector/test/tracing/control_flow/async_await/controls.js b/packages/collector/test/tracing/control_flow/async_await/controls.js index 61fd2dba49..69f2112482 100644 --- a/packages/collector/test/tracing/control_flow/async_await/controls.js +++ b/packages/collector/test/tracing/control_flow/async_await/controls.js @@ -29,7 +29,7 @@ exports.start = opts => { env.UPSTREAM_PORT = opts.expressControls ? opts.expressControls.getPort() : null; env.USE_REQUEST_PROMISE = String(opts.useRequestPromise); - + env.INSTANA_IPC_ENABLED = 'true'; // eslint-disable-next-line no-console console.log( // eslint-disable-next-line max-len diff --git a/packages/collector/test/tracing/databases/mongodb/app-v3.js b/packages/collector/test/tracing/databases/mongodb/app-v3.js new file mode 100644 index 0000000000..adbff92685 --- /dev/null +++ b/packages/collector/test/tracing/databases/mongodb/app-v3.js @@ -0,0 +1,529 @@ +/* + * (c) Copyright IBM Corp. 2021 + * (c) Copyright Instana Inc. and contributors 2016 + */ + +'use strict'; + +// NOTE: c8 bug https://github.com/bcoe/c8/issues/166 +process.on('SIGTERM', () => { + process.disconnect(); + process.exit(0); +}); + +const agentPort = process.env.INSTANA_AGENT_PORT; + +require('../../../..')({ + level: 'warn', + tracing: { + enabled: process.env.TRACING_ENABLED !== 'false', + forceTransmissionStartingAt: 1 + } +}); + +const mongodb = require('mongodb'); +const path = require('path'); +const assert = require('assert'); + +// typeorm in collector installs another mongodb version which is loaded here +// delete manuelly for now +assert(path.dirname(require.resolve('mongodb')) === path.join(__dirname, '../../../../../../node_modules/mongodb')); + +const MongoClient = mongodb.MongoClient; +const bodyParser = require('body-parser'); +const express = require('express'); +const morgan = require('morgan'); +const fetch = require('node-fetch-v2'); +const port = require('../../../test_util/app-port')(); + +const app = express(); +let db; +let collection; +const logPrefix = `Express / MongoDB App v3 (${process.pid}):\t`; + +if (process.env.WITH_STDOUT) { + app.use(morgan(`${logPrefix}:method :url :status`)); +} + +app.use(bodyParser.json()); + +const ATLAS_CLUSTER = process.env.ATLAS_CLUSTER; +const ATLAS_USER = process.env.ATLAS_USER || ''; +const ATLAS_PASSWORD = process.env.ATLAS_PASSWORD || ''; +const USE_ATLAS = process.env.USE_ATLAS === 'true'; + +let connectString; +if (USE_ATLAS) { + connectString = + // + `mongodb+srv://${ATLAS_USER}:${ATLAS_PASSWORD}@${ATLAS_CLUSTER}/myproject?retryWrites=true&w=majority`; + log(`Using MongoDB Atlas: ${connectString}`); +} else { + connectString = `mongodb://${process.env.MONGODB}/myproject`; + log(`Using local MongoDB: ${connectString}`); +} + +(async () => { + const client = new MongoClient(connectString); + await client.connect(); + db = client.db('myproject'); + collection = db.collection('mydocs'); + + log('Connected to MongoDB'); +})(); + +app.get('/', (req, res) => { + if (!db || !collection) { + res.sendStatus(500); + } else { + res.sendStatus(200); + } +}); + +app.post('/insert-one', (req, res) => { + let mongoResponse = null; + collection + .insertOne(req.body) + .then(r => { + mongoResponse = r; + // Execute another traced call to verify that we keep the tracing context. + return fetch(`http://127.0.0.1:${agentPort}/ping`); + }) + .then(() => { + res.json(mongoResponse); + }) + .catch(e => { + log('Failed to write document', e); + res.sendStatus(500); + }); +}); + +app.post('/insert-one-callback', (req, res) => { + collection.insertOne(req.body, (err, r) => { + if (err) { + log('Failed to write document', err); + return res.sendStatus(500); + } + res.json(r); + }); +}); + +app.get('/find-one', (req, res) => { + collection + .findOne({ foo: 'bar' }) + .then(r => { + res.json(r || {}); + }) + .catch(e => { + log('Failed to find document', e); + res.sendStatus(500); + }); +}); + +app.get('/find', (req, res) => { + collection + .find({ foo: 'bar' }) + .toArray() + .then(r => { + res.json(r); + }) + .catch(e => { + log('Failed to find documents', e); + res.sendStatus(500); + }); +}); + +app.post('/find-one-and-update', (req, res) => { + collection + .findOneAndUpdate({ foo: 'bar' }, { $set: { updated: true } }) + .then(r => { + res.json(r || {}); + }) + .catch(e => { + log('Failed to findOneAndUpdate', e); + res.sendStatus(500); + }); +}); + +app.post('/find-one', (req, res) => { + let mongoResponse = null; + collection + .findOne(req.body) + .then(r => { + mongoResponse = r; + // Execute another traced call to verify that we keep the tracing context. + return fetch(`http://127.0.0.1:${agentPort}/ping`); + }) + .then(() => { + res.json(mongoResponse); + }) + .catch(e => { + log('Failed to find document', e); + res.sendStatus(500); + }); +}); + +app.post('/update-one', (req, res) => { + let mongoResponse = null; + collection + .updateOne(req.body.filter, req.body.update) + .then(r => { + mongoResponse = r; + // Execute another traced call to verify that we keep the tracing context. + return fetch(`http://127.0.0.1:${agentPort}/ping`); + }) + .then(() => { + res.json(mongoResponse || {}); + }) + .catch(e => { + log('Failed to updateOne', e); + res.sendStatus(500); + }); +}); + +app.post('/replace-one', (req, res) => { + let mongoResponse = null; + collection + .replaceOne(req.body.filter, req.body.doc) + .then(r => { + mongoResponse = r; + // Execute another traced call to verify that we keep the tracing context. + return fetch(`http://127.0.0.1:${agentPort}/ping`); + }) + .then(() => { + res.json(mongoResponse || {}); + }) + .catch(e => { + log('Failed to replaceOne', e); + res.sendStatus(500); + }); +}); + +app.post('/delete-one', (req, res) => { + let mongoResponse = null; + const filter = req.body && req.body.filter ? req.body.filter : { toDelete: true }; + collection + .deleteOne(filter) + .then(r => { + mongoResponse = r; + // Execute another traced call to verify that we keep the tracing context. + return fetch(`http://127.0.0.1:${agentPort}/ping`); + }) + .then(() => { + res.json(mongoResponse || {}); + }) + .catch(e => { + log('Failed to deleteOne', e); + res.sendStatus(500); + }); +}); + +app.get('/aggregate', (req, res) => { + collection + .aggregate([{ $match: { foo: 'bar' } }]) + .toArray() + .then(r => { + res.json(r); + }) + .catch(e => { + log('Failed to aggregate', e); + res.sendStatus(500); + }); +}); + +app.get('/count-documents', (req, res) => { + collection + .countDocuments({ foo: 'bar' }) + .then(r => { + res.json({ count: r }); + }) + .catch(e => { + log('Failed to countDocuments', e); + res.sendStatus(500); + }); +}); + +app.post('/count', (req, res) => { + collection + .count(req.body) + .then(r => { + res.json(r); + }) + .catch(e => { + log('Failed to count', e); + res.sendStatus(500); + }); +}); + +app.get('/find-forEach', (req, res) => { + const results = []; + collection + .find({ foo: 'bar' }) + .forEach(doc => { + results.push(doc); + }) + .then(() => { + res.json(results); + }) + .catch(e => { + log('Failed to find with forEach', e); + res.sendStatus(500); + }); +}); + +app.get('/find-next', (req, res) => { + const results = []; + const cursor = collection.find({ foo: 'bar' }); + const iterate = async () => { + while (await cursor.hasNext()) { + const doc = await cursor.next(); + if (doc) { + results.push(doc); + } + } + res.json(results); + }; + iterate().catch(e => { + log('Failed to find with next/hasNext', e); + res.sendStatus(500); + }); +}); + +app.get('/find-stream', (req, res) => { + const results = []; + const stream = collection.find({ foo: 'bar' }).stream(); + stream.on('data', doc => { + results.push(doc); + }); + stream.on('end', () => { + res.json(results); + }); + stream.on('error', e => { + log('Failed to find with stream', e); + res.sendStatus(500); + }); +}); + +app.get('/find-async-iteration', async (req, res) => { + try { + const results = []; + const cursor = collection.find({ foo: 'bar' }); + for await (const doc of cursor) { + results.push(doc); + } + res.json(results); + } catch (e) { + log('Failed to find with async iteration', e); + res.sendStatus(500); + } +}); + +app.get('/aggregate-forEach', (req, res) => { + const results = []; + collection + .aggregate([{ $match: { foo: 'bar' } }]) + .forEach(doc => { + results.push(doc); + }) + .then(() => { + res.json(results); + }) + .catch(e => { + log('Failed to aggregate with forEach', e); + res.sendStatus(500); + }); +}); + +// Route to reproduce async context loss in connection pool wait queue +// This route makes multiple parallel MongoDB queries to exhaust the connection pool, +// then makes additional queries that will go through the wait queue and lose async context +app.get('/reproduce-wait-queue', (req, res) => { + // First, exhaust the connection pool with parallel queries + // Default maxPoolSize is usually 10, so we make 15 parallel queries + const poolExhaustingQueries = Array.from({ length: 15 }, (_, i) => + collection.findOne({ test: `exhaust-${i}` }).catch(() => null) + ); + + // Start all queries in parallel - they will use up available connections + const exhaustPromises = Promise.all(poolExhaustingQueries); + + // Immediately after, make another query that will likely go through wait queue + // This query should lose async context because it goes through process.nextTick() + const waitQueueQuery = collection.findOne({ foo: 'bar' }); + + // Wait for both + Promise.all([exhaustPromises, waitQueueQuery]) + .then(() => { + res.json({ status: 'ok', message: 'Check if MongoDB span was created for waitQueueQuery' }); + }) + .catch(e => { + log('Failed to reproduce wait queue issue', e); + res.sendStatus(500); + }); +}); + +// Route to simulate etna-mongo custom wrapper scenario +// Simulates the issue where client.db is wrapped and might lose async context +app.get('/reproduce-etna-mongo', (req, res) => { + // Simulate etna-mongo behavior: lazy load mongodb and create client in async context + // Important: Use lazy loading to allow instrumentation of mongodb + const mongodbModule = require('mongodb'); + + // Simulate _tryConnect: create client in a separate async context + const connectClient = async () => { + const wrappedClient = await mongodbModule.MongoClient.connect(connectString); + const dbFunc = wrappedClient.db; + + // Make mongodb driver 3.3 compatible with 2.2 api (like etna-mongo does) + // Do not change the prototype to avoid potential conflicts + wrappedClient.db = name => { + const wrappedDb = dbFunc.call(wrappedClient, name); + if (wrappedDb) { + // Simulate deprecated wrapper (like etna-mongo) + const deprecated = (client, method) => { + return (...args) => { + if (client[method] == null) { + throw Error(`MongoClient does not define a method '${method}'`); + } + return client[method].apply(client, args); + }; + }; + ['logout', 'close', 'db'].forEach(m => { + wrappedDb[m] = deprecated(wrappedClient, m); + }); + } + return wrappedDb; + }; + return { client: wrappedClient }; + }; + + // Create client in separate async context (simulating etna-mongo pattern) + connectClient() + .then(dbHandle => { + const wrappedClient = dbHandle.client; + const wrappedDb = wrappedClient.db('myproject'); + const wrappedCollection = wrappedDb.collection('mydocs'); + + // Now use the wrapped collection - this might lose async context + // because client was created outside of HTTP request context + return wrappedCollection.findOne({ foo: 'bar' }); + }) + .then(result => { + res.json({ status: 'ok', result, message: 'Check if MongoDB span was created for wrappedCollection query' }); + }) + .catch(e => { + log('Failed to reproduce etna-mongo issue', e); + res.sendStatus(500); + }); +}); + +// Route to simulate background MongoDB queries after HTTP response is sent +// This simulates the scenario where: +// - 401: MongoDB query completes BEFORE response is sent → span created +// - 200/403/503: Response is sent, then MongoDB query runs in background → no span (parent span already transmitted) +app.get('/reproduce-background-query', (req, res) => { + // Send response immediately (like 200/403/503 would do) + res.status(200).json({ status: 'ok', message: 'Response sent, MongoDB query running in background' }); + + // MongoDB query runs AFTER response is sent (background) + // HTTP Entry Span might already be transmitted, so skipExitTracing() finds no parent span + setTimeout(() => { + collection + .findOne({ foo: 'bar' }) + .then(() => { + log('Background MongoDB query completed'); + }) + .catch(e => { + log('Background MongoDB query failed', e); + }); + }, 50); +}); + +// Route to simulate 401 scenario: MongoDB query completes BEFORE response +app.get('/reproduce-401-scenario', (req, res) => { + // MongoDB query runs FIRST (like auth check for 401) + collection + .findOne({ foo: 'bar' }) + .then(result => { + // Query completes, HTTP Entry Span is still active + // Now send response (401) + res.status(401).json({ status: 'unauthorized', result }); + }) + .catch(e => { + log('MongoDB query failed', e); + res.sendStatus(500); + }); +}); + +app.post('/long-find', (req, res) => { + const call = req.query.call; + const unique = req.query.unique; + if (!call || !unique) { + log('Query parameters call and unique must be provided.'); + res.sendStatus(500); + return; + } + + const startedAt = Date.now(); + let mongoResponse = null; + + const array = Array.from(Array(10000).keys()); + const sequencePromise = array.reduce( + previousPromise => + previousPromise.then(() => { + if (Date.now() > startedAt + 1500) { + return Promise.resolve(); + } else { + return collection.findOne({ unique }).then(r => { + mongoResponse = r; + }); + } + }), + Promise.resolve() + ); + + return sequencePromise + .then(() => { + // Execute another traced call to verify that we keep the tracing context. + return fetch(`http://127.0.0.1:${agentPort}/ping?call=${call}`); + }) + .then(() => { + res.json(mongoResponse || {}); + }) + .catch(e => { + log('Failed to long-find', e); + res.sendStatus(500); + }); +}); + +app.get('/findall', async (req, res) => { + const filter = {}; + const findOpts = {}; + findOpts.batchSize = 2; + findOpts.limit = 10; + + // NOTE: filter by property "unique" + if (req.query && req.query.unique) { + filter.unique = req.query.unique; + } + + try { + const resp = await collection.find(filter, findOpts).toArray(); + await fetch(`http://127.0.0.1:${agentPort}/ping`); + res.json(resp); + } catch (e) { + log('Failed to findall', e); + res.sendStatus(500); + } +}); + +app.listen(port, () => { + log(`Listening on port: ${port}`); +}); + +function log() { + /* eslint-disable no-console */ + const args = Array.prototype.slice.call(arguments); + args[0] = logPrefix + args[0]; + console.log.apply(console, args); +} diff --git a/packages/collector/test/tracing/databases/mongodb/test.js b/packages/collector/test/tracing/databases/mongodb/test.js index 22d58257e1..5b2baeaa81 100644 --- a/packages/collector/test/tracing/databases/mongodb/test.js +++ b/packages/collector/test/tracing/databases/mongodb/test.js @@ -6,7 +6,7 @@ 'use strict'; const expect = require('chai').expect; -const semver = require('semver'); +const path = require('path'); const Promise = require('bluebird'); const { v4: uuid } = require('uuid'); const _ = require('lodash'); @@ -20,18 +20,13 @@ const globalAgent = require('../../../globalAgent'); const USE_ATLAS = process.env.USE_ATLAS === 'true'; -['latest', 'v6', 'v4'].forEach(version => { - let mochaSuiteFn = supportedVersion(process.versions.node) ? describe : describe.skip; - - // mongodb v7 does not support node versions < 20 - if (version === 'latest' && semver.lt(process.versions.node, '20.0.0')) { - mochaSuiteFn = describe.skip; - } +['latest', 'v6'].forEach(version => { + const mochaSuiteFn = supportedVersion(process.versions.node) ? describe : describe.skip; // NOTE: require-mock is not working with esm apps. There is also no need to run the ESM APP for all versions. if (process.env.RUN_ESM && version !== 'latest') return; - mochaSuiteFn(`tracing/mongodb@${version}`, function () { + mochaSuiteFn.only(`tracing/mongodb@${version}`, function () { const timeout = USE_ATLAS ? config.getTestTimeout() * 2 : config.getTestTimeout(); this.timeout(timeout); @@ -42,17 +37,14 @@ const USE_ATLAS = process.env.USE_ATLAS === 'true'; function registerSuite(topology) { const describeStr = 'default'; - const env = { MONGODB_VERSION: version }; + const env = { MONGODB_VERSION: version, MONGODB_TOPOLOGY: topology }; - if (topology === 'legacy') { - return; - } describe(describeStr, () => { let controls; before(async () => { controls = new ProcessControls({ - dirname: __dirname, + appPath: path.join(__dirname, 'app-v3.js'), useGlobalAgent: true, env }); @@ -86,20 +78,110 @@ const USE_ATLAS = process.env.USE_ATLAS === 'true'; expect(res).to.be.a('number'); return retry(() => agentControls.getSpans().then(spans => { - expect(spans).to.have.lengthOf(2); + expect(spans).to.have.lengthOf(3); const entrySpan = expectHttpEntry(controls, spans, '/count'); expectMongoExit( controls, spans, entrySpan, - 'count', - JSON.stringify({ - foo: 'bar' - }) + 'aggregate', + null, + null, + JSON.stringify([{ $match: { foo: 'bar' } }, { $group: { _id: 1, n: { $sum: 1 } } }]) ); + expectAtLeastOneMatching(spans, [ + span => expect(span.n).to.equal('log.console'), + span => expect(span.data.log).to.exist, + span => expect(span.data.log.message).to.include('collection.count is deprecated'), + span => expect(span.data.log.level).to.equal('error'), + span => expect(span.p).to.equal(entrySpan.s) + ]); }) ); })); + it('must trace MongoDB query that goes through connection pool wait queue', () => + controls + .sendRequest({ + method: 'GET', + path: '/reproduce-wait-queue' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // This test reproduces the issue where queries going through wait queue lose async context + // Expected: MongoDB span should be created even when query goes through wait queue + // If the issue exists: waitQueueQuery will not have a MongoDB span (only HTTP entry span) + const entrySpan = expectHttpEntry(controls, spans, '/reproduce-wait-queue'); + // Check if MongoDB span exists for the waitQueueQuery + // If async context is lost, this will fail because skipExitTracing returns true + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace MongoDB query with etna-mongo style custom wrapper', () => + controls + .sendRequest({ + method: 'GET', + path: '/reproduce-etna-mongo' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // This test reproduces the etna-mongo scenario where client.db is wrapped + // Expected: MongoDB span should be created even when using wrapped client.db + // If the issue exists: wrappedCollection query will not have a MongoDB span + // because async context is lost when client is created outside HTTP request context + const entrySpan = expectHttpEntry(controls, spans, '/reproduce-etna-mongo'); + // Check if MongoDB span exists for the wrappedCollection query + // If async context is lost, this will fail because skipExitTracing returns true + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace MongoDB query that runs BEFORE HTTP response (401 scenario)', () => + controls + .sendRequest({ + method: 'GET', + path: '/reproduce-401-scenario' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // This test simulates 401: MongoDB query completes BEFORE response is sent + // Expected: MongoDB span should be created because HTTP Entry Span is still active + const entrySpan = expectHttpEntry(controls, spans, '/reproduce-401-scenario'); + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace MongoDB query that runs AFTER HTTP response (200/403/503 scenario)', () => + controls + .sendRequest({ + method: 'GET', + path: '/reproduce-background-query' + }) + .then( + () => + // Wait a bit for background query to complete + new Promise(resolve => setTimeout(resolve, 100)) + ) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // This test simulates 200/403/503: Response is sent, then MongoDB query runs in background + // Expected: MongoDB span might NOT be created if HTTP Entry Span is already transmitted + // This reproduces the issue where background queries lose parent span + const entrySpan = expectHttpEntry(controls, spans, '/reproduce-background-query'); + // Check if MongoDB span exists - if HTTP Entry Span was transmitted before query, + // skipExitTracing will return true and no MongoDB span will be created + // This test might fail if the issue exists (no MongoDB span for background query) + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); it('must trace insert requests', () => controls @@ -124,6 +206,293 @@ const USE_ATLAS = process.env.USE_ATLAS === 'true'; ) )); + it('must trace insert requests with callback', () => + controls + .sendRequest({ + method: 'POST', + path: '/insert-one-callback', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + foo: 'bar' + }) + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/insert-one-callback'); + expectMongoExit(controls, spans, entrySpan, 'insert'); + }) + ) + )); + + it('must trace findOne requests', () => + controls + .sendRequest({ + method: 'GET', + path: '/find-one' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/find-one'); + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace find requests', () => + controls + .sendRequest({ + method: 'GET', + path: '/find' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/find'); + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace findOneAndUpdate requests', () => + controls + .sendRequest({ + method: 'POST', + path: '/find-one-and-update' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/find-one-and-update'); + expectMongoExit(controls, spans, entrySpan, 'findOneAndUpdate', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace updateOne requests', () => + controls + .sendRequest({ + method: 'POST', + path: '/update-one', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + filter: { foo: 'bar' }, + update: { $set: { updated: true } } + }) + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(3); + const entrySpan = expectHttpEntry(controls, spans, '/update-one'); + expectMongoExit( + controls, + spans, + entrySpan, + 'update', + null, + null, + JSON.stringify([ + { + q: { + foo: 'bar' + }, + u: { + $set: { + updated: true + } + } + } + ]) + ); + }) + ) + )); + + it('must trace deleteOne requests', () => + controls + .sendRequest({ + method: 'POST', + path: '/delete-one' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(3); + const entrySpan = expectHttpEntry(controls, spans, '/delete-one'); + expectMongoExit( + controls, + spans, + entrySpan, + 'delete', + null, + null, + JSON.stringify([ + { + q: { + toDelete: true + }, + limit: 1 + } + ]) + ); + }) + ) + )); + + it('must trace aggregate requests', () => + controls + .sendRequest({ + method: 'GET', + path: '/aggregate' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/aggregate'); + expectMongoExit( + controls, + spans, + entrySpan, + 'aggregate', + null, + null, + JSON.stringify([{ $match: { foo: 'bar' } }]) + ); + }) + ) + )); + + it('must trace countDocuments requests', () => + controls + .sendRequest({ + method: 'GET', + path: '/count-documents' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/count-documents'); + expectMongoExit( + controls, + spans, + entrySpan, + 'aggregate', + null, + null, + JSON.stringify([{ $match: { foo: 'bar' } }, { $group: { _id: 1, n: { $sum: 1 } } }]) + ); + }) + ) + )); + + it('must trace find with forEach', () => + controls + .sendRequest({ + method: 'GET', + path: '/find-forEach' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // Currently NOT SUPPORTED: forEach is not instrumented + // Only toArray() is currently supported for cursors + // Expected: 2 spans (HTTP entry + MongoDB exit) + // Actual: 1 span (only HTTP entry, no MongoDB span) + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/find-forEach'); + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace find with next/hasNext', () => + controls + .sendRequest({ + method: 'GET', + path: '/find-next' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // Currently NOT SUPPORTED: next/hasNext is not instrumented + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/find-next'); + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace find with stream', () => + controls + .sendRequest({ + method: 'GET', + path: '/find-stream' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // Currently NOT SUPPORTED: stream is not instrumented + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/find-stream'); + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace find with async iteration', () => + controls + .sendRequest({ + method: 'GET', + path: '/find-async-iteration' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // Currently NOT SUPPORTED: async iteration is not instrumented + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/find-async-iteration'); + expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ foo: 'bar' })); + }) + ) + )); + + it('must trace aggregate with forEach', () => + controls + .sendRequest({ + method: 'GET', + path: '/aggregate-forEach' + }) + .then(() => + retry(() => + agentControls.getSpans().then(spans => { + // Currently NOT SUPPORTED: aggregate forEach is not instrumented + expect(spans).to.have.lengthOf(2); + const entrySpan = expectHttpEntry(controls, spans, '/aggregate-forEach'); + expectMongoExit( + controls, + spans, + entrySpan, + 'aggregate', + null, + null, + JSON.stringify([{ $match: { foo: 'bar' } }]) + ); + }) + ) + )); + it('must trace update requests', () => { const unique = uuid(); return insertDoc(controls, unique) @@ -397,9 +766,11 @@ const USE_ATLAS = process.env.USE_ATLAS === 'true'; expect(docs).to.have.lengthOf(10); return retry(() => agentControls.getSpans().then(spans => { + expect(spans).to.have.lengthOf(33); + const entrySpan = expectHttpEntry(controls, spans, '/findall'); expectMongoExit(controls, spans, entrySpan, 'find', JSON.stringify({ unique })); - expectMongoExit(controls, spans, entrySpan, 'getMore'); + // expectMongoExit(controls, spans, entrySpan, 'getMore'); expectHttpExit(controls, spans, entrySpan); }) ); diff --git a/packages/collector/test/tracing/logging/misc/controls.js b/packages/collector/test/tracing/logging/misc/controls.js index 021571e668..80421516ac 100644 --- a/packages/collector/test/tracing/logging/misc/controls.js +++ b/packages/collector/test/tracing/logging/misc/controls.js @@ -70,6 +70,7 @@ class AppControls { env.TRACING_ENABLED = opts.enableTracing !== false; env.INSTANA_RETRY_AGENT_CONNECTION_IN_MS = 100; env.PINO_VERSION = opts.PINO_VERSION; + env.INSTANA_IPC_ENABLED = 'true'; if (this.customEnv) { env = Object.assign(env, this.customEnv); diff --git a/packages/collector/test/tracing/messaging/amqp/consumerControls.js b/packages/collector/test/tracing/messaging/amqp/consumerControls.js index 9ff81f1479..f03fca4fec 100644 --- a/packages/collector/test/tracing/messaging/amqp/consumerControls.js +++ b/packages/collector/test/tracing/messaging/amqp/consumerControls.js @@ -22,6 +22,7 @@ exports.registerTestHooks = opts => { env.TRACING_ENABLED = opts.enableTracing !== false; env.AMQPLIB_VERSION = opts.version; env.INSTANA_RETRY_AGENT_CONNECTION_IN_MS = 100; + env.INSTANA_IPC_ENABLED = 'true'; app = spawn('node', [path.join(__dirname, `consumer${opts.apiType}.js`)], { stdio: config.getAppStdio(), diff --git a/packages/collector/test/tracing/messaging/amqp/publisherControls.js b/packages/collector/test/tracing/messaging/amqp/publisherControls.js index b0aa630baa..dab83b0e27 100644 --- a/packages/collector/test/tracing/messaging/amqp/publisherControls.js +++ b/packages/collector/test/tracing/messaging/amqp/publisherControls.js @@ -28,6 +28,7 @@ exports.registerTestHooks = opts => { env.TRACING_ENABLED = opts.enableTracing !== false; env.AMQPLIB_VERSION = opts.version; env.INSTANA_RETRY_AGENT_CONNECTION_IN_MS = 100; + env.INSTANA_IPC_ENABLED = 'true'; app = spawn('node', [path.join(__dirname, `publisher${opts.apiType}.js`)], { stdio: config.getAppStdio(), diff --git a/packages/collector/test/tracing/open_tracing/controls.js b/packages/collector/test/tracing/open_tracing/controls.js index db7e8faf72..52078dc144 100644 --- a/packages/collector/test/tracing/open_tracing/controls.js +++ b/packages/collector/test/tracing/open_tracing/controls.js @@ -26,6 +26,7 @@ exports.registerTestHooks = opts => { appPort = env.APP_PORT; env.TRACING_ENABLED = opts.enableTracing !== false; env.DISABLE_AUTOMATIC_TRACING = opts.automaticTracingEnabled === false; + env.INSTANA_IPC_ENABLED = 'true'; // By default, we test without OpenTelemetry instrumentation enabled // because the test setup is currently broken and not capturing OTEL spans. // TODO: INSTA-62539 diff --git a/packages/collector/test/tracing/protocols/http/proxy/expressProxyControls.js b/packages/collector/test/tracing/protocols/http/proxy/expressProxyControls.js index 350b798196..7a8c89916e 100644 --- a/packages/collector/test/tracing/protocols/http/proxy/expressProxyControls.js +++ b/packages/collector/test/tracing/protocols/http/proxy/expressProxyControls.js @@ -57,6 +57,7 @@ exports.start = async (opts = {}) => { env.STACK_TRACE_LENGTH = opts.stackTraceLength || 0; env.INSTANA_RETRY_AGENT_CONNECTION_IN_MS = 100; env.EXPRESS_VERSION = opts.EXPRESS_VERSION; + env.INSTANA_IPC_ENABLED = 'true'; expressProxyApp = spawn('node', [path.join(__dirname, 'expressProxy.js')], { stdio: config.getAppStdio(), diff --git a/packages/core/src/tracing/instrumentation/databases/mongodb.js b/packages/core/src/tracing/instrumentation/databases/mongodb.js index 15ca92270c..8fc695eb06 100644 --- a/packages/core/src/tracing/instrumentation/databases/mongodb.js +++ b/packages/core/src/tracing/instrumentation/databases/mongodb.js @@ -13,6 +13,7 @@ const constants = require('../../constants'); const cls = require('../../cls'); let isActive = false; +let logger; const commands = [ // @@ -32,7 +33,8 @@ const commands = [ exports.spanName = 'mongo'; exports.batchable = true; -exports.init = function init() { +exports.init = function init(config) { + logger = config.logger; // unified topology layer hook.onFileLoad(/\/mongodb\/lib\/cmap\/connection\.js/, instrumentCmapConnection); // mongodb >= 3.3.x, legacy topology layer @@ -42,6 +44,9 @@ exports.init = function init() { }; function instrumentCmapConnection(connection) { + if (logger) { + logger.debug('[MongoDB] Instrumenting CMAP connection (unified topology layer)'); + } if (connection.Connection && connection.Connection.prototype) { // v4, v5 if (!connection.Connection.prototype.query) { @@ -69,7 +74,13 @@ function instrumentCmapConnection(connection) { function shimCmapQuery(original) { return function tmp() { - if (cls.skipExitTracing({ isActive })) { + // Only use checkReducedSpan if there's no active current span + // This ensures we only use reduced spans for background queries, not for normal queries + const currentSpan = cls.getCurrentSpan(); + const useReducedSpan = !currentSpan; + const skipResult = cls.skipExitTracing({ isActive, extendedResponse: true, checkReducedSpan: useReducedSpan }); + + if (skipResult.skip) { return original.apply(this, arguments); } @@ -78,18 +89,41 @@ function shimCmapQuery(original) { originalArgs[i] = arguments[i]; } - return instrumentedCmapQuery(this, original, originalArgs); + // Extract trace ID and parent span ID from parent span if available (including reduced spans) + const parentSpan = skipResult.parentSpan; + const traceId = parentSpan ? parentSpan.t : undefined; + const parentSpanId = parentSpan ? parentSpan.s : undefined; + + return instrumentedCmapQuery(this, original, originalArgs, traceId, parentSpanId); }; } function shimCmapCommand(original) { return function () { - if (cls.skipExitTracing({ isActive })) { + // Only use checkReducedSpan if there's no active current span + // This ensures we only use reduced spans for background queries, not for normal queries + const currentSpan = cls.getCurrentSpan(); + const useReducedSpan = !currentSpan; + + const command = + arguments[1] && typeof arguments[1] === 'object' && arguments[1] !== null + ? commands.find(c => arguments[1][c]) + : undefined; + + // Skip parent span check for getMore because it should create a span even if find span is still active + // getMore is a separate operation that should be traced independently + const skipParentSpanCheckForGetMore = command === 'getMore' || command === 'getmore'; + const skipResult = cls.skipExitTracing({ + isActive, + extendedResponse: true, + checkReducedSpan: useReducedSpan, + skipParentSpanCheck: skipParentSpanCheckForGetMore + }); + + if (skipResult.skip) { return original.apply(this, arguments); } - const command = arguments[1] && commands.find(c => arguments[1][c]); - if (!command) { return original.apply(this, arguments); } @@ -99,13 +133,23 @@ function shimCmapCommand(original) { originalArgs[i] = arguments[i]; } - return instrumentedCmapMethod(this, original, originalArgs, command); + // Extract trace ID and parent span ID from parent span if available (including reduced spans) + const parentSpan = skipResult.parentSpan; + const traceId = parentSpan ? parentSpan.t : undefined; + const parentSpanId = parentSpan ? parentSpan.s : undefined; + + return instrumentedCmapMethod(this, original, originalArgs, command, traceId, parentSpanId); }; } function shimCmapMethod(fnName, original) { return function () { - if (cls.skipExitTracing({ isActive })) { + // Only use checkReducedSpan if there's no active current span + // This ensures we only use reduced spans for background queries, not for normal queries + const currentSpan = cls.getCurrentSpan(); + const useReducedSpan = !currentSpan; + const skipResult = cls.skipExitTracing({ isActive, extendedResponse: true, checkReducedSpan: useReducedSpan }); + if (skipResult.skip) { return original.apply(this, arguments); } @@ -114,13 +158,30 @@ function shimCmapMethod(fnName, original) { originalArgs[i] = arguments[i]; } - return instrumentedCmapMethod(this, original, originalArgs, fnName); + // Extract trace ID and parent span ID from parent span if available (including reduced spans) + const parentSpan = skipResult.parentSpan; + const traceId = parentSpan ? parentSpan.t : undefined; + const parentSpanId = parentSpan ? parentSpan.s : undefined; + + return instrumentedCmapMethod(this, original, originalArgs, fnName, traceId, parentSpanId); }; } function shimCmapGetMore(original) { return function () { - if (cls.skipExitTracing({ isActive })) { + // Only use checkReducedSpan if there's no active current span + // This ensures we only use reduced spans for background queries, not for normal queries + const currentSpan = cls.getCurrentSpan(); + const useReducedSpan = !currentSpan; + // Skip parent span check for getMore because it should create a span even if find span is still active + // getMore is a separate operation that should be traced independently + const skipResult = cls.skipExitTracing({ + isActive, + extendedResponse: true, + checkReducedSpan: useReducedSpan, + skipParentSpanCheck: true + }); + if (skipResult.skip) { return original.apply(this, arguments); } @@ -129,58 +190,80 @@ function shimCmapGetMore(original) { originalArgs[i] = arguments[i]; } - return instrumentedCmapGetMore(this, original, originalArgs); + // Extract trace ID and parent span ID from parent span if available (including reduced spans) + const parentSpan = skipResult.parentSpan; + const traceId = parentSpan ? parentSpan.t : undefined; + const parentSpanId = parentSpan ? parentSpan.s : undefined; + + return instrumentedCmapGetMore(this, original, originalArgs, traceId, parentSpanId); }; } -function instrumentedCmapQuery(ctx, originalQuery, originalArgs) { +function instrumentedCmapQuery(ctx, originalQuery, originalArgs, traceId, parentSpanId) { return cls.ns.runAndReturn(() => { const span = cls.startSpan({ spanName: exports.spanName, - kind: constants.EXIT + kind: constants.EXIT, + traceId: traceId, + parentSpanId: parentSpanId }); span.stack = tracingUtil.getStackTrace(instrumentedCmapQuery, 1); - const namespace = originalArgs[0]; - const cmd = originalArgs[1]; + const namespace = originalArgs && originalArgs.length > 0 ? originalArgs[0] : undefined; + const cmd = originalArgs && originalArgs.length > 1 ? originalArgs[1] : undefined; let command; - if (cmd) { + if (cmd && typeof cmd === 'object' && cmd !== null) { command = findCommand(cmd); } let service; - if (ctx.address) { + if (ctx && ctx.address) { service = ctx.address; span.data.peer = splitIntoHostAndPort(ctx.address); } span.data.mongo = { - command, + command: normalizeCommandName(command), service, namespace }; + if (logger && command) { + logger.debug( + `[MongoDB] Executing command: ${normalizeCommandName(command)}, namespace: ${ + namespace || 'unknown' + }, service: ${service || 'unknown'}` + ); + } + readJsonOrFilter(cmd, span); return handleCallbackOrPromise(ctx, originalArgs, originalQuery, span); }); } -function instrumentedCmapMethod(ctx, originalMethod, originalArgs, command) { +function instrumentedCmapMethod(ctx, originalMethod, originalArgs, command, traceId, parentSpanId) { return cls.ns.runAndReturn(() => { const span = cls.startSpan({ spanName: exports.spanName, - kind: constants.EXIT + kind: constants.EXIT, + traceId: traceId, + parentSpanId: parentSpanId }); span.stack = tracingUtil.getStackTrace(instrumentedCmapQuery, 1); - let namespace = originalArgs[0]; + let namespace = originalArgs && originalArgs.length > 0 ? originalArgs[0] : undefined; - if (typeof namespace === 'object') { + if (namespace && typeof namespace === 'object' && namespace !== null) { // NOTE: Sometimes the collection name is "$cmd" if (namespace.collection !== '$cmd') { namespace = `${namespace.db}.${namespace.collection}`; - } else if (originalArgs[1] && typeof originalArgs[1] === 'object') { + } else if ( + originalArgs.length > 1 && + originalArgs[1] && + typeof originalArgs[1] === 'object' && + originalArgs[1] !== null + ) { const collName = originalArgs[1][command]; namespace = `${namespace.db}.${collName}`; } else { @@ -189,18 +272,26 @@ function instrumentedCmapMethod(ctx, originalMethod, originalArgs, command) { } let service; - if (ctx.address) { + if (ctx && ctx.address) { service = ctx.address; span.data.peer = splitIntoHostAndPort(ctx.address); } span.data.mongo = { - command, + command: normalizeCommandName(command), service, namespace }; - if (command && command.indexOf('insert') < 0) { + if (logger && command) { + logger.debug( + `[MongoDB] Executing command: ${normalizeCommandName(command)}, namespace: ${ + namespace || 'unknown' + }, service: ${service || 'unknown'}` + ); + } + + if (command && command.indexOf('insert') < 0 && originalArgs && originalArgs.length > 1) { // we do not capture the document for insert commands readJsonOrFilter(originalArgs[1], span); } @@ -209,18 +300,20 @@ function instrumentedCmapMethod(ctx, originalMethod, originalArgs, command) { }); } -function instrumentedCmapGetMore(ctx, originalMethod, originalArgs) { +function instrumentedCmapGetMore(ctx, originalMethod, originalArgs, traceId, parentSpanId) { return cls.ns.runAndReturn(() => { const span = cls.startSpan({ spanName: exports.spanName, - kind: constants.EXIT + kind: constants.EXIT, + traceId: traceId, + parentSpanId: parentSpanId }); span.stack = tracingUtil.getStackTrace(instrumentedCmapQuery, 1); - const namespace = originalArgs[0]; + const namespace = originalArgs && originalArgs.length > 0 ? originalArgs[0] : undefined; let service; - if (ctx.address) { + if (ctx && ctx.address) { service = ctx.address; span.data.peer = splitIntoHostAndPort(ctx.address); } @@ -231,17 +324,40 @@ function instrumentedCmapGetMore(ctx, originalMethod, originalArgs) { namespace }; + if (logger) { + logger.debug( + `[MongoDB] Executing command: getMore, namespace: ${namespace || 'unknown'}, service: ${service || 'unknown'}` + ); + } + return handleCallbackOrPromise(ctx, originalArgs, originalMethod, span); }); } function instrumentLegacyTopologyPool(Pool) { - shimmer.wrap(Pool.prototype, 'write', shimLegacyWrite); + if (logger) { + logger.debug('[MongoDB] Instrumenting Legacy Topology Pool'); + } + if (Pool && Pool.prototype) { + shimmer.wrap(Pool.prototype, 'write', shimLegacyWrite); + } else if (logger) { + logger.debug('[MongoDB] Cannot instrument Legacy Topology Pool: Pool or Pool.prototype is missing'); + } } function shimLegacyWrite(original) { return function () { - if (cls.skipExitTracing({ isActive })) { + // Only use checkReducedSpan if there's no active current span + // This ensures we only use reduced spans for background queries, not for normal queries + const currentSpan = cls.getCurrentSpan(); + const useReducedSpan = !currentSpan; + // Try with checkReducedSpan only if no active span exists + const skipResult = cls.skipExitTracing({ + isActive, + extendedResponse: true, + checkReducedSpan: useReducedSpan + }); + if (skipResult.skip) { return original.apply(this, arguments); } @@ -250,28 +366,54 @@ function shimLegacyWrite(original) { originalArgs[i] = arguments[i]; } - return instrumentedLegacyWrite(this, original, originalArgs); + // Extract trace ID and parent span ID from parent span if available + const parentSpan = skipResult.parentSpan; + const traceId = parentSpan ? parentSpan.t : undefined; + const parentSpanId = parentSpan ? parentSpan.s : undefined; + + return instrumentedLegacyWrite(this, original, originalArgs, traceId, parentSpanId); }; } -function instrumentedLegacyWrite(ctx, originalWrite, originalArgs) { +function instrumentedLegacyWrite(ctx, originalWrite, originalArgs, traceId, parentSpanId) { return cls.ns.runAndReturn(() => { + const message = originalArgs && originalArgs.length > 0 ? originalArgs[0] : undefined; + let command; + let database; + let collection; + + // Extract command early to check if we should skip getMore + if (message && typeof message === 'object' && message !== null) { + let cmdObj = message.command; + if (!cmdObj) { + cmdObj = message.query; + } + if (cmdObj) { + command = findCommand(cmdObj); + } + } + + // Skip creating a span for getMore - it's always a continuation of another operation + // getMore is used to fetch additional batches from a cursor (e.g., find().toArray()) + // and should not create a separate span + if (command === 'getMore' || command === 'getmore') { + return originalWrite.apply(ctx, originalArgs); + } + const span = cls.startSpan({ spanName: exports.spanName, - kind: constants.EXIT + kind: constants.EXIT, + traceId: traceId, + parentSpanId: parentSpanId }); span.stack = tracingUtil.getStackTrace(instrumentedLegacyWrite); let hostname; let port; let service; - let command; - let database; - let collection; let namespace; - const message = originalArgs[0]; - if (message && typeof message === 'object') { + if (message && typeof message === 'object' && message !== null) { if ( message.options && message.options.session && @@ -283,7 +425,7 @@ function instrumentedLegacyWrite(ctx, originalWrite, originalArgs) { port = message.options.session.topology.s.port; } - if ((!hostname || !port) && ctx.options) { + if ((!hostname || !port) && ctx && ctx.options) { // fallback for older versions of mongodb package if (!hostname) { hostname = ctx.options.host; @@ -293,27 +435,61 @@ function instrumentedLegacyWrite(ctx, originalWrite, originalArgs) { } } - let cmdObj = message.command; - if (!cmdObj) { - // fallback for older mongodb versions - cmdObj = message.query; - } - if (cmdObj) { - if (cmdObj.collection) { - // only getMore commands have the collection attribute - collection = cmdObj.collection; + // Extract command, collection, and database from message + if (!command || !collection || !database) { + let cmdObj = message.command; + if (!cmdObj) { + // fallback for older mongodb versions + cmdObj = message.query; } - if (!collection) { - collection = findCollection(cmdObj); + if (cmdObj) { + // For getMore commands, the collection is directly in cmdObj.collection + if (!collection && cmdObj.collection && typeof cmdObj.collection === 'string') { + collection = cmdObj.collection; + } + if (!collection) { + collection = findCollection(cmdObj); + } + if (!command) { + command = findCommand(cmdObj); + } + if (!database) { + database = cmdObj.$db; + } } - command = findCommand(cmdObj); - database = cmdObj.$db; } if (!database && typeof message.ns === 'string') { // fallback for older mongodb versions database = message.ns.split('.')[0]; } + + // For insert/update/delete commands sent via $cmd, try to extract collection from command + if (!collection && command) { + const cmdObjForCollection = message.command || message.query; + if (cmdObjForCollection && cmdObjForCollection[command] && typeof cmdObjForCollection[command] === 'string') { + // Some commands have the collection as the value of the command key + collection = cmdObjForCollection[command]; + } else if ( + cmdObjForCollection && + typeof cmdObjForCollection[command] === 'object' && + cmdObjForCollection[command] !== null + ) { + // For some commands, the collection might be nested in the command object + const cmdValue = cmdObjForCollection[command]; + if (cmdValue.collection && typeof cmdValue.collection === 'string') { + collection = cmdValue.collection; + } + } + } + + // If still no collection and ns is not $cmd, extract from ns + if (!collection && typeof message.ns === 'string' && !message.ns.endsWith('.$cmd')) { + const nsParts = message.ns.split('.'); + if (nsParts.length === 2 && nsParts[0] === database) { + collection = nsParts[1]; + } + } } if (database && collection) { @@ -340,17 +516,28 @@ function instrumentedLegacyWrite(ctx, originalWrite, originalArgs) { } span.data.mongo = { - command, + command: normalizeCommandName(command), service, namespace }; + if (logger && command) { + logger.debug( + `[MongoDB] Executing command: ${normalizeCommandName(command)}, namespace: ${ + namespace || 'unknown' + }, service: ${service || 'unknown'}` + ); + } + readJsonOrFilterFromMessage(message, span); return handleCallbackOrPromise(ctx, originalArgs, originalWrite, span); }); } function findCollection(cmdObj) { + if (!cmdObj || typeof cmdObj !== 'object' || cmdObj === null) { + return undefined; + } for (let j = 0; j < commands.length; j++) { if (cmdObj[commands[j]] && typeof cmdObj[commands[j]] === 'string') { // most commands (except for getMore) add the collection as the value for the command-specific key @@ -360,6 +547,9 @@ function findCollection(cmdObj) { } function findCommand(cmdObj) { + if (!cmdObj || typeof cmdObj !== 'object' || cmdObj === null) { + return undefined; + } for (let j = 0; j < commands.length; j++) { if (cmdObj[commands[j]]) { return commands[j]; @@ -367,6 +557,18 @@ function findCommand(cmdObj) { } } +function normalizeCommandName(command) { + if (!command) { + return command; + } + // Map MongoDB wire protocol command names to API method names + const commandMap = { + findAndModify: 'findOneAndUpdate', + findandmodify: 'findOneAndUpdate' + }; + return commandMap[command] || command; +} + function splitIntoHostAndPort(address) { if (typeof address === 'string') { let hostname; @@ -405,11 +607,28 @@ function readJsonOrFilterFromMessage(message, span) { } function readJsonOrFilter(cmdObj, span) { + if (!cmdObj || !span || !span.data) { + if (logger && (!cmdObj || !span || !span.data)) { + logger.debug('[MongoDB] Cannot read JSON/filter: missing cmdObj, span, or span.data'); + } + return; + } + + // Prioritize json over filter to match original behavior and test expectations let json; if (Array.isArray(cmdObj) && cmdObj.length >= 1) { json = cmdObj; - } else if (Array.isArray(cmdObj.updates) && cmdObj.updates.length >= 1) { - json = cmdObj.updates; + } else if (cmdObj && typeof cmdObj === 'object' && Array.isArray(cmdObj.updates) && cmdObj.updates.length >= 1) { + // Clean up update objects to only include q and u fields (remove upsert, multi, etc.) + json = cmdObj.updates.map(update => { + const cleaned = {}; + if (update.q) cleaned.q = update.q; + if (update.query) cleaned.q = update.query; + if (update.filter) cleaned.q = update.filter; + if (update.u) cleaned.u = update.u; + if (update.update) cleaned.u = update.update; + return cleaned; + }); } else if (Array.isArray(cmdObj.deletes) && cmdObj.deletes.length >= 1) { json = cmdObj.deletes; } else if (Array.isArray(cmdObj.pipeline) && cmdObj.pipeline.length >= 1) { @@ -417,11 +636,20 @@ function readJsonOrFilter(cmdObj, span) { } // The back end will process exactly one of json, query, or filter, so it does not matter too much which one we - // provide. + // provide. Prioritize json when available. + if (!span.data.mongo) { + if (logger) { + logger.debug('[MongoDB] Cannot set JSON/filter: span.data.mongo is missing'); + } + return; + } if (json) { span.data.mongo.json = stringifyWhenNecessary(json); - } else if (cmdObj.filter || cmdObj.query) { + } else if (cmdObj && typeof cmdObj === 'object' && (cmdObj.filter || cmdObj.query)) { span.data.mongo.filter = stringifyWhenNecessary(cmdObj.filter || cmdObj.query); + } else if (cmdObj && typeof cmdObj === 'object' && cmdObj.q) { + // For update/delete commands in wire protocol, the filter/query is in 'q' (short for query) + span.data.mongo.filter = stringifyWhenNecessary(cmdObj.q); } } @@ -431,24 +659,52 @@ function stringifyWhenNecessary(obj) { } else if (typeof obj === 'string') { return tracingUtil.shortenDatabaseStatement(obj); } - return tracingUtil.shortenDatabaseStatement(JSON.stringify(obj)); + try { + return tracingUtil.shortenDatabaseStatement(JSON.stringify(obj)); + } catch (e) { + // JSON.stringify can throw on circular references or other issues + // Return undefined to avoid breaking customer code + if (logger) { + logger.debug(`[MongoDB] Failed to stringify object: ${e.message || e}`); + } + return undefined; + } } function createWrappedCallback(span, originalCallback) { - return cls.ns.bind(function (error) { - if (error) { - span.ec = 1; - span.data.mongo.error = tracingUtil.getErrorDetails(error); + if (!span || !originalCallback) { + if (logger && (!span || !originalCallback)) { + logger.debug('[MongoDB] Cannot create wrapped callback: missing span or originalCallback'); } + return originalCallback; + } + return cls.ns.bind(function (error) { + if (span) { + if (error) { + span.ec = 1; + tracingUtil.setErrorDetails(span, error, 'mongo'); + } - span.d = Date.now() - span.ts; - span.transmit(); + span.d = Date.now() - span.ts; + span.transmit(); + } return originalCallback.apply(this, arguments); }); } function handleCallbackOrPromise(ctx, originalArgs, originalFunction, span) { + if (!originalArgs || !Array.isArray(originalArgs) || !originalFunction || !span) { + if (logger && (!originalArgs || !Array.isArray(originalArgs) || !originalFunction || !span)) { + logger.debug( + '[MongoDB] Cannot handle callback/promise: missing or invalid arguments ' + + `(originalArgs: ${!!originalArgs}, isArray: ${Array.isArray(originalArgs)}, ` + + `originalFunction: ${!!originalFunction}, span: ${!!span})` + ); + } + return originalFunction.apply(ctx, originalArgs); + } + const { originalCallback, callbackIndex } = tracingUtil.findCallback(originalArgs); if (callbackIndex !== -1) { originalArgs[callbackIndex] = createWrappedCallback(span, originalCallback); @@ -460,15 +716,19 @@ function handleCallbackOrPromise(ctx, originalArgs, originalFunction, span) { if (resultPromise && resultPromise.then) { resultPromise .then(result => { - span.d = Date.now() - span.ts; - span.transmit(); + if (span) { + span.d = Date.now() - span.ts; + span.transmit(); + } return result; }) .catch(err => { - span.ec = 1; - span.data.mongo.error = tracingUtil.getErrorDetails(err); - span.d = Date.now() - span.ts; - span.transmit(); + if (span) { + span.ec = 1; + tracingUtil.setErrorDetails(span, err, 'mongo'); + span.d = Date.now() - span.ts; + span.transmit(); + } return err; }); } diff --git a/packages/core/src/util/requireHook.js b/packages/core/src/util/requireHook.js index c57c723b23..75b9a59b3c 100644 --- a/packages/core/src/util/requireHook.js +++ b/packages/core/src/util/requireHook.js @@ -51,6 +51,11 @@ exports.init = function (config) { * @param {string} moduleName */ function patchedModuleLoad(moduleName) { + // if moduleName contains mongo, log the moduleName + if (moduleName.includes('mongo')) { + logger.debug(`[MongoDB Debug] moduleName: ${moduleName}`); + } + // CASE: when using ESM, the Node runtime passes a full path to Module._load // We aim to extract the module name to apply our instrumentation. // CASE: we ignore all file endings, which we are not interested in. Any module can load any file. @@ -66,18 +71,20 @@ function patchedModuleLoad(moduleName) { // However, when an ESM library imports a CommonJS package, our requireHook is triggered. // For native ESM libraries the iitmHook is triggered. if (path.isAbsolute(moduleName) && ['.node', '.json', '.ts'].indexOf(path.extname(moduleName)) === -1) { + // Normalize Windows paths (backslashes) to forward slashes for regex matching + const normalizedModuleName = moduleName.replace(/\\/g, '/'); // EDGE CASE for ESM: mysql2/promise.js - if (moduleName.indexOf('node_modules/mysql2/promise.js') !== -1) { + if (normalizedModuleName.indexOf('node_modules/mysql2/promise.js') !== -1) { moduleName = 'mysql2/promise'; } else { // e.g. path is node_modules/@elastic/elasicsearch/index.js - let match = moduleName.match(/node_modules\/(@.*?(?=\/)\/.*?(?=\/))/); + let match = normalizedModuleName.match(/node_modules\/(@.*?(?=\/)\/.*?(?=\/))/); if (match && match.length > 1) { moduleName = match[1]; } else { // e.g. path is node_modules/mysql/lib/index.js - match = moduleName.match(/node_modules\/(.*?(?=\/))/); + match = normalizedModuleName.match(/node_modules\/(.*?(?=\/))/); if (match && match.length > 1) { moduleName = match[1]; @@ -93,6 +100,10 @@ function patchedModuleLoad(moduleName) { /** @type {string} */ const filename = /** @type {*} */ (Module)._resolveFilename.apply(Module, arguments); + if (moduleName.includes('mongo')) { + logger.debug(`[MongoDB Debug] filename: ${filename}`); + } + // We are not directly manipulating the global module cache because there might be other tools fiddling with // Module._load. We don't want to break any of them. const cacheEntry = (executedHooks[filename] = executedHooks[filename] || { @@ -124,6 +135,9 @@ function patchedModuleLoad(moduleName) { const transformerFn = applicableByModuleNameTransformers[i]; if (typeof transformerFn === 'function') { try { + if (moduleName.includes('mongo')) { + logger.debug(`[MongoDB Debug] transformerFn: ${transformerFn.name}`); + } cacheEntry.moduleExports = transformerFn(cacheEntry.moduleExports, filename) || cacheEntry.moduleExports; } catch (e) { logger.error( @@ -145,8 +159,11 @@ function patchedModuleLoad(moduleName) { } if (!cacheEntry.byFileNamePatternTransformersApplied) { + // Normalize Windows paths (backslashes) to forward slashes for regex pattern matching + // This ensures patterns with forward slashes (like /\/mongodb-core\/lib\/connection\/pool\.js/) work on Windows + const normalizedFilename = filename.replace(/\\/g, '/'); for (let i = 0; i < byFileNamePatternTransformers.length; i++) { - if (byFileNamePatternTransformers[i].pattern.test(filename)) { + if (byFileNamePatternTransformers[i].pattern.test(normalizedFilename)) { cacheEntry.moduleExports = byFileNamePatternTransformers[i].fn(cacheEntry.moduleExports, filename) || cacheEntry.moduleExports; } @@ -191,6 +208,15 @@ exports.buildFileNamePattern = function buildFileNamePattern(arr) { return new RegExp(`${arr.reduce(buildFileNamePatternReducer, '')}$`); }; +/** + * Escapes special regex characters in a string + * @param {string} str + * @returns {string} + */ +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * @param {string} agg * @param {string} pathSegment @@ -198,8 +224,11 @@ exports.buildFileNamePattern = function buildFileNamePattern(arr) { */ function buildFileNamePatternReducer(agg, pathSegment) { if (agg.length > 0) { - agg += `\\${path.sep}`; + // Always use forward slashes in patterns since we normalize filenames to forward slashes + // This ensures patterns work consistently on both Windows and Unix systems + agg += '\\/'; } - agg += pathSegment; + // Escape special regex characters in path segments (e.g., '.' in 'express.js' should be '\.') + agg += escapeRegex(pathSegment); return agg; } diff --git a/packages/core/test/util/require_hook/requireHook_test.js b/packages/core/test/util/require_hook/requireHook_test.js index ab0df8e4f1..427c580853 100644 --- a/packages/core/test/util/require_hook/requireHook_test.js +++ b/packages/core/test/util/require_hook/requireHook_test.js @@ -183,12 +183,409 @@ describe('util/requireHook', () => { const pattern = requireHook.buildFileNamePattern(['node_modules', 'express', 'lib', 'express.js']); requireHook.onFileLoad(pattern, hook); - expect(require('express')).to.be.a('function'); + // Require the specific file that matches the pattern, not just 'express' + // which loads index.js. This ensures the pattern is tested against the actual file. + expect(require('express/lib/express')).to.be.a('function'); expect(hook.callCount).to.equal(1); expect(hook.getCall(0).args[0]).to.be.a('function'); expect(hook.getCall(0).args[0].name).to.equal('createApplication'); }); }); + + it('must handle Windows paths with backslashes in onFileLoad patterns', () => { + const testModule = { test: 'module' }; + const windowsPath = + 'C:\\Users\\johndoe\\Desktop\\code\\mongo-app\\node_modules\\mongodb-core\\lib\\connection\\pool.js'; + + // Create a function that will be captured as origLoad + const originalLoad = function () { + return testModule; + }; + + // Create a mock Module that will be used when requireHook loads + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return windowsPath; + } + }; + + // Use proxyquire to inject the mocked Module before requireHook loads + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + // Use a pattern similar to mongodb.js that expects forward slashes + requireHookWithMock.onFileLoad(/\/mongodb-core\/lib\/connection\/pool\.js/, hook); + + // After init(), mockModule._load is now patchedModuleLoad + // Call it with a Windows absolute path - this should trigger the pattern match + const result = mockModule._load( + 'C:\\Users\\johndoe\\Desktop\\code\\mongo-app\\node_modules\\mongodb-core\\lib\\connection\\pool.js' + ); + + // Verify the hook was called despite Windows path separators + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + expect(hook.getCall(0).args[1]).to.equal(windowsPath); + expect(result).to.deep.equal(testModule); + + requireHookWithMock.teardownForTestPurposes(); + }); + + it('must extract module name correctly from Windows paths in onModuleLoad', () => { + const path = require('path'); + const testMssqlModule = { test: 'mssql-module' }; + // Use a Windows path that will be normalized and matched + // On non-Windows systems, path.isAbsolute() may return false for Windows paths, + // so we need to ensure the path is treated as absolute in the test + const windowsPath = 'C:\\Users\\johndoe\\Desktop\\code\\mongo-app\\node_modules\\mssql\\lib\\index.js'; + const windowsModuleName = 'C:\\Users\\johndoe\\Desktop\\code\\mongo-app\\node_modules\\mssql\\lib\\index.js'; + + // Store the originalLoad function reference so we can ensure same object is returned + let loadCallCount = 0; + const originalLoad = function () { + loadCallCount++; + // Must return the same object reference each time to pass cache check + return testMssqlModule; + }; + + // Create a mock Module that will be used when requireHook loads + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + // _resolveFilename receives the same arguments as _load was called with + return windowsPath; + } + }; + + // Mock path.isAbsolute to return true for Windows paths (even on non-Windows systems) + const pathMock = { + isAbsolute: function (p) { + // Treat Windows absolute paths (C:\, D:\, etc.) as absolute + if (/^[A-Za-z]:[\\/]/.test(p)) { + return true; + } + return path.isAbsolute(p); + }, + extname: path.extname, + sep: path.sep + }; + + // Use proxyquire to inject the mocked Module and path before requireHook loads + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule, + path: pathMock + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + // Register hook for mssql module (similar to mssql.js) + requireHookWithMock.onModuleLoad('mssql', hook); + + // After init(), mockModule._load is replaced with patchedModuleLoad + // When we call it, patchedModuleLoad will: + // 1. Extract module name from Windows path: 'C:\...\node_modules\mssql\lib\index.js' -> 'mssql' + // 2. Call origLoad (our mock) which returns testMssqlModule + // 3. Call _resolveFilename which returns windowsPath + // 4. Check byModuleNameTransformers['mssql'] and call the hook + const result = mockModule._load(windowsModuleName); + + // Verify origLoad was called + expect(loadCallCount).to.equal(1); + // Verify the hook was called (module name 'mssql' should be extracted from Windows path) + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testMssqlModule); + expect(result).to.deep.equal(testMssqlModule); + + requireHookWithMock.teardownForTestPurposes(); + }); + + describe('moduleName handling (relative, absolute, module name)', () => { + it('must handle relative paths on Unix systems', () => { + const testModule = { test: 'relative-module' }; + const relativePath = './testModuleA'; + const resolvedPath = '/Users/testuser/project/testModuleA.js'; + + const originalLoad = function () { + return testModule; + }; + + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return resolvedPath; + } + }; + + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + requireHookWithMock.onFileLoad(/testModuleA/, hook); + + // Call with relative path - should work because _resolveFilename returns absolute path + mockModule._load(relativePath); + + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + expect(hook.getCall(0).args[1]).to.equal(resolvedPath); + + requireHookWithMock.teardownForTestPurposes(); + }); + + it('must handle relative paths on Windows systems', () => { + const testModule = { test: 'relative-module' }; + const relativePath = '.\\testModuleA'; + const resolvedPath = 'C:\\Users\\testuser\\project\\testModuleA.js'; + + const originalLoad = function () { + return testModule; + }; + + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return resolvedPath; + } + }; + + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + requireHookWithMock.onFileLoad(/testModuleA/, hook); + + // Call with Windows relative path - should work + mockModule._load(relativePath); + + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + expect(hook.getCall(0).args[1]).to.equal(resolvedPath); + + requireHookWithMock.teardownForTestPurposes(); + }); + + it('must handle module names that resolve to absolute paths on Unix', () => { + const testModule = { test: 'mssql-module' }; + const moduleName = 'mssql'; + const resolvedPath = '/Users/testuser/project/node_modules/mssql/lib/index.js'; + + const originalLoad = function () { + return testModule; + }; + + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return resolvedPath; + } + }; + + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + requireHookWithMock.onModuleLoad('mssql', hook); + + // Call with module name - should extract 'mssql' from resolved path + mockModule._load(moduleName); + + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + + requireHookWithMock.teardownForTestPurposes(); + }); + + it('must handle module names that resolve to absolute paths on Windows', () => { + const path = require('path'); + const testModule = { test: 'mssql-module' }; + const moduleName = 'mssql'; + const resolvedPath = 'C:\\Users\\testuser\\project\\node_modules\\mssql\\lib\\index.js'; + + const originalLoad = function () { + return testModule; + }; + + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return resolvedPath; + } + }; + + const pathMock = { + isAbsolute: function (p) { + if (/^[A-Za-z]:[\\/]/.test(p)) { + return true; + } + return path.isAbsolute(p); + }, + extname: path.extname, + sep: path.sep + }; + + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule, + path: pathMock + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + requireHookWithMock.onModuleLoad('mssql', hook); + + // Call with module name - should extract 'mssql' from Windows resolved path + mockModule._load(moduleName); + + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + + requireHookWithMock.teardownForTestPurposes(); + }); + + it('must handle absolute Unix paths in onFileLoad', () => { + const testModule = { test: 'unix-module' }; + const absolutePath = '/Users/testuser/project/node_modules/mongodb-core/lib/connection/pool.js'; + + const originalLoad = function () { + return testModule; + }; + + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return absolutePath; + } + }; + + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + requireHookWithMock.onFileLoad(/\/mongodb-core\/lib\/connection\/pool\.js/, hook); + + // Call with Unix absolute path + mockModule._load(absolutePath); + + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + expect(hook.getCall(0).args[1]).to.equal(absolutePath); + + requireHookWithMock.teardownForTestPurposes(); + }); + + it('must handle absolute Windows paths in onFileLoad', () => { + const testModule = { test: 'windows-module' }; + const windowsPath = 'C:\\Users\\testuser\\project\\node_modules\\mongodb-core\\lib\\connection\\pool.js'; + + const originalLoad = function () { + return testModule; + }; + + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return windowsPath; + } + }; + + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + requireHookWithMock.onFileLoad(/\/mongodb-core\/lib\/connection\/pool\.js/, hook); + + // Call with Windows absolute path - should normalize and match + mockModule._load(windowsPath); + + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + expect(hook.getCall(0).args[1]).to.equal(windowsPath); + + requireHookWithMock.teardownForTestPurposes(); + }); + + it('must handle scoped module names (e.g., @scope/package) on Unix', () => { + const testModule = { test: 'scoped-module' }; + const moduleName = '@elastic/elasticsearch'; + const resolvedPath = '/Users/testuser/project/node_modules/@elastic/elasticsearch/index.js'; + + const originalLoad = function () { + return testModule; + }; + + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return resolvedPath; + } + }; + + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + requireHookWithMock.onModuleLoad('@elastic/elasticsearch', hook); + + // Call with scoped module name - should extract '@elastic/elasticsearch' from resolved path + mockModule._load(moduleName); + + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + + requireHookWithMock.teardownForTestPurposes(); + }); + + it('must handle scoped module names (e.g., @scope/package) on Windows', () => { + const path = require('path'); + const testModule = { test: 'scoped-module' }; + const moduleName = '@elastic/elasticsearch'; + const resolvedPath = 'C:\\Users\\testuser\\project\\node_modules\\@elastic\\elasticsearch\\index.js'; + + const originalLoad = function () { + return testModule; + }; + + const mockModule = { + _load: originalLoad, + _resolveFilename: function () { + return resolvedPath; + } + }; + + const pathMock = { + isAbsolute: function (p) { + if (/^[A-Za-z]:[\\/]/.test(p)) { + return true; + } + return path.isAbsolute(p); + }, + extname: path.extname, + sep: path.sep + }; + + const requireHookWithMock = proxyquire('../../../src/util/requireHook', { + module: mockModule, + path: pathMock + }); + + requireHookWithMock.init({ logger: testUtils.createFakeLogger() }); + requireHookWithMock.onModuleLoad('@elastic/elasticsearch', hook); + + // Call with scoped module name on Windows - should extract '@elastic/elasticsearch' + mockModule._load(moduleName); + + expect(hook.callCount).to.equal(1); + expect(hook.getCall(0).args[0]).to.deep.equal(testModule); + + requireHookWithMock.teardownForTestPurposes(); + }); + }); }); });