From 7fee6434586a52f2e6793ea8648fbf8a6f3b0f97 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 27 Jul 2026 17:27:22 +0200 Subject: [PATCH 01/11] refactor(test-optimization): answer "is there coverage" from the value's shape `parseSkippableSuitesResponse` leaves `coverage` undefined when the backend sends none, and the instrumentations keep that `undefined` instead of re-normalizing it to `{}`. Reinstating an empty-object default anywhere along that path turns "nothing to backfill" back into a state only an `Object.keys` probe can tell apart from real coverage. `getConfiguredEfdRetryCount` relies on `getMaxEfdRetryCount` already returning `undefined` for an empty bucket map, so the caller-side emptiness check folds into the existing fallback. --- .../datadog-instrumentations/src/cucumber.js | 14 ++++------- packages/datadog-instrumentations/src/jest.js | 24 +++++++------------ .../src/mocha/main.js | 16 ++++--------- .../src/cypress-plugin.js | 19 ++++----------- .../get-skippable-suites.js | 3 ++- packages/dd-trace/src/plugins/util/jest.js | 7 +++--- packages/dd-trace/src/plugins/util/test.js | 16 ++++++------- .../get-skippable-suites.spec.js | 10 +++++++- .../dd-trace/test/plugins/util/test.spec.js | 16 +++++++++++++ 9 files changed, 61 insertions(+), 64 deletions(-) diff --git a/packages/datadog-instrumentations/src/cucumber.js b/packages/datadog-instrumentations/src/cucumber.js index 24f923202f1..572f24b5a1b 100644 --- a/packages/datadog-instrumentations/src/cucumber.js +++ b/packages/datadog-instrumentations/src/cucumber.js @@ -116,7 +116,7 @@ let pickleByFile = {} const pickleResultByFile = {} let skippableSuites = [] -let skippableSuitesCoverage = {} +let skippableSuitesCoverage let skippedSuitesCoverage = {} let itrCorrelationId = '' let isForcedToRun = false @@ -153,12 +153,6 @@ function isValidKnownTests (receivedKnownTests) { return !!receivedKnownTests.cucumber } -function hasSkippableSuitesCoverage () { - return skippableSuitesCoverage && - typeof skippableSuitesCoverage === 'object' && - Object.keys(skippableSuitesCoverage).length > 0 -} - function isTiaCoverageBackfillEnabled () { return isItrEnabled && isCoverageReportUploadEnabled } @@ -172,7 +166,7 @@ function shouldReportCodeCoverageLinesPct (hasBackfilledCoverage) { } function getSkippedSuitesCoverageForRun () { - return isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage() + return isSuitesSkipped && isTiaCoverageBackfillEnabled() && skippableSuitesCoverage !== undefined ? skippableSuitesCoverage : {} } @@ -188,7 +182,7 @@ function getCucumberTestSessionCoverageFiles () { function resetSuiteSkippingRunState () { skippableSuites = [] - skippableSuitesCoverage = {} + skippableSuitesCoverage = undefined skippedSuitesCoverage = {} skippedSuites = [] isSuitesSkipped = false @@ -1143,7 +1137,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin errorSkippableRequest = skippableResponse.err skippableSuites = skippableResponse.skippableSuites ?? [] - skippableSuitesCoverage = skippableResponse.skippableSuitesCoverage ?? {} + skippableSuitesCoverage = skippableResponse.skippableSuitesCoverage if (!errorSkippableRequest) { const filteredPickles = isCoordinator diff --git a/packages/datadog-instrumentations/src/jest.js b/packages/datadog-instrumentations/src/jest.js index 8db1a7b324f..e24a12003de 100644 --- a/packages/datadog-instrumentations/src/jest.js +++ b/packages/datadog-instrumentations/src/jest.js @@ -112,7 +112,7 @@ const jestSessionState = (globalThis[JEST_SESSION_STATE] ||= {}) const RETRY_TIMES = Symbol.for('RETRY_TIMES') let skippableSuites = [] -let skippableSuitesCoverage = {} +let skippableSuitesCoverage let skippedSuitesCoverage = {} let knownTests = {} let isCodeCoverageEnabled = false @@ -133,7 +133,7 @@ let isTestManagementTestsEnabled = false let testManagementTests = {} let testManagementAttemptToFixRetries = 0 let isImpactedTestsEnabled = false -let modifiedFiles = {} +let modifiedFiles let repositoryRoot let lastCoverageMap let lastCoverageMapRootDir @@ -690,8 +690,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { if (this.isImpactedTestsEnabled) { try { - const hasImpactedTests = Object.keys(modifiedFiles).length > 0 - this.modifiedFiles = hasImpactedTests ? modifiedFiles : this.testEnvironmentOptions._ddModifiedFiles + this.modifiedFiles = modifiedFiles ?? this.testEnvironmentOptions._ddModifiedFiles } catch (e) { log.error('Error parsing impacted tests', e) this.isImpactedTestsEnabled = false @@ -2491,12 +2490,6 @@ function getRepositoryRootFromTest (test, fallbackRootDir) { return getRepositoryRootFromConfig(test?.context?.config, fallbackRootDir) } -function hasSkippableSuitesCoverage () { - return skippableSuitesCoverage && - typeof skippableSuitesCoverage === 'object' && - Object.keys(skippableSuitesCoverage).length > 0 -} - function shouldCollectJestCoverageForTia () { return shouldReportJestSuiteCoverageForTia() || (isJestCoverageBackfillSupported && isItrEnabled && isCoverageReportUploadEnabled) @@ -2607,7 +2600,7 @@ function resetLibraryConfiguration () { testManagementTests = {} testManagementAttemptToFixRetries = 0 isImpactedTestsEnabled = false - modifiedFiles = {} + modifiedFiles = undefined repositoryRoot = undefined } @@ -2629,13 +2622,14 @@ function applySuiteSkipping (originalTests, rootDir, frameworkVersion) { isSuitesSkipped ||= jestSuitesToRun.suitesToRun.length !== originalTests.length numSkippedSuites += jestSuitesToRun.skippedSuites.length - skippedSuitesCoverage = isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage() + const hasSkippableSuitesCoverage = skippableSuitesCoverage !== undefined + skippedSuitesCoverage = isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage ? skippableSuitesCoverage : {} coverageBackfillContexts = isSuitesSkipped && isTiaCoverageBackfillEnabled() ? getTestContexts(originalTests) : undefined - coverageBackfillFiles = isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage() + coverageBackfillFiles = isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage ? getCoverageBackfillFiles(skippableSuitesCoverage, repositoryRoot, getTestSuitePath) : undefined @@ -3024,10 +3018,10 @@ function getCliWrapper (isNewJestVersion) { skippableSuitesCoverage: receivedSkippableSuitesCoverage, } = skippableSuitesResponse || await getChannelPromise(skippableSuitesCh) if (err) { - skippableSuitesCoverage = {} + skippableSuitesCoverage = undefined } else { skippableSuites = receivedSkippableSuites - skippableSuitesCoverage = receivedSkippableSuitesCoverage || {} + skippableSuitesCoverage = receivedSkippableSuitesCoverage } skippedSuitesCoverage = {} } catch (err) { diff --git a/packages/datadog-instrumentations/src/mocha/main.js b/packages/datadog-instrumentations/src/mocha/main.js index ecf9082d533..312b3292763 100644 --- a/packages/datadog-instrumentations/src/mocha/main.js +++ b/packages/datadog-instrumentations/src/mocha/main.js @@ -92,7 +92,7 @@ let suitesToSkip = [] let isSuitesSkipped = false let areAllSuitesSkipped = false let skippedSuites = [] -let skippableSuitesCoverage = {} +let skippableSuitesCoverage let skippedSuitesCoverage = {} let itrCorrelationId = '' let isForcedToRun = false @@ -203,12 +203,6 @@ function getFilteredSuites (originalSuites) { }, { suitesToRun: [], skippedSuites: new Set(), suitesToSkipForRun }) } -function hasSkippableSuitesCoverage () { - return skippableSuitesCoverage && - typeof skippableSuitesCoverage === 'object' && - Object.keys(skippableSuitesCoverage).length > 0 -} - function isTiaCoverageBackfillEnabled () { return config.isItrEnabled && config.isCoverageReportUploadEnabled } @@ -239,7 +233,7 @@ function shouldReportCodeCoverageLinesPct (hasBackfilledCoverage) { } function getSkippedSuitesCoverageForRun () { - return isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage() + return isSuitesSkipped && isTiaCoverageBackfillEnabled() && skippableSuitesCoverage !== undefined ? skippableSuitesCoverage : {} } @@ -257,7 +251,7 @@ function resetSuiteSkippingRunState () { isSuitesSkipped = false areAllSuitesSkipped = false skippedSuites = [] - skippableSuitesCoverage = {} + skippableSuitesCoverage = undefined skippedSuitesCoverage = {} untestedCoverage = undefined config.repositoryRoot = undefined @@ -927,11 +921,11 @@ function getExecutionConfiguration (runner, isParallel, frameworkVersion, onFini } = response || {} if (!response || err) { suitesToSkip = [] - skippableSuitesCoverage = {} + skippableSuitesCoverage = undefined } else { suitesToSkip = skippableSuites itrCorrelationId = responseItrCorrelationId - skippableSuitesCoverage = responseSkippableSuitesCoverage || {} + skippableSuitesCoverage = responseSkippableSuitesCoverage } if (localSuites) { suitesToSkip = getSuitesToSkipFromPaths(localSuites) diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index a2e8da17a65..13585663d58 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -477,7 +477,7 @@ class CypressPlugin { testsToSkip = [] skippedTests = [] skippedTestIds = new Set() - skippableTestsCoverage = {} + skippableTestsCoverage testSessionCoverageMap = createCoverageMap() hasForcedToRunSuites = false hasUnskippableSuites = false @@ -566,7 +566,7 @@ class CypressPlugin { this.testsToSkip = [] this.skippedTests = [] this.skippedTestIds = new Set() - this.skippableTestsCoverage = {} + this.skippableTestsCoverage = undefined this.testSessionCoverageMap = createCoverageMap() this.hasForcedToRunSuites = false this.hasUnskippableSuites = false @@ -679,17 +679,6 @@ class CypressPlugin { return this.repositoryRoot || this.rootDir || process.cwd() } - /** - * Returns whether the backend supplied skipped-test coverage data. - * - * @returns {boolean} - */ - hasSkippableTestsCoverage () { - return !!(this.skippableTestsCoverage && - typeof this.skippableTestsCoverage === 'object' && - Object.keys(this.skippableTestsCoverage).length > 0) - } - /** * Returns whether skipped test coverage should be backfilled into the session coverage map. * @@ -699,7 +688,7 @@ class CypressPlugin { return this.isItrEnabled && this.isCoverageReportUploadEnabled && this.isTestsSkipped && - this.hasSkippableTestsCoverage() + this.skippableTestsCoverage !== undefined } /** @@ -1162,7 +1151,7 @@ class CypressPlugin { } else { const { skippableTests, correlationId, skippableTestsCoverage } = skippableTestsResponse this.testsToSkip = skippableTests || [] - this.skippableTestsCoverage = skippableTestsCoverage || {} + this.skippableTestsCoverage = skippableTestsCoverage this.itrCorrelationId = correlationId incrementCountMetric(TELEMETRY_ITR_SKIPPED, { testLevel: 'test' }, this.testsToSkip.length) } diff --git a/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js b/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js index 82a6515eb16..79db5f842eb 100644 --- a/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +++ b/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js @@ -34,10 +34,11 @@ function parseSkippableSuitesResponse ( if (validateRequiredFields) { validateSkippableTestsResponse(parsedResponse, { validationMode }) } - const coverage = {} + let coverage const coverageByFilename = parsedResponse.meta?.coverage if (coverageByFilename) { for (const [filename, bitmap] of Object.entries(coverageByFilename)) { + coverage ??= {} coverage[filename.replaceAll('\\', '/')] = bitmap } } diff --git a/packages/dd-trace/src/plugins/util/jest.js b/packages/dd-trace/src/plugins/util/jest.js index e65fb540cbd..8c9de67e360 100644 --- a/packages/dd-trace/src/plugins/util/jest.js +++ b/packages/dd-trace/src/plugins/util/jest.js @@ -68,6 +68,8 @@ function getJestTestName (test) { function getJestSuitesToRun (skippableSuites, originalTests, rootDir, fallbackRootDir) { const unskippableSuites = {} const forcedToRunSuites = {} + let hasUnskippableSuites = false + let hasForcedToRunSuites = false const skippedSuites = [] const suitesToRun = [] @@ -87,11 +89,13 @@ function getJestSuitesToRun (skippableSuites, originalTests, rootDir, fallbackRo if (isMarkedAsUnskippable(test)) { suitesToRun.push(test) unskippableSuites[relativePath] = true + hasUnskippableSuites = true if (fallbackRelativePath !== undefined) { unskippableSuites[fallbackRelativePath] = true } if (skippedSuite !== undefined) { forcedToRunSuites[relativePath] = true + hasForcedToRunSuites = true if (fallbackRelativePath !== undefined) { forcedToRunSuites[fallbackRelativePath] = true } @@ -105,9 +109,6 @@ function getJestSuitesToRun (skippableSuites, originalTests, rootDir, fallbackRo } } - const hasUnskippableSuites = Object.keys(unskippableSuites).length > 0 - const hasForcedToRunSuites = Object.keys(forcedToRunSuites).length > 0 - if (originalTests.length) { // The config object is shared by all tests, so we can just take the first one const [test] = originalTests diff --git a/packages/dd-trace/src/plugins/util/test.js b/packages/dd-trace/src/plugins/util/test.js index f3d57b722f4..6778fe2c77a 100644 --- a/packages/dd-trace/src/plugins/util/test.js +++ b/packages/dd-trace/src/plugins/util/test.js @@ -1439,10 +1439,6 @@ function addSkippedCoverageToMap (skippedCoverage, targetMap) { } } -function hasSkippedCoverage (skippedCoverage) { - return skippedCoverage && typeof skippedCoverage === 'object' && Object.keys(skippedCoverage).length > 0 -} - function getTestCoverageLinesPercentage (coverage, skippedCoverage, rootDir) { const executableLinesByFile = new Map() const coveredLinesByFile = new Map() @@ -1496,10 +1492,10 @@ function applySkippedCoverageToFileCoverage (fileCoverage, skippedBitmap) { * @returns {boolean} */ function applySkippedCoverageToCoverage (coverage, skippedCoverage, rootDir) { - if (!hasSkippedCoverage(skippedCoverage)) return false + const skippedCoverageByFilename = getSkippedCoverageByFilename(skippedCoverage) + if (skippedCoverageByFilename.size === 0) return false const coverageMap = getCoverageMap(coverage) - const skippedCoverageByFilename = getSkippedCoverageByFilename(skippedCoverage) let matched = false for (const filename of coverageMap.files()) { @@ -1820,6 +1816,7 @@ function getPullRequestBaseBranch (pullRequestBaseBranch) { } const metrics = {} + let hasMetrics = false for (const candidate of candidateBranches) { // Find common ancestor const baseSha = getMergeBase(candidate, sourceBranch) @@ -1838,6 +1835,7 @@ function getPullRequestBaseBranch (pullRequestBaseBranch) { ahead, baseSha, } + hasMetrics = true } function isDefaultBranch (branch) { @@ -1846,7 +1844,7 @@ function getPullRequestBaseBranch (pullRequestBaseBranch) { ) } - if (Object.keys(metrics).length === 0) { + if (!hasMetrics) { return null } // Find branch with smallest "ahead" value, preferring default branch on tie @@ -1872,6 +1870,7 @@ function getPullRequestDiff (baseCommit, targetCommit) { function getModifiedFilesFromDiff (diff) { if (!diff) return null const result = {} + let hasModifiedFiles = false const filesRegex = /^diff --git a\/(?.+) b\/(?.+)$/g const linesRegex = /^@@ -\d+(,\d+)? \+(?\d+)(,(?\d+))? @@/g @@ -1886,6 +1885,7 @@ function getModifiedFilesFromDiff (diff) { if (fileMatch && fileMatch.groups.file) { currentFile = fileMatch.groups.file result[currentFile] = [] + hasModifiedFiles = true continue } @@ -1904,7 +1904,7 @@ function getModifiedFilesFromDiff (diff) { linesRegex.lastIndex = 0 } - if (Object.keys(result).length === 0) { + if (!hasModifiedFiles) { return null } return result diff --git a/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js b/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js index 39b615a8ef4..05c26e94b5e 100644 --- a/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js +++ b/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js @@ -425,12 +425,20 @@ describe('parseSkippableSuitesResponse', () => { assert.deepStrictEqual(result, { skippableSuites: [{ suite: 'suite1.spec.js', name: 'test 1' }], correlationId: 'corr-123', - coverage: {}, + coverage: undefined, numReceivedSkippableItems: 1, numExcludedByMissingLineCoverage: 0, }) }) + it('leaves coverage undefined when the response carries none', () => { + const withoutCoverage = parseSkippableSuitesResponse(JSON.stringify({ data: [], meta: {} })) + const withEmptyCoverage = parseSkippableSuitesResponse(JSON.stringify({ data: [], meta: { coverage: {} } })) + + assert.strictEqual(withoutCoverage.coverage, undefined) + assert.strictEqual(withEmptyCoverage.coverage, undefined) + }) + it('filters missing line coverage when coverage report upload is enabled', () => { const result = parseSkippableSuitesResponse(JSON.stringify(SKIPPABLE_RESPONSE_WITH_MISSING_LINE_COVERAGE), { testLevel: 'suite', diff --git a/packages/dd-trace/test/plugins/util/test.spec.js b/packages/dd-trace/test/plugins/util/test.spec.js index 3cd3b6d7170..9a9189f9ee5 100644 --- a/packages/dd-trace/test/plugins/util/test.spec.js +++ b/packages/dd-trace/test/plugins/util/test.spec.js @@ -1819,6 +1819,7 @@ index 1234567..89abcde 100644 assert.strictEqual(getModifiedFilesFromDiff(''), null) assert.strictEqual(getModifiedFilesFromDiff(null), null) assert.strictEqual(getModifiedFilesFromDiff(undefined), null) + assert.strictEqual(getModifiedFilesFromDiff('not a diff\n@@ -1 +1 @@\n'), null) }) it('should handle multiple line changes in a single hunk', () => { @@ -1965,6 +1966,21 @@ describe('getPullRequestBaseBranch', () => { sinon.assert.calledWith(getCountsStub, 'master', 'feature-branch') sinon.assert.calledWith(getCountsStub, 'trunk', 'feature-branch') }) + + it('returns null when no candidate branch has a merge base', () => { + const { getPullRequestBaseBranch } = proxyquire('../../../src/plugins/util/test', { + './git': { + getGitRemoteName: () => 'origin', + getSourceBranch: () => 'feature-branch', + getMergeBase: sinon.stub().returns(undefined), + checkAndFetchBranch: sinon.stub(), + getLocalBranches: sinon.stub().returns(['trunk', 'master', 'feature-branch']), + getCounts: sinon.stub().returns({ ahead: 0, behind: 0 }), + }, + }) + + assert.strictEqual(getPullRequestBaseBranch(), null) + }) }) }) From b6e62a611d3d97549f7674b49940fb308ba37992 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 27 Jul 2026 17:45:28 +0200 Subject: [PATCH 02/11] refactor(openai): create the log store only when there is something to log The extraction helpers return a store instead of filling one the caller allocated up front, so `store.openai` stays undefined for the methods that record nothing. `sendLog` keys off that: any helper that starts writing to the store must create it, or its data never reaches the log. --- packages/datadog-plugin-openai/src/tracing.js | 71 +++++++++++-------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/packages/datadog-plugin-openai/src/tracing.js b/packages/datadog-plugin-openai/src/tracing.js index 5589f6ecde4..9edbcb566de 100644 --- a/packages/datadog-plugin-openai/src/tracing.js +++ b/packages/datadog-plugin-openai/src/tracing.js @@ -102,23 +102,23 @@ class OpenAiTracingPlugin extends TracingPlugin { }, }, false) - const openaiStore = Object.create(null) - const tags = {} // The remaining tags are added one at a time if (payload.stream) { tags['openai.request.stream'] = payload.stream } + let openaiStore + switch (normalizedMethodName) { case 'createImage': case 'createImageEdit': case 'createImageVariation': - commonCreateImageRequestExtraction(tags, payload, openaiStore) + openaiStore = commonCreateImageRequestExtraction(tags, payload) break case 'createChatCompletion': - createChatCompletionRequestExtraction(tags, payload, openaiStore) + openaiStore = createChatCompletionRequestExtraction(tags, payload) break case 'createFile': @@ -128,7 +128,7 @@ class OpenAiTracingPlugin extends TracingPlugin { case 'createTranscription': case 'createTranslation': - commonCreateAudioRequestExtraction(tags, payload, openaiStore) + openaiStore = commonCreateAudioRequestExtraction(tags, payload) break case 'retrieveModel': @@ -136,11 +136,11 @@ class OpenAiTracingPlugin extends TracingPlugin { break case 'createEdit': - createEditRequestExtraction(tags, payload, openaiStore) + openaiStore = createEditRequestExtraction(tags, payload) break case 'createResponse': - createResponseRequestExtraction(tags, payload, openaiStore) + openaiStore = createResponseRequestExtraction(tags, payload) break } @@ -177,8 +177,6 @@ class OpenAiTracingPlugin extends TracingPlugin { body = coerceResponseBody(body, normalizedMethodName) - const openaiStore = store.openai - if (!error && (path?.startsWith('https://') || path?.startsWith('http://'))) { // basic checking for if the path was set as a full URL // not using a full regex as it will likely be "https://api.openai.com/..." @@ -204,7 +202,7 @@ class OpenAiTracingPlugin extends TracingPlugin { 'openai.response.created_at': body.created_at, } - responseDataExtractionByMethod(normalizedMethodName, tags, body, openaiStore) + const openaiStore = responseDataExtractionByMethod(normalizedMethodName, tags, body, store.openai) span.addTags(tags) span.finish() @@ -298,7 +296,6 @@ class OpenAiTracingPlugin extends TracingPlugin { sendLog (methodName, span, tags, openaiStore, error) { if (!openaiStore) return - if (!Object.keys(openaiStore).length) return if (!this.sampler.isSampled(span)) return const log = { @@ -395,45 +392,54 @@ function normalizeMethodName (methodName) { } } -function createEditRequestExtraction (tags, payload, openaiStore) { - const instruction = payload.instruction - openaiStore.instruction = instruction +function createEditRequestExtraction (tags, payload) { + const openaiStore = Object.create(null) + openaiStore.instruction = payload.instruction + return openaiStore } -function createResponseRequestExtraction (tags, payload, openaiStore) { +function createResponseRequestExtraction (tags, payload) { // Extract model information if (payload.model) { tags['openai.request.model'] = payload.model } // Store the full payload for response extraction + const openaiStore = Object.create(null) openaiStore.responseData = payload + return openaiStore } function retrieveModelRequestExtraction (tags, payload) { tags['openai.request.id'] = payload.id } -function createChatCompletionRequestExtraction (tags, payload, openaiStore) { +function createChatCompletionRequestExtraction (tags, payload) { const messages = payload.messages if (!defensiveArrayLength(messages)) return - openaiStore.messages = payload.messages + const openaiStore = Object.create(null) + openaiStore.messages = messages + return openaiStore } -function commonCreateImageRequestExtraction (tags, payload, openaiStore) { +function commonCreateImageRequestExtraction (tags, payload) { + let openaiStore + // createImageEdit, createImageVariation const img = payload.file || payload.image if (img !== null && typeof img === 'object' && img.path) { - const file = path.basename(img.path) - openaiStore.file = file + openaiStore = Object.create(null) + openaiStore.file = path.basename(img.path) } // createImageEdit if (payload.mask !== null && typeof payload.mask === 'object' && payload.mask.path) { - const mask = path.basename(payload.mask.path) - openaiStore.mask = mask + openaiStore ??= Object.create(null) + openaiStore.mask = path.basename(payload.mask.path) } + + return openaiStore } function responseDataExtractionByMethod (methodName, tags, body, openaiStore) { @@ -441,12 +447,10 @@ function responseDataExtractionByMethod (methodName, tags, body, openaiStore) { case 'createCompletion': case 'createChatCompletion': case 'createEdit': - commonCreateResponseExtraction(tags, body, openaiStore, methodName) - break + return commonCreateResponseExtraction(tags, body, openaiStore, methodName) case 'createResponse': - createResponseResponseExtraction(tags, body, openaiStore) - break + return createResponseResponseExtraction(tags, body, openaiStore) case 'listFiles': case 'listFineTunes': @@ -475,6 +479,8 @@ function responseDataExtractionByMethod (methodName, tags, body, openaiStore) { retrieveModelResponseExtraction(tags, body) break } + + return openaiStore } function retrieveModelResponseExtraction (tags, body) { @@ -513,10 +519,11 @@ function deleteFileResponseExtraction (tags, body) { tags['openai.response.id'] = body.id } -function commonCreateAudioRequestExtraction (tags, body, openaiStore) { +function commonCreateAudioRequestExtraction (tags, body) { if (body.file !== null && typeof body.file === 'object' && body.file.path) { - const filename = path.basename(body.file.path) - openaiStore.file = filename + const openaiStore = Object.create(null) + openaiStore.file = path.basename(body.file.path) + return openaiStore } } @@ -546,9 +553,11 @@ function commonListCountResponseExtraction (tags, body) { // createCompletion, createChatCompletion, createEdit function commonCreateResponseExtraction (tags, body, openaiStore, methodName) { - if (!body.choices) return + if (!body.choices) return openaiStore + openaiStore ??= Object.create(null) openaiStore.choices = body.choices + return openaiStore } function createResponseResponseExtraction (tags, body, openaiStore) { @@ -568,7 +577,9 @@ function createResponseResponseExtraction (tags, body, openaiStore) { } // Store the full response for potential future use + openaiStore ??= Object.create(null) openaiStore.response = body + return openaiStore } // The server almost always responds with JSON From a365bccfa6ee84f70cd0015ebe2d311d577c1718 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 27 Jul 2026 18:24:04 +0200 Subject: [PATCH 03/11] refactor(profiling): report absent profiler info as undefined `SpaceProfiler` has nothing to contribute, so it returns undefined rather than an empty object the submitter has to probe before dropping it. `webTags` is only ever the tag map of a span `isWebServerSpan` matched, so it always carries `span.type`; the emptiness check in front of the endpoint label could never fire. --- packages/dd-trace/src/profiling/profiler.js | 2 +- packages/dd-trace/src/profiling/profilers/space.js | 4 +--- packages/dd-trace/src/profiling/profilers/wall.js | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/dd-trace/src/profiling/profiler.js b/packages/dd-trace/src/profiling/profiler.js index e071cd93148..e30d002e460 100644 --- a/packages/dd-trace/src/profiling/profiler.js +++ b/packages/dd-trace/src/profiling/profiler.js @@ -35,7 +35,7 @@ function profileHasMissingSourceMaps (profile) { } function processInfo (infos, info, type) { - if (Object.keys(info).length > 0) { + if (info !== undefined) { infos[type] = info } } diff --git a/packages/dd-trace/src/profiling/profilers/space.js b/packages/dd-trace/src/profiling/profilers/space.js index a5c8ba25641..cfc7f54b714 100644 --- a/packages/dd-trace/src/profiling/profilers/space.js +++ b/packages/dd-trace/src/profiling/profilers/space.js @@ -66,9 +66,7 @@ class NativeSpaceProfiler { return profile } - getInfo () { - return {} - } + getInfo () {} encode (profile) { return encodeProfileAsync(profile) diff --git a/packages/dd-trace/src/profiling/profilers/wall.js b/packages/dd-trace/src/profiling/profilers/wall.js index d5f7f53c0bd..2228f853977 100644 --- a/packages/dd-trace/src/profiling/profilers/wall.js +++ b/packages/dd-trace/src/profiling/profilers/wall.js @@ -417,7 +417,7 @@ class NativeWallProfiler { if (rootSpanId !== undefined) { labels[LOCAL_ROOT_SPAN_ID_LABEL] = toBigInt(rootSpanId) } - if (webTags !== undefined && Object.keys(webTags).length !== 0) { + if (webTags !== undefined) { labels[TRACE_ENDPOINT_LABEL] = endpointNameFromTags(webTags) } else if (endpoint) { // fallback to endpoint computed when sample was taken From c80cf07caffd13196ff99cbfd6691b2380bf98f9 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 27 Jul 2026 18:41:46 +0200 Subject: [PATCH 04/11] refactor(core): drop Object.keys emptiness probes from ritm, otel and aiguard 1. `ritm` counts hooked modules as they are registered and removed. The counter is the only thing deciding whether teardown resets the require patch, so every add and delete of a `moduleHooks` key has to keep it in step; `unhook` skips modules it no longer holds so a repeated teardown cannot undercount. 2. The OTel context manager builds the baggage entries in one pass and lets the absence of that object stand for "no baggage". 3. Azure Functions span links drop the probe in front of `tracer.extract`, which already returns null for a carrier that holds no trace context. --- .../src/index.js | 2 +- .../src/opentelemetry/context_manager.js | 14 ++++---- packages/dd-trace/src/ritm.js | 11 +++++-- packages/dd-trace/test/aiguard/index.spec.js | 25 ++++++++++++++ .../ritm-tests/module-partially-unhooked.js | 3 ++ .../test/ritm-tests/module-sibling.js | 3 ++ .../test/ritm-tests/module-unhooked.js | 3 ++ packages/dd-trace/test/ritm.spec.js | 33 +++++++++++++++++++ 8 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 packages/dd-trace/test/ritm-tests/module-partially-unhooked.js create mode 100644 packages/dd-trace/test/ritm-tests/module-sibling.js create mode 100644 packages/dd-trace/test/ritm-tests/module-unhooked.js diff --git a/packages/datadog-plugin-azure-functions/src/index.js b/packages/datadog-plugin-azure-functions/src/index.js index cfbe0e5a480..522f11980d2 100644 --- a/packages/datadog-plugin-azure-functions/src/index.js +++ b/packages/datadog-plugin-azure-functions/src/index.js @@ -156,7 +156,7 @@ function setSpanLinks (triggerType, tracer, span, ctx) { : triggerMetadata.propertiesArray const addLinkFromProperties = (props) => { - if (!props || Object.keys(props).length === 0) return + if (!props) return const spanContext = tracer.extract('text_map', props) if (spanContext) { span.addLink({ context: spanContext }) diff --git a/packages/dd-trace/src/opentelemetry/context_manager.js b/packages/dd-trace/src/opentelemetry/context_manager.js index dfbd07f49b9..bb4dcaebbe2 100644 --- a/packages/dd-trace/src/opentelemetry/context_manager.js +++ b/packages/dd-trace/src/opentelemetry/context_manager.js @@ -21,14 +21,14 @@ class ContextManager { const storedSpan = store ? trace.getSpan(store) : null // Convert DD baggage to OTel format - const baggages = getAllBaggageItems() - const hasBaggage = Object.keys(baggages).length > 0 + let entries + for (const [key, value] of Object.entries(getAllBaggageItems())) { + entries ??= {} + entries[key] = { value } + } + let otelBaggages - if (hasBaggage) { - const entries = {} - for (const [key, value] of Object.entries(baggages)) { - entries[key] = { value } - } + if (entries !== undefined) { otelBaggages = propagation.createBaggage(entries) } diff --git a/packages/dd-trace/src/ritm.js b/packages/dd-trace/src/ritm.js index 88a7f09d95e..835ce0412a2 100644 --- a/packages/dd-trace/src/ritm.js +++ b/packages/dd-trace/src/ritm.js @@ -20,6 +20,7 @@ const origRequire = Module.prototype.require module.exports = Hook let moduleHooks = Object.create(null) +let hookedModuleCount = 0 let cache = Object.create(null) let patching = Object.create(null) let patchedRequire = null @@ -64,6 +65,7 @@ function Hook (modules, options, onrequire) { hooks.push(onrequire) } else { moduleHooks[mod] = [onrequire] + hookedModuleCount++ } } } @@ -205,6 +207,7 @@ Hook.reset = function () { patching = Object.create(null) cache = Object.create(null) moduleHooks = Object.create(null) + hookedModuleCount = 0 } function findProjectRoot (startDir) { @@ -221,16 +224,20 @@ function findProjectRoot (startDir) { Hook.prototype.unhook = function () { for (const mod of this.modules) { - const hooks = (moduleHooks[mod] || []).filter(hook => hook !== this.onrequire) + const registeredHooks = moduleHooks[mod] + if (registeredHooks === undefined) continue + + const hooks = registeredHooks.filter(hook => hook !== this.onrequire) if (hooks.length > 0) { moduleHooks[mod] = hooks } else { delete moduleHooks[mod] + hookedModuleCount-- } } - if (Object.keys(moduleHooks).length === 0) { + if (hookedModuleCount === 0) { Hook.reset() } } diff --git a/packages/dd-trace/test/aiguard/index.spec.js b/packages/dd-trace/test/aiguard/index.spec.js index 797c0955920..2f9af1f51c7 100644 --- a/packages/dd-trace/test/aiguard/index.spec.js +++ b/packages/dd-trace/test/aiguard/index.spec.js @@ -8,9 +8,11 @@ const msgpack = require('@msgpack/msgpack') const { afterEach, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') +const { storage } = require('../../../datadog-core') const aiguardAutoInstrumentation = require('../../src/aiguard') const NoopAIGuard = require('../../src/aiguard/noop') const AIGuard = require('../../src/aiguard/sdk') +const { withRequest } = require('../../src/appsec/store') const agent = require('../plugins/agent') const { assertObjectContains } = require('../../../../integration-tests/helpers') @@ -18,6 +20,7 @@ const tracerVersion = require('../../../../package.json').version const telemetryMetrics = require('../../src/telemetry/metrics') const aiguardMetrics = telemetryMetrics.manager.namespace('ai_guard') const { USER_KEEP } = require('../../../../ext/priority') +const { HTTP_CLIENT_IP, NETWORK_CLIENT_IP } = require('../../../../ext/tags') const { SAMPLING_MECHANISM_AI_GUARD, DECISION_MAKER_KEY } = require('../../src/constants') const { EVENT_TAG_KEY, @@ -501,6 +504,28 @@ describe('AIGuard SDK', () => { }) }) + it('copies the client ip of the active request onto the root span', async () => { + mockFetch({ + body: { data: { attributes: { action: 'ALLOW', reason: 'OK', is_blocking_enabled: false } } }, + }) + + const req = { headers: { 'x-forwarded-for': '8.8.8.8' }, socket: { remoteAddress: '10.0.0.1' } } + await tracer.trace('root', async () => { + const legacyStorage = storage('legacy') + await legacyStorage.run(withRequest(legacyStorage.getStore(), req), () => + aiguard.evaluate(prompt, { block: false }) + ) + }) + + await agent.assertSomeTraces(traces => { + const rootSpan = traces[0].find(span => span.name === 'root') + assertObjectContains(rootSpan.meta, { + [HTTP_CLIENT_IP]: '8.8.8.8', + [NETWORK_CLIENT_IP]: '10.0.0.1', + }) + }) + }) + it('parents the ai_guard span under the explicit childOf span', async () => { mockFetch({ body: { data: { attributes: { action: 'ALLOW', reason: 'OK', is_blocking_enabled: false } } }, diff --git a/packages/dd-trace/test/ritm-tests/module-partially-unhooked.js b/packages/dd-trace/test/ritm-tests/module-partially-unhooked.js new file mode 100644 index 00000000000..dece2626b19 --- /dev/null +++ b/packages/dd-trace/test/ritm-tests/module-partially-unhooked.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports.foo = 'foo' diff --git a/packages/dd-trace/test/ritm-tests/module-sibling.js b/packages/dd-trace/test/ritm-tests/module-sibling.js new file mode 100644 index 00000000000..dece2626b19 --- /dev/null +++ b/packages/dd-trace/test/ritm-tests/module-sibling.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports.foo = 'foo' diff --git a/packages/dd-trace/test/ritm-tests/module-unhooked.js b/packages/dd-trace/test/ritm-tests/module-unhooked.js new file mode 100644 index 00000000000..dece2626b19 --- /dev/null +++ b/packages/dd-trace/test/ritm-tests/module-unhooked.js @@ -0,0 +1,3 @@ +'use strict' + +module.exports.foo = 'foo' diff --git a/packages/dd-trace/test/ritm.spec.js b/packages/dd-trace/test/ritm.spec.js index f20e0b8e670..5cff882de4b 100644 --- a/packages/dd-trace/test/ritm.spec.js +++ b/packages/dd-trace/test/ritm.spec.js @@ -149,4 +149,37 @@ describe('Ritm', () => { } } }) + + describe('unhook', () => { + /** @param {Record} exports */ + function markPatched (exports) { + exports.patched = true + return exports + } + + it('removes the hook and leaves unrelated registrations intact', () => { + Hook(['./ritm-tests/module-sibling'], markPatched) + const hook = Hook(['./ritm-tests/module-unhooked'], markPatched) + + hook.unhook() + hook.unhook() + + assert.equal(require('./ritm-tests/module-unhooked').patched, undefined) + assert.equal(require('./ritm-tests/module-sibling').patched, true) + }) + + it('keeps the hooks a module still has registered', () => { + const hook = Hook(['./ritm-tests/module-partially-unhooked'], markPatched) + Hook(['./ritm-tests/module-partially-unhooked'], (exports) => { + exports.kept = true + return exports + }) + + hook.unhook() + + const patchedModule = require('./ritm-tests/module-partially-unhooked') + assert.equal(patchedModule.patched, undefined) + assert.equal(patchedModule.kept, true) + }) + }) }) From 86e4fa7dc2a2ab1f55d93ed1fcf7b8e0de46d32d Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 27 Jul 2026 18:42:26 +0200 Subject: [PATCH 05/11] ci: ban Object.keys(obj).length as an emptiness probe under packages/*/src MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selector only matches probe positions — a comparison against 0, a `!` operand, or the test of an `if`, ternary, logical expression or loop — so a genuine count such as `writeMapPrefix(Object.keys(x).length)` stays legal. Four sites keep the probe behind an inline opt-out. Fastify cookies and bodies and router path params reach us as third-party objects with arbitrary keys, and publishing an empty one would set the matching WAF address and burn the once-per-request publish latch. The debugger snapshot walker faces the same shape: `fields` is keyed by the user's variable names. --- eslint.config.mjs | 57 ++++++++++++++----- .../datadog-instrumentations/src/fastify.js | 2 + .../datadog-instrumentations/src/router.js | 2 + .../dynamic-instrumentation/worker/index.js | 1 + 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 6e676d732a2..2b7910595db 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -107,6 +107,37 @@ const GLOBAL_RESTRICTED_REQUIRES = [ }, ] +const SRC_RESTRICTED_SYNTAX = [ + { + // Inline `.evaluate()` callbacks (Playwright/Puppeteer) are serialized with + // `toString()` and run in chromium — coverage counters inside would ReferenceError. + selector: + "CallExpression[callee.property.name='evaluate']" + + ":matches([arguments.0.type='ArrowFunctionExpression'], [arguments.0.type='FunctionExpression'])", + message: + 'Move the inline `.evaluate(...)` callback into a `*-browser-scripts.js` file ' + + '(NYC-excluded in nyc.config.js) and import it here.', + }, + { + // Static-analysis bundlers (esbuild, webpack, rollup) only see literals as require + // arguments; once any transform (e.g. NYC) wraps them, this shape breaks bundling. + selector: "CallExpression[callee.name='require'][arguments.0.type='ConditionalExpression']", + message: 'Use `cond ? require(\'a\') : require(\'b\')` instead of `require(cond ? \'a\' : \'b\')`.', + }, +] + +// Matches only probe positions; a genuine count (`writeMapPrefix(Object.keys(x).length)`) must stay allowed. +const OBJECT_KEYS_LENGTH_PROBE = { + selector: + ':matches(BinaryExpression[right.value=0], BinaryExpression[left.value=0], UnaryExpression[operator="!"],' + + ' IfStatement, ConditionalExpression, LogicalExpression, WhileStatement, DoWhileStatement)' + + " > MemberExpression[property.name='length']" + + " > CallExpression[callee.object.name='Object'][callee.property.name='keys']", + message: 'Do not probe emptiness with `Object.keys(obj).length`; the keys array is allocated on every call. ' + + 'Track presence with a boolean at the assignment site, probe a known key (`obj.field !== undefined`), or ' + + 'return `undefined` when there is nothing to report instead of an empty object.', +} + export default [ { name: 'dd-trace/global-ignore', @@ -643,21 +674,7 @@ export default [ 'eslint-rules/eslint-prefer-set-service-name': 'error', 'eslint-rules/eslint-timer-unref': 'error', - 'no-restricted-syntax': ['error', { - // Inline `.evaluate()` callbacks (Playwright/Puppeteer) are serialized with - // `toString()` and run in chromium — coverage counters inside would ReferenceError. - selector: - "CallExpression[callee.property.name='evaluate']" + - ":matches([arguments.0.type='ArrowFunctionExpression'], [arguments.0.type='FunctionExpression'])", - message: - 'Move the inline `.evaluate(...)` callback into a `*-browser-scripts.js` file ' + - '(NYC-excluded in nyc.config.js) and import it here.', - }, { - // Static-analysis bundlers (esbuild, webpack, rollup) only see literals as require - // arguments; once any transform (e.g. NYC) wraps them, this shape breaks bundling. - selector: "CallExpression[callee.name='require'][arguments.0.type='ConditionalExpression']", - message: 'Use `cond ? require(\'a\') : require(\'b\')` instead of `require(cond ? \'a\' : \'b\')`.', - }], + 'no-restricted-syntax': ['error', ...SRC_RESTRICTED_SYNTAX], 'n/no-restricted-require': ['error', [ ...GLOBAL_RESTRICTED_REQUIRES, @@ -793,6 +810,16 @@ export default [ 'unicorn/prefer-optional-catch-binding': 'error', }, }, + { + name: 'dd-trace/packages/src', + files: [ + 'packages/*/src/**/*.js', + 'packages/*/src/**/*.mjs', + ], + rules: { + 'no-restricted-syntax': ['error', ...SRC_RESTRICTED_SYNTAX, OBJECT_KEYS_LENGTH_PROBE], + }, + }, { name: 'dd-trace/config-sync', files: [ diff --git a/packages/datadog-instrumentations/src/fastify.js b/packages/datadog-instrumentations/src/fastify.js index 457afb8e465..62671d3ed4b 100644 --- a/packages/datadog-instrumentations/src/fastify.js +++ b/packages/datadog-instrumentations/src/fastify.js @@ -146,6 +146,7 @@ function wrapHookDone (ctx, request, reply, req, name, doneCallback) { ctx.error = error publishError(ctx) + // eslint-disable-next-line no-restricted-syntax -- arbitrary cookie names; publishing {} sets a WAF address const hasCookies = request.cookies && Object.keys(request.cookies).length > 0 if (cookieParserReadCh.hasSubscribers && hasCookies && !cookiesPublished.has(req)) { @@ -193,6 +194,7 @@ function preHandler (request, reply, done) { const res = getRes(reply) const ctx = { req, res } + // eslint-disable-next-line no-restricted-syntax -- arbitrary body keys; publishing {} sets a WAF address const hasBody = request.body && Object.keys(request.body).length > 0 // For multipart/form-data, the body is not available until after preValidation hook diff --git a/packages/datadog-instrumentations/src/router.js b/packages/datadog-instrumentations/src/router.js index 00948c1e1c9..72f5023c4e8 100644 --- a/packages/datadog-instrumentations/src/router.js +++ b/packages/datadog-instrumentations/src/router.js @@ -596,6 +596,7 @@ const visitedParams = new WeakSet() function wrapHandleRequest (original) { return function wrappedHandleRequest (...args) { const req = args[0] + // eslint-disable-next-line no-restricted-syntax -- arbitrary param names; publishing {} sets a WAF address if (routerParamStartCh.hasSubscribers && !visitedParams.has(req.params) && Object.keys(req.params).length) { visitedParams.add(req.params) @@ -635,6 +636,7 @@ function wrapParam (original) { args[1] = shimmer.wrapFunction(args[1], (originalFn) => { return function wrappedFn (...fnArgs) { const req = fnArgs[0] + // eslint-disable-next-line no-restricted-syntax -- arbitrary param names; publishing {} sets a WAF address if (routerParamStartCh.hasSubscribers && Object.keys(req.params).length && !visitedParams.has(req.params)) { visitedParams.add(req.params) diff --git a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js index 11ed2b2eb8b..bd7434d41eb 100644 --- a/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +++ b/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js @@ -92,6 +92,7 @@ function removeEmptyCaptureProperties (value) { if (current.fields === null || ( current.fields && typeof current.fields === 'object' && + // eslint-disable-next-line no-restricted-syntax -- snapshot field names are user variables; no key to probe Object.keys(current.fields).length === 0 )) { delete current.fields From 109ed16117f8ebea52de0045403128bcce9a0286 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 12 Aug 2026 15:22:59 +0200 Subject: [PATCH 06/11] test: cover empty-value refactors Pin the undefined values returned when profiler info or suite coverage is absent. Exercise legacy OpenAI edit logging through the public SDK path so the lazy store branch remains covered. --- .../datadog-plugin-openai/test/index.spec.js | 25 +++++++++++++++++++ .../get-skippable-suites.spec.js | 2 +- .../test/profiling/profilers/space.spec.js | 5 ++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/datadog-plugin-openai/test/index.spec.js b/packages/datadog-plugin-openai/test/index.spec.js index d7e3bb4387c..13668e6c771 100644 --- a/packages/datadog-plugin-openai/test/index.spec.js +++ b/packages/datadog-plugin-openai/test/index.spec.js @@ -6,6 +6,7 @@ const assert = require('node:assert/strict') const Path = require('path') const { inspect } = require('node:util') +const nock = require('nock') const semver = require('semver') const sinon = require('sinon') @@ -160,6 +161,30 @@ describe('Plugin', () => { sinon.assert.neverCalledWith(metricStub, 'openai.ratelimit.remaining.tokens') }) + it('logs edit instructions', async function () { + if (semver.satisfies(realVersion, '>=4.0.0')) { + this.skip() + } + + const instruction = 'Fix the spelling mistakes.' + const scope = nock('http://127.0.0.1:9126') + .post('/vcr/openai/edits') + .reply(200, { + choices: [{ index: 0, text: 'What day of the week is it?' }], + }) + + const checkTrace = agent.assertFirstTraceSpan({ error: 0 }) + await openai.createEdit({ + input: 'What day of the wek is it?', + instruction, + model: 'text-davinci-edit-001', + }) + await checkTrace + + scope.done() + sinon.assert.calledWithMatch(externalLoggerStub, { instruction }) + }) + describe('maintains context', () => { it('should maintain the context with a non-streamed call', async () => { await tracer.trace('outer', async (outerSpan) => { diff --git a/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js b/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js index 05c26e94b5e..1dffbe91123 100644 --- a/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js +++ b/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js @@ -208,7 +208,7 @@ describe('get-skippable-suites', () => { assert.strictEqual(err, null) assert.deepStrictEqual(skippableSuites, ['suite1.spec.js', 'suite2.spec.js']) assert.strictEqual(correlationId, 'corr-123') - assert.deepStrictEqual(coverage, {}) + assert.strictEqual(coverage, undefined) done() }) }) diff --git a/packages/dd-trace/test/profiling/profilers/space.spec.js b/packages/dd-trace/test/profiling/profilers/space.spec.js index 2a8574bc6fa..b0e490d4fbf 100644 --- a/packages/dd-trace/test/profiling/profilers/space.spec.js +++ b/packages/dd-trace/test/profiling/profilers/space.spec.js @@ -99,11 +99,10 @@ describe('profilers/native/space', () => { sinon.assert.calledOnce(pprof.heap.stop) }) - it('should provide info', () => { + it('should not provide info', () => { const profiler = makeSpace(NativeSpaceProfiler) - const info = profiler.getInfo() - assert.strictEqual(Object.keys(info).length, 0) + assert.strictEqual(profiler.getInfo(), undefined) }) it('should collect profiles from the pprof space profiler', () => { From 7d9978b2df2b7459b3c22b03997a343ffd315b7f Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 12 Aug 2026 16:44:40 +0200 Subject: [PATCH 07/11] test(openai): defer nock until tracer initialization Loading nock at module scope patches HTTP before agent.load(), so OpenAI instrumentation wraps the patched methods and the remaining span assertions fail. --- packages/datadog-plugin-openai/test/index.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datadog-plugin-openai/test/index.spec.js b/packages/datadog-plugin-openai/test/index.spec.js index 13668e6c771..784723f7d2f 100644 --- a/packages/datadog-plugin-openai/test/index.spec.js +++ b/packages/datadog-plugin-openai/test/index.spec.js @@ -6,7 +6,6 @@ const assert = require('node:assert/strict') const Path = require('path') const { inspect } = require('node:util') -const nock = require('nock') const semver = require('semver') const sinon = require('sinon') @@ -166,6 +165,7 @@ describe('Plugin', () => { this.skip() } + const nock = require('nock') const instruction = 'Fix the spelling mistakes.' const scope = nock('http://127.0.0.1:9126') .post('/vcr/openai/edits') From 9d9990b44530c0809e7f81bc638c0f067715c6b2 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 12 Aug 2026 16:45:58 +0200 Subject: [PATCH 08/11] perf(llmobs): retain faster experiment config copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Node.js 24 with representative small configs, spreading and probing takes 10.3–11.5 ns versus 17.5–20.9 ns for copying entries while tracking state. --- packages/dd-trace/src/llmobs/experiments/experiment.js | 1 + .../dd-trace/test/llmobs/experiments/experiment.spec.js | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/dd-trace/src/llmobs/experiments/experiment.js b/packages/dd-trace/src/llmobs/experiments/experiment.js index ccac6252926..c08dab577c8 100644 --- a/packages/dd-trace/src/llmobs/experiments/experiment.js +++ b/packages/dd-trace/src/llmobs/experiments/experiment.js @@ -374,6 +374,7 @@ class Experiment { } const datasetVersion = this.#dataset.version() if (datasetVersion !== null) attributes.dataset_version = datasetVersion + // eslint-disable-next-line no-restricted-syntax -- faster than tracking entries while copying arbitrary config if (Object.keys(this.#config).length > 0) attributes.config = this.#config let created diff --git a/packages/dd-trace/test/llmobs/experiments/experiment.spec.js b/packages/dd-trace/test/llmobs/experiments/experiment.spec.js index 170e24b51d1..4de22a29db2 100644 --- a/packages/dd-trace/test/llmobs/experiments/experiment.spec.js +++ b/packages/dd-trace/test/llmobs/experiments/experiment.spec.js @@ -53,7 +53,7 @@ function clientWithMockBackend ({ createDatasetError } = {}) { describe('LLMObs Experiments — dataset + experiment run', () => { it('runs task inside an LLMObs experiment span', async () => { - const { client: c } = clientWithMockBackend() + const { client: c, requests } = clientWithMockBackend() const dataset = new Dataset(c, 'demo').addRecord({ q: 'apple' }, 'apple', { row: 0 }) const callsToLlmobs = [] const llmobs = { @@ -72,6 +72,7 @@ describe('LLMObs Experiments — dataset + experiment run', () => { dataset, task: (input) => input.q, evaluators: { ok: () => true }, + config: { temperature: 0 }, }, llmobs).run() assert.equal(callsToLlmobs[0][0], 'trace') @@ -81,6 +82,9 @@ describe('LLMObs Experiments — dataset + experiment run', () => { assert.equal(callsToLlmobs[1][1].tags.dataset_record_id, dataset.records()[0].id) assert.equal(result.rows[0].spanId, '000000000000abcd') assert.equal(result.rows[0].traceId, '0000000000000000000000000000abcd') + assert.deepEqual(requests.find(request => request.method === 'createExperiment').attributes.config, { + temperature: 0, + }) }) it('surfaces backend failures', async () => { From 679c5f94b4c2814e5926999cfad4d3cc5e185ffe Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 12 Aug 2026 16:46:11 +0200 Subject: [PATCH 09/11] refactor(llmobs): retain Object.assign for runtime context --- packages/dd-trace/src/llmobs/plugins/ai/util.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dd-trace/src/llmobs/plugins/ai/util.js b/packages/dd-trace/src/llmobs/plugins/ai/util.js index 393574c8014..a6846b83acd 100644 --- a/packages/dd-trace/src/llmobs/plugins/ai/util.js +++ b/packages/dd-trace/src/llmobs/plugins/ai/util.js @@ -307,6 +307,7 @@ function getGenerationMetadataFromEvent (event) { metadata[transformedKey] = value } + // eslint-disable-next-line no-restricted-syntax -- manual tracking would duplicate Object.assign semantics return Object.keys(metadata).length ? metadata : null } From 940271d644c26bf08b06226147322133470e948a Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 12 Aug 2026 17:12:13 +0200 Subject: [PATCH 10/11] test(openai): isolate legacy edit request mock Leaving Nock active after the legacy edit request rejects every subsequent VCR call as an unmatched request. --- .../datadog-plugin-openai/test/index.spec.js | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/datadog-plugin-openai/test/index.spec.js b/packages/datadog-plugin-openai/test/index.spec.js index 784723f7d2f..b4827f33d30 100644 --- a/packages/datadog-plugin-openai/test/index.spec.js +++ b/packages/datadog-plugin-openai/test/index.spec.js @@ -166,23 +166,30 @@ describe('Plugin', () => { } const nock = require('nock') - const instruction = 'Fix the spelling mistakes.' - const scope = nock('http://127.0.0.1:9126') - .post('/vcr/openai/edits') - .reply(200, { - choices: [{ index: 0, text: 'What day of the week is it?' }], - }) + if (!nock.isActive()) nock.activate() - const checkTrace = agent.assertFirstTraceSpan({ error: 0 }) - await openai.createEdit({ - input: 'What day of the wek is it?', - instruction, - model: 'text-davinci-edit-001', - }) - await checkTrace + try { + const instruction = 'Fix the spelling mistakes.' + const scope = nock('http://127.0.0.1:9126') + .post('/vcr/openai/edits') + .reply(200, { + choices: [{ index: 0, text: 'What day of the week is it?' }], + }) - scope.done() - sinon.assert.calledWithMatch(externalLoggerStub, { instruction }) + const checkTrace = agent.assertFirstTraceSpan({ error: 0 }) + await openai.createEdit({ + input: 'What day of the wek is it?', + instruction, + model: 'text-davinci-edit-001', + }) + await checkTrace + + scope.done() + sinon.assert.calledWithMatch(externalLoggerStub, { instruction }) + } finally { + nock.cleanAll() + nock.restore() + } }) describe('maintains context', () => { From 3ee43851e3ddb62b7feffcb2aa0344c185e7e00b Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 17 Aug 2026 12:43:22 +0200 Subject: [PATCH 11/11] refactor(llmobs): avoid metadata emptiness probes --- .../src/integration.js | 1 + packages/dd-trace/src/llmobs/plugins/ai/util.js | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/datadog-plugin-openai-agents/src/integration.js b/packages/datadog-plugin-openai-agents/src/integration.js index 79118cd2abf..b5427d3eb53 100644 --- a/packages/datadog-plugin-openai-agents/src/integration.js +++ b/packages/datadog-plugin-openai-agents/src/integration.js @@ -498,6 +498,7 @@ class OpenAIAgentsIntegration { this.#tagger.tagTextIO(ddSpan, inputValue, outputValue) + // eslint-disable-next-line no-restricted-syntax -- agents-core builds metadata before the plugin receives it if (info.metadata && Object.keys(info.metadata).length > 0) { this.#tagger.tagMetadata(ddSpan, info.metadata) } diff --git a/packages/dd-trace/src/llmobs/plugins/ai/util.js b/packages/dd-trace/src/llmobs/plugins/ai/util.js index a6846b83acd..6e9308af5ae 100644 --- a/packages/dd-trace/src/llmobs/plugins/ai/util.js +++ b/packages/dd-trace/src/llmobs/plugins/ai/util.js @@ -228,6 +228,7 @@ function getJsonStringValue (str, defaultValue) { function getModelMetadata (tags) { /** @type {Record} */ const modelMetadata = {} + let hasModelMetadata = false for (const tag of Object.keys(tags)) { const isModelMetadata = tag.startsWith(VERCEL_AI_MODEL_METADATA_PREFIX) if (isModelMetadata) { @@ -235,6 +236,7 @@ function getModelMetadata (tags) { const metadataKey = lastCommaPosition === -1 ? tag : tag.slice(lastCommaPosition + 1) if (metadataKey && MODEL_METADATA_KEYS.has(metadataKey)) { modelMetadata[metadataKey] = tags[tag] + hasModelMetadata = true } } else { const isTelemetryMetadata = tag.startsWith(VERCEL_AI_TELEMETRY_METADATA_PREFIX) @@ -242,12 +244,13 @@ function getModelMetadata (tags) { const metadataKey = tag.slice(VERCEL_AI_TELEMETRY_METADATA_PREFIX.length) if (metadataKey) { modelMetadata[metadataKey] = tags[tag] + hasModelMetadata = true } } } } - return Object.keys(modelMetadata).length ? modelMetadata : null + return hasModelMetadata ? modelMetadata : null } /** @@ -259,6 +262,7 @@ function getModelMetadata (tags) { function getGenerationMetadata (tags) { /** @type {Record} */ const metadata = {} + let hasMetadata = false for (const tag of Object.keys(tags)) { const isGenerationMetadata = tag.startsWith(VERCEL_AI_GENERATION_METADATA_PREFIX) @@ -270,18 +274,20 @@ function getGenerationMetadata (tags) { const settingValue = tags[tag] metadata[settingKey] = settingValue + hasMetadata = true } else { const isTelemetryMetadata = tag.startsWith(VERCEL_AI_TELEMETRY_METADATA_PREFIX) if (isTelemetryMetadata) { const metadataKey = tag.slice(VERCEL_AI_TELEMETRY_METADATA_PREFIX.length) if (metadataKey) { metadata[metadataKey] = tags[tag] + hasMetadata = true } } } } - return Object.keys(metadata).length ? metadata : null + return hasMetadata ? metadata : null } /** @@ -433,6 +439,7 @@ function getLlmObsSpanName (operation, functionId) { */ function getTelemetryMetadata (tags) { const metadata = {} + let hasMetadata = false for (const tag of Object.keys(tags)) { if (!tag.startsWith(VERCEL_AI_TELEMETRY_METADATA_PREFIX)) continue @@ -440,10 +447,11 @@ function getTelemetryMetadata (tags) { const metadataKey = tag.slice(VERCEL_AI_TELEMETRY_METADATA_PREFIX.length) if (metadataKey) { metadata[metadataKey] = tags[tag] + hasMetadata = true } } - return Object.keys(metadata).length ? metadata : null + return hasMetadata ? metadata : null } module.exports = {