From 21b1f103b28713b1a9ae2afcbe51acd1323479db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 10:50:09 +0200 Subject: [PATCH 01/25] fix(test-optimization): retry unknown network errors --- .../src/ci-visibility/exporters/request.js | 12 +++++- .../ci-visibility/exporters/request.spec.js | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/dd-trace/src/ci-visibility/exporters/request.js b/packages/dd-trace/src/ci-visibility/exporters/request.js index ca5b13cc8a..5c40e50808 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/request.js +++ b/packages/dd-trace/src/ci-visibility/exporters/request.js @@ -272,11 +272,19 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { lastError = error const responseStatus = statusCode ?? error.status - const isRetriableError = isRetriableNetworkError(error) || isRetriableHttpStatusCode(responseStatus) + const isUnknownNetworkError = responseStatus === undefined && error.code === undefined + const isRetriableError = + isRetriableNetworkError(error) || isUnknownNetworkError || isRetriableHttpStatusCode(responseStatus) const deadlineExtended = options.deadline !== undefined && (attemptDeadline === undefined || options.deadline > attemptDeadline) const reachedAttemptLimit = attemptIndex >= getMaxAttempts(attemptOptions) && !deadlineExtended - if (options.retry === false || !isRetriableError || reachedAttemptLimit) { + const reachedUnknownNetworkAttemptLimit = isUnknownNetworkError && attemptIndex >= 2 + if ( + options.retry === false || + !isRetriableError || + reachedAttemptLimit || + reachedUnknownNetworkAttemptLimit + ) { complete(error, result, statusCode, headers) return } diff --git a/packages/dd-trace/test/ci-visibility/exporters/request.spec.js b/packages/dd-trace/test/ci-visibility/exporters/request.spec.js index c3e18a17e6..78dc1edd0d 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/request.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/request.spec.js @@ -253,6 +253,47 @@ describe('Test Optimization exporter request', () => { assert.strictEqual(pendingRequests.length, 2) }) + it('retries an unknown network error once', () => { + const done = sinon.spy() + request('payload', {}, done) + + pendingRequests[0].callback(new Error('network failure')) + clock.tick(6000) + + assert.strictEqual(pendingRequests.length, 2) + pendingRequests[1].callback(null, 'ok', 200, {}) + sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) + }) + + it('does not extend the unknown network retry budget during finalization', () => { + const done = sinon.spy() + const options = { deadline: Date.now() + 1000, timeout: 2000 } + request('payload', options, done) + const firstError = new Error('first network failure') + const secondError = new Error('second network failure') + + pendingRequests[0].callback(firstError) + clock.tick(500) + options.deadline = Date.now() + 2000 + pendingRequests[1].callback(secondError) + clock.tick(1000) + + assert.strictEqual(pendingRequests.length, 2) + sinon.assert.calledOnceWithExactly(done, secondError, undefined, undefined, undefined) + }) + + it('does not retry a coded non-transient network error', () => { + const done = sinon.spy() + request('payload', {}, done) + const error = Object.assign(new Error('unknown host'), { code: 'ENOTFOUND' }) + + pendingRequests[0].callback(error) + clock.tick(6000) + + assert.strictEqual(pendingRequests.length, 1) + sinon.assert.calledOnceWithExactly(done, error, undefined, undefined, undefined) + }) + it('aborts a scheduled retry and completes once', () => { const controller = new AbortController() const done = sinon.spy() From 8e41f23f72a9bb5398662641b9b82caa9c4885c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 11:39:17 +0200 Subject: [PATCH 02/25] fix(test-optimization): retry timed out final flush requests --- .../src/ci-visibility/exporters/request.js | 33 ++++++++---- .../ci-visibility/exporters/request.spec.js | 50 +++++++++++++++++-- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/packages/dd-trace/src/ci-visibility/exporters/request.js b/packages/dd-trace/src/ci-visibility/exporters/request.js index 5c40e50808..7346df58b2 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/request.js +++ b/packages/dd-trace/src/ci-visibility/exporters/request.js @@ -180,6 +180,7 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { const timeout = options.timeout || 2000 const payloadSize = reservedPayloadSize ?? getPayloadSize(data) let retryTimer + let attemptTimer let attemptController let settled = false let lastError @@ -197,6 +198,7 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { if (settled) return settled = true clearTimeout(retryTimer) + clearTimeout(attemptTimer) signal?.removeEventListener('abort', onAbort) bufferedBytes -= payloadSize callback(error, result, statusCode, headers) @@ -249,7 +251,6 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { } waitingForBackpressure = false - const attemptDeadline = deadline const attemptOptions = { ...options, headers: options.headers ? { ...options.headers } : undefined, @@ -258,10 +259,19 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { if (deadline !== undefined) attemptOptions.timeout = Math.max(1, Math.min(timeout, remaining)) const controller = new AbortController() + const attemptTimeout = attemptOptions.timeout || timeout + let attemptTimedOut = false + attemptController = controller attemptOptions.signal = controller.signal + attemptTimer = setTimeout(() => { + attemptTimedOut = true + controller.abort(createRequestTimeoutError()) + }, attemptTimeout) + attemptTimer.unref?.() commonRequest(data, attemptOptions, (error, result, statusCode, headers) => { + clearTimeout(attemptTimer) if (attemptController === controller) attemptController = undefined if (settled) return if (!error) { @@ -269,23 +279,24 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { return } - lastError = error + const requestError = attemptTimedOut ? createRequestTimeoutError() : error + lastError = requestError const responseStatus = statusCode ?? error.status const isUnknownNetworkError = responseStatus === undefined && error.code === undefined const isRetriableError = - isRetriableNetworkError(error) || isUnknownNetworkError || isRetriableHttpStatusCode(responseStatus) - const deadlineExtended = options.deadline !== undefined && - (attemptDeadline === undefined || options.deadline > attemptDeadline) - const reachedAttemptLimit = attemptIndex >= getMaxAttempts(attemptOptions) && !deadlineExtended + attemptTimedOut || isRetriableNetworkError(error) || isUnknownNetworkError || + isRetriableHttpStatusCode(responseStatus) + const reachedBackgroundAttemptLimit = + options.deadline === undefined && attemptIndex >= getMaxAttempts(attemptOptions) const reachedUnknownNetworkAttemptLimit = isUnknownNetworkError && attemptIndex >= 2 if ( options.retry === false || !isRetriableError || - reachedAttemptLimit || + reachedBackgroundAttemptLimit || reachedUnknownNetworkAttemptLimit ) { - complete(error, result, statusCode, headers) + complete(requestError, result, statusCode, headers) return } @@ -296,11 +307,11 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { if (options.deadline !== undefined) { const retryRemaining = options.deadline - Date.now() if (resetDelay >= retryRemaining) { - complete(error, result, statusCode, headers) + complete(requestError, result, statusCode, headers) return } } else if (resetDelay > RATE_LIMIT_MAX_WAIT_MS) { - complete(error, result, statusCode, headers) + complete(requestError, result, statusCode, headers) return } retryDelay = resetDelay @@ -311,7 +322,7 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { if (options.deadline !== undefined && retryDelay === undefined) { const retryRemaining = options.deadline - Date.now() if (retryRemaining <= 0) { - complete(error, result, statusCode, headers) + complete(requestError, result, statusCode, headers) return } const retryAttemptTimeout = timeout < retryRemaining ? timeout : Math.ceil(retryRemaining / 2) diff --git a/packages/dd-trace/test/ci-visibility/exporters/request.spec.js b/packages/dd-trace/test/ci-visibility/exporters/request.spec.js index 78dc1edd0d..781ae50ded 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/request.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/request.spec.js @@ -53,7 +53,7 @@ describe('Test Optimization exporter request', () => { sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) }) - it('keeps the ordinary attempt cap during finalization', () => { + it('keeps retrying retriable responses while the finalization deadline has capacity', () => { const done = sinon.spy() request('payload', { deadline: Date.now() + 30_000 }, done) const error = Object.assign(new Error('unavailable'), { status: 503 }) @@ -62,9 +62,12 @@ describe('Test Optimization exporter request', () => { clock.tick(6000) pendingRequests[1].callback(error, null, 503, {}) clock.tick(6000) + pendingRequests[2].callback(error, null, 503, {}) + clock.tick(6000) + pendingRequests[3].callback(null, 'ok', 200, {}) - assert.strictEqual(pendingRequests.length, 2) - sinon.assert.calledOnceWithExactly(done, error, null, 503, {}) + assert.strictEqual(pendingRequests.length, 4) + sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) }) it('allows one more attempt when an overlapping final flush extends the deadline', () => { @@ -294,6 +297,47 @@ describe('Test Optimization exporter request', () => { sinon.assert.calledOnceWithExactly(done, error, undefined, undefined, undefined) }) + it('times out a transport attempt from creation and retries it', () => { + const done = sinon.spy() + request('payload', { deadline: Date.now() + 30_000, timeout: 1000 }, done) + + const firstRequest = pendingRequests[0] + firstRequest.options.signal.addEventListener('abort', () => { + const error = Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }) + firstRequest.callback(error) + }) + + clock.tick(999) + assert.strictEqual(firstRequest.options.signal.aborted, false) + clock.tick(1) + assert.strictEqual(firstRequest.options.signal.aborted, true) + sinon.assert.notCalled(done) + + clock.tick(5999) + assert.strictEqual(pendingRequests.length, 1) + clock.tick(1) + assert.strictEqual(pendingRequests.length, 2) + + pendingRequests[1].callback(null, 'ok', 200, {}) + sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) + }) + + it('reports a transport attempt timeout when retries are disabled', () => { + const done = sinon.spy() + request('payload', { retry: false, timeout: 1000 }, done) + + const pendingRequest = pendingRequests[0] + pendingRequest.options.signal.addEventListener('abort', () => { + const error = Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }) + pendingRequest.callback(error) + }) + + clock.tick(1000) + + sinon.assert.calledOnce(done) + assert.strictEqual(done.firstCall.args[0].code, 'ERR_DD_TEST_OPTIMIZATION_REQUEST_TIMEOUT') + }) + it('aborts a scheduled retry and completes once', () => { const controller = new AbortController() const done = sinon.spy() From 0837250ed203a5411a472e8453baeebc642f9a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 15:36:30 +0200 Subject: [PATCH 03/25] test(playwright): stabilize dynamic name detection --- .../dynamic-name-test.js | 21 +++++++--------- .../playwright/playwright-atr.spec.js | 25 +++++++++++++------ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/integration-tests/ci-visibility/playwright-tests-dynamic/dynamic-name-test.js b/integration-tests/ci-visibility/playwright-tests-dynamic/dynamic-name-test.js index 9fc84655ba..28ac888004 100644 --- a/integration-tests/ci-visibility/playwright-tests-dynamic/dynamic-name-test.js +++ b/integration-tests/ci-visibility/playwright-tests-dynamic/dynamic-name-test.js @@ -1,41 +1,38 @@ 'use strict' -const crypto = require('crypto') const { test, expect } = require('@playwright/test') -const uuid = crypto.randomBytes(16).toString('hex') - .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5') - test.describe('dynamic name suite', () => { - test(`can do stuff at ${Date.now()}`, () => { + // Playwright loads this file during discovery and again in workers, so test names must match across processes. + test('can do stuff at 1750000000000', () => { expect(1 + 2).toBe(3) }) - test(`connects to localhost:${3000 + Math.floor(Math.random() * 60000)}`, () => { + test('connects to localhost:54321', () => { expect(2 + 3).toBe(5) }) - test(`user session ${uuid}`, () => { + test('user session 12345678-1234-1234-1234-123456789abc', () => { expect(3 + 4).toBe(7) }) - test(`created at ${new Date().toISOString()}`, () => { + test('created at 2026-08-31T12:34:56.789Z', () => { expect(4 + 5).toBe(9) }) - test(`event on ${new Date().toISOString().split('T')[0]}`, () => { + test('event on 2026-08-31', () => { expect(5 + 6).toBe(11) }) - test(`probability ${Math.random()}`, () => { + test('probability 0.1234567890', () => { expect(6 + 7).toBe(13) }) - test(`server at 127.0.0.1:${3000 + Math.floor(Math.random() * 60000)}`, () => { + test('server at 127.0.0.1:54322', () => { expect(7 + 8).toBe(15) }) - test(`bound to 0.0.0.0:${3000 + Math.floor(Math.random() * 60000)}`, () => { + test('bound to 0.0.0.0:54323', () => { expect(8 + 9).toBe(17) }) }) diff --git a/integration-tests/playwright/playwright-atr.spec.js b/integration-tests/playwright/playwright-atr.spec.js index c50ed5e1eb..b70114885a 100644 --- a/integration-tests/playwright/playwright-atr.spec.js +++ b/integration-tests/playwright/playwright-atr.spec.js @@ -15,6 +15,9 @@ const { const { createWebAppServer } = require('../ci-visibility/web-app-server') const { TEST_STATUS, + TEST_NAME, + TEST_IS_NEW, + TEST_HAS_DYNAMIC_NAME, TEST_IS_RETRY, TEST_RETRY_REASON, TEST_HAS_FAILED_ALL_RETRIES, @@ -248,6 +251,19 @@ versions.forEach((version) => { }) receiver.setKnownTests({ playwright: {} }) + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const uniqueTests = new Map(tests.map(test => [test.meta[TEST_NAME], test])) + + assert.strictEqual(uniqueTests.size, 8) + for (const test of uniqueTests.values()) { + assert.strictEqual(test.meta[TEST_IS_NEW], 'true') + assert.strictEqual(test.meta[TEST_HAS_DYNAMIC_NAME], 'true') + } + }, 30000) + const proc = run( './node_modules/.bin/playwright test -c playwright.config.js', { @@ -263,14 +279,9 @@ versions.forEach((version) => { proc.stdout?.on('data', chunk => { testOutput += chunk.toString() }) proc.stderr?.on('data', chunk => { testOutput += chunk.toString() }) - const eventsPromise = receiver - .gatherPayloadsUntilChildExit(proc, ({ url }) => url.endsWith('/api/v2/citestcycle'), () => { - assert.match(testOutput, /detected as new but their names contain dynamic data/) - }) - const [[exitCode]] = await Promise.all([once(proc, 'exit'), eventsPromise]) - // Dynamic names differ between discovery and worker processes, so Playwright cannot find these tests. - assert.strictEqual(exitCode, 1, testOutput) + assert.strictEqual(exitCode, 0, testOutput) + assert.match(testOutput, /detected as new but their names contain dynamic data/) }) }) }) From 096831da428d71ed29ece1765a6c2154fe86e33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 15:51:40 +0200 Subject: [PATCH 04/25] test(playwright): await final telemetry delivery --- .../playwright-active-test-span.spec.js | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index b2d0dc7dd7..ad60affd99 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -197,7 +197,7 @@ versions.forEach((version) => { }) }) - const runRumTest = async (receiver, { isRedirecting }, extraEnvVars) => { + const runRumTest = async (receiver, { isRedirecting }, extraEnvVars, onTelemetryPayloads) => { const testAssertionsPromise = getTestAssertions(receiver, { isRedirecting }) let proc try { @@ -214,7 +214,16 @@ versions.forEach((version) => { } ) - const [[exitCode]] = await Promise.all([once(proc, 'exit'), testAssertionsPromise]) + const assertions = [once(proc, 'exit'), testAssertionsPromise] + if (onTelemetryPayloads) { + assertions.push(receiver.gatherPayloadsUntilChildExit( + proc, + ({ url }) => url.endsWith('/api/v2/apmtelemetry'), + onTelemetryPayloads + )) + } + + const [[exitCode]] = await Promise.all(assertions) assert.strictEqual(exitCode, isRedirecting ? 1 : 0) } finally { @@ -241,8 +250,14 @@ versions.forEach((version) => { }) it('sends telemetry for RUM browser tests when telemetry is enabled', async (receiver) => { - const telemetryPromise = receiver - .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/apmtelemetry'), (payloads) => { + await runRumTest( + receiver, + { isRedirecting: false }, + { + ...getCiVisEvpProxyConfig(receiver.port), + DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', + }, + (payloads) => { const telemetryEvents = payloads.flatMap(({ payload }) => payload.payload.series) const testSessionMetric = telemetryEvents.find( @@ -258,19 +273,8 @@ versions.forEach((version) => { assert.ok(tags.includes('is_rum'), `Got: ${inspect(tags)}`) assert.ok(tags.includes('test_framework:playwright'), `Got: ${inspect(tags)}`) }) - }) - - await Promise.all([ - runRumTest( - receiver, - { isRedirecting: false }, - { - ...getCiVisEvpProxyConfig(receiver.port), - DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', - } - ), - telemetryPromise, - ]) + } + ) }) it('do not crash when redirecting and RUM sessions are not active', async (receiver) => { From 1b15bca49fcf94c190d4f7ec04e9b5bbeaa0620e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 16:10:39 +0200 Subject: [PATCH 05/25] fix(test-optimization): await final telemetry metrics --- .../playwright-active-test-span.spec.js | 3 +- .../src/cypress-plugin.js | 3 +- packages/datadog-plugin-jest/src/index.js | 11 ++-- .../datadog-plugin-playwright/src/index.js | 3 +- packages/dd-trace/src/telemetry/index.js | 8 ++- packages/dd-trace/src/telemetry/metrics.js | 35 ++++++++++-- packages/dd-trace/src/telemetry/telemetry.js | 11 +++- .../dd-trace/test/telemetry/index.spec.js | 18 +++++-- .../dd-trace/test/telemetry/metrics.spec.js | 53 +++++++++++++++++++ 9 files changed, 125 insertions(+), 20 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index ad60affd99..357c685778 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -173,9 +173,10 @@ versions.forEach((version) => { contextNewVersions('correlation between tests and RUM sessions', () => { const getTestAssertions = (receiver, { isRedirecting }) => receiver - .gatherPayloadsMaxTimeout(({ url }) => url === '/api/v2/citestcycle', (payloads) => { + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { const events = payloads.flatMap(({ payload }) => payload.events) const tests = events.filter(event => event.type === 'test').map(event => event.content) + assert.ok(tests.length > 0, 'test events should be sent') tests.forEach(test => { if (isRedirecting) { // can't do assertions because playwright has been redirected diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 1cdffd58b6..858a51acb4 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -1329,8 +1329,7 @@ class CypressPlugin { return new Promise(resolve => { const finishAfterRun = () => { this._isInit = false - appClosingTelemetry() - resolve(null) + appClosingTelemetry(() => resolve(null)) } const exporter = this.tracer._tracer._exporter diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index 7e9513b18a..3b4cd92955 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -172,11 +172,12 @@ class JestPlugin extends CiPlugin { autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - appClosingTelemetry() - this.tracer._exporter.flush(() => { - if (onDone) { - onDone() - } + appClosingTelemetry(() => { + this.tracer._exporter.flush(() => { + if (onDone) { + onDone() + } + }) }) } diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index ee7281d948..c5563ccd7d 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -182,8 +182,7 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - appClosingTelemetry() - this.tracer._exporter.flush(onDone) + appClosingTelemetry(() => this.tracer._exporter.flush(onDone)) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined diff --git a/packages/dd-trace/src/telemetry/index.js b/packages/dd-trace/src/telemetry/index.js index bc277ddafb..8b566768bf 100644 --- a/packages/dd-trace/src/telemetry/index.js +++ b/packages/dd-trace/src/telemetry/index.js @@ -19,7 +19,11 @@ module.exports = { updateIntegrations () { telemetry?.updateIntegrations() }, - appClosing () { - telemetry?.appClosing() + appClosing (onMetricsSent) { + if (telemetry) { + telemetry.appClosing(onMetricsSent) + } else { + onMetricsSent?.() + } }, } diff --git a/packages/dd-trace/src/telemetry/metrics.js b/packages/dd-trace/src/telemetry/metrics.js index 3f244576a7..ca33df6569 100644 --- a/packages/dd-trace/src/telemetry/metrics.js +++ b/packages/dd-trace/src/telemetry/metrics.js @@ -308,16 +308,42 @@ class NamespaceManager extends Map { return mapToJsonArray(this) } - send (config, application, host) { + /** + * Sends all pending metrics and invokes the callback after every request settles. + * + * @param {import('../config/config-base')} config + * @param {import('./send-data').TelemetryApplication} application + * @param {import('./send-data').TelemetryHost} host + * @param {() => void} [onDone] + * @returns {void} + */ + send (config, application, host, onDone) { + let pendingRequests = 0 + let dispatching = true + + const requestDone = () => { + pendingRequests-- + if (!dispatching && pendingRequests === 0) onDone() + } + + const send = (requestType, payload) => { + if (!onDone) { + sendData(config, application, host, requestType, payload) + return + } + pendingRequests++ + sendData(config, application, host, requestType, payload, requestDone) + } + for (const namespace of this.values()) { const { metrics, sketches } = namespace.toJSON() if (metrics) { - sendData(config, application, host, 'generate-metrics', metrics) + send('generate-metrics', metrics) } if (sketches) { - sendData(config, application, host, 'sketches', sketches) + send('sketches', sketches) } // TODO: This could also be clear() but then it'd have to rebuild all @@ -325,6 +351,9 @@ class NamespaceManager extends Map { // with high cardinality and variability over time. namespace.reset() } + + dispatching = false + if (onDone && pendingRequests === 0) onDone() } } diff --git a/packages/dd-trace/src/telemetry/telemetry.js b/packages/dd-trace/src/telemetry/telemetry.js index cce74abb31..0f9b9aa7a5 100644 --- a/packages/dd-trace/src/telemetry/telemetry.js +++ b/packages/dd-trace/src/telemetry/telemetry.js @@ -192,8 +192,15 @@ function appStarted (config) { return app } -function appClosing () { +/** + * Flushes final telemetry, optionally reporting when metric requests settle. + * + * @param {() => void} [onMetricsSent] + * @returns {void} + */ +function appClosing (onMetricsSent) { if (!config?.telemetry.DD_INSTRUMENTATION_TELEMETRY_ENABLED) { + onMetricsSent?.() return } // Give chance to listeners to update metrics before shutting down. @@ -201,7 +208,7 @@ function appClosing () { const { reqType, payload } = createPayload('app-closing') sendData(config, application, host, reqType, payload) // We flush before shutting down. - metricsManager.send(config, application, host) + metricsManager.send(config, application, host, onMetricsSent) telemetryLogger.send(config, application, host) } diff --git a/packages/dd-trace/test/telemetry/index.spec.js b/packages/dd-trace/test/telemetry/index.spec.js index 493b8a35cd..a6584f57dd 100644 --- a/packages/dd-trace/test/telemetry/index.spec.js +++ b/packages/dd-trace/test/telemetry/index.spec.js @@ -44,16 +44,26 @@ describe('telemetry (proxy)', () => { it('should proxy when enabled', () => { const config = { telemetry: { DD_INSTRUMENTATION_TELEMETRY_ENABLED: true } } + const onMetricsSent = sinon.spy() proxy.start(config) proxy.updateIntegrations() proxy.updateConfig([], config) - proxy.appClosing() + proxy.appClosing(onMetricsSent) sinon.assert.calledWith(telemetry.start, config) sinon.assert.called(telemetry.updateIntegrations) sinon.assert.called(telemetry.updateConfig) - sinon.assert.called(telemetry.appClosing) + sinon.assert.calledWith(telemetry.appClosing, onMetricsSent) + }) + + it('should complete app closing when telemetry has not started', () => { + const onMetricsSent = sinon.spy() + + proxy.appClosing(onMetricsSent) + + sinon.assert.notCalled(telemetry.appClosing) + sinon.assert.calledOnce(onMetricsSent) }) it('should proxy when enabled from updateConfig', () => { @@ -273,6 +283,7 @@ describe('telemetry', () => { it('should not send app-closing if telemetry is not enabled', () => { const sendDataStub = sinon.stub() + const onMetricsSent = sinon.spy() const notEnabledTelemetry = proxyquire('../../src/telemetry/telemetry', { './send-data': { sendData: sendDataStub, @@ -289,8 +300,9 @@ describe('telemetry', () => { }, { _pluginsByName: pluginsByName, }) - notEnabledTelemetry.appClosing() + notEnabledTelemetry.appClosing(onMetricsSent) assert.strictEqual(sendDataStub.called, false) + sinon.assert.calledOnce(onMetricsSent) }) }) diff --git a/packages/dd-trace/test/telemetry/metrics.spec.js b/packages/dd-trace/test/telemetry/metrics.spec.js index b7a3114fb2..c50981c420 100644 --- a/packages/dd-trace/test/telemetry/metrics.spec.js +++ b/packages/dd-trace/test/telemetry/metrics.spec.js @@ -226,6 +226,59 @@ describe('metrics', () => { assert.strictEqual(sketch.count, 1) }) + it('should call onDone after every request settles', () => { + const manager = new metrics.NamespaceManager() + + manager.namespace('test').count('metric').inc() + manager.namespace('test').distribution('duration').track(42) + + const config = { + hostname: 'localhost', + port: 12345, + tags: { + 'runtime-id': 'abc123', + }, + } + const application = { + language_name: 'nodejs', + tracer_version: '1.2.3', + } + const host = {} + const onDone = sinon.spy() + + manager.send(config, application, host, onDone) + + sinon.assert.notCalled(onDone) + sendData.firstCall.args[5]() + sinon.assert.notCalled(onDone) + sendData.secondCall.args[5]() + sinon.assert.calledOnce(onDone) + }) + + it('should call onDone when there are no requests', () => { + const manager = new metrics.NamespaceManager() + const onDone = sinon.spy() + + manager.send({}, {}, {}, onDone) + + sinon.assert.notCalled(sendData) + sinon.assert.calledOnce(onDone) + }) + + it('should call onDone once after synchronous request callbacks', () => { + const manager = new metrics.NamespaceManager() + const onDone = sinon.spy() + + manager.namespace('test1').count('metric').inc() + manager.namespace('test2').count('metric').inc() + sendData.callsFake((config, application, host, requestType, payload, callback) => callback()) + + manager.send({}, {}, {}, onDone) + + sinon.assert.calledTwice(sendData) + sinon.assert.calledOnce(onDone) + }) + it('should not send empty metrics', () => { const manager = new metrics.NamespaceManager() From 7b0c2cefe4c70fc793ead42f0f1819088286d55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 16:26:37 +0200 Subject: [PATCH 06/25] test(playwright): expose missing telemetry metrics --- .../playwright/playwright-active-test-span.spec.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index 357c685778..042b194a2e 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -264,7 +264,10 @@ versions.forEach((version) => { const testSessionMetric = telemetryEvents.find( ({ metric }) => metric === 'test_session' ) - assert.ok(testSessionMetric, 'test_session telemetry metric should be sent') + assert.ok( + testSessionMetric, + `test_session telemetry metric should be sent. Got: ${inspect(telemetryEvents)}` + ) const eventFinishedTestEvents = telemetryEvents .filter(({ metric, tags }) => metric === 'event_finished' && tags.includes('event_type:test')) From 4ae21648f255cdd62299cce79f214f249c1c2622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 16:39:41 +0200 Subject: [PATCH 07/25] fix(test-optimization): flush telemetry after delivery --- .../playwright/playwright-active-test-span.spec.js | 4 +++- packages/datadog-plugin-jest/src/index.js | 4 ++-- packages/datadog-plugin-playwright/src/index.js | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index 042b194a2e..78b55d720f 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -266,7 +266,9 @@ versions.forEach((version) => { ) assert.ok( testSessionMetric, - `test_session telemetry metric should be sent. Got: ${inspect(telemetryEvents)}` + `test_session telemetry metric should be sent. Got: ${inspect( + telemetryEvents.map(({ metric, tags }) => ({ metric, tags })) + )}` ) const eventFinishedTestEvents = telemetryEvents diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index 3b4cd92955..d9ccc43a7a 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -172,8 +172,8 @@ class JestPlugin extends CiPlugin { autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - appClosingTelemetry(() => { - this.tracer._exporter.flush(() => { + this.tracer._exporter.flush(() => { + appClosingTelemetry(() => { if (onDone) { onDone() } diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index c5563ccd7d..915dedb272 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -182,7 +182,7 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - appClosingTelemetry(() => this.tracer._exporter.flush(onDone)) + this.tracer._exporter.flush(() => appClosingTelemetry(onDone)) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined From d769b853ba6b668cc983bf59904881429e01123e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 16:47:42 +0200 Subject: [PATCH 08/25] test(test-optimization): observe agentless telemetry --- integration-tests/ci-visibility-intake.js | 5 ++++- .../playwright/playwright-active-test-span.spec.js | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/integration-tests/ci-visibility-intake.js b/integration-tests/ci-visibility-intake.js index 170df12ca6..7bfcd7a130 100644 --- a/integration-tests/ci-visibility-intake.js +++ b/integration-tests/ci-visibility-intake.js @@ -447,7 +447,10 @@ class FakeCiVisIntake extends FakeAgent { }) }) - app.post('/telemetry/proxy/api/v2/apmtelemetry', express.json(), (req, res) => { + app.post([ + '/api/v2/apmtelemetry', + '/telemetry/proxy/api/v2/apmtelemetry', + ], express.json(), (req, res) => { res.status(200).send() if (req.body?.payload?.namespace !== 'civisibility') return this.emit('message', { diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index 78b55d720f..d6a0c5c525 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -260,16 +260,16 @@ versions.forEach((version) => { }, (payloads) => { const telemetryEvents = payloads.flatMap(({ payload }) => payload.payload.series) - - const testSessionMetric = telemetryEvents.find( - ({ metric }) => metric === 'test_session' - ) + const testSessionPayload = payloads.find(({ payload }) => { + return payload.payload.series.some(({ metric }) => metric === 'test_session') + }) assert.ok( - testSessionMetric, + testSessionPayload, `test_session telemetry metric should be sent. Got: ${inspect( telemetryEvents.map(({ metric, tags }) => ({ metric, tags })) )}` ) + assert.strictEqual(testSessionPayload.url, '/telemetry/proxy/api/v2/apmtelemetry') const eventFinishedTestEvents = telemetryEvents .filter(({ metric, tags }) => metric === 'event_finished' && tags.includes('event_type:test')) From bf75e52fbe4e8c6370454659f69e6fbd2ea55b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 17:02:52 +0200 Subject: [PATCH 09/25] test(playwright): expose finalization diagnostics --- .../playwright/playwright-active-test-span.spec.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index d6a0c5c525..97ed8dfb71 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -200,6 +200,7 @@ versions.forEach((version) => { const runRumTest = async (receiver, { isRedirecting }, extraEnvVars, onTelemetryPayloads) => { const testAssertionsPromise = getTestAssertions(receiver, { isRedirecting }) + let childOutput = '' let proc try { proc = exec( @@ -215,12 +216,15 @@ versions.forEach((version) => { } ) + proc.stdout.on('data', chunk => { childOutput += chunk }) + proc.stderr.on('data', chunk => { childOutput += chunk }) + const assertions = [once(proc, 'exit'), testAssertionsPromise] if (onTelemetryPayloads) { assertions.push(receiver.gatherPayloadsUntilChildExit( proc, ({ url }) => url.endsWith('/api/v2/apmtelemetry'), - onTelemetryPayloads + (payloads) => onTelemetryPayloads(payloads, childOutput.slice(-20_000)) )) } @@ -257,8 +261,9 @@ versions.forEach((version) => { { ...getCiVisEvpProxyConfig(receiver.port), DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', + DD_TRACE_DEBUG: 'true', }, - (payloads) => { + (payloads, childOutput) => { const telemetryEvents = payloads.flatMap(({ payload }) => payload.payload.series) const testSessionPayload = payloads.find(({ payload }) => { return payload.payload.series.some(({ metric }) => metric === 'test_session') @@ -267,7 +272,7 @@ versions.forEach((version) => { testSessionPayload, `test_session telemetry metric should be sent. Got: ${inspect( telemetryEvents.map(({ metric, tags }) => ({ metric, tags })) - )}` + )}\nChild output:\n${childOutput}` ) assert.strictEqual(testSessionPayload.url, '/telemetry/proxy/api/v2/apmtelemetry') From 16ed962a9d6781a6afe8a3eb64a065d47600f189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 17:12:22 +0200 Subject: [PATCH 10/25] test(playwright): trace final telemetry lifecycle --- .../playwright/playwright-active-test-span.spec.js | 3 ++- packages/datadog-plugin-playwright/src/index.js | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index 97ed8dfb71..fed767aed7 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -224,7 +224,8 @@ versions.forEach((version) => { assertions.push(receiver.gatherPayloadsUntilChildExit( proc, ({ url }) => url.endsWith('/api/v2/apmtelemetry'), - (payloads) => onTelemetryPayloads(payloads, childOutput.slice(-20_000)) + (payloads) => onTelemetryPayloads(payloads, childOutput.slice(-20_000)), + { hardTimeout: 70_000 } )) } diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 915dedb272..716c272045 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -134,6 +134,7 @@ class PlaywrightPlugin extends CiPlugin { error, onDone, }) => { + log.debug('Playwright test session finalization started') if (error) { this.#isFinalizingAfterError = true for (const testSuiteSpan of this._testSuiteSpansByTestSuiteAbsolutePath.values()) { @@ -147,6 +148,7 @@ class PlaywrightPlugin extends CiPlugin { } const finishSession = () => { + log.debug('Playwright test session spans are being finished') this.testModuleSpan.setTag(TEST_STATUS, status) this.testSessionSpan.setTag(TEST_STATUS, status) @@ -182,13 +184,21 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - this.tracer._exporter.flush(() => appClosingTelemetry(onDone)) + log.debug('Playwright test session payload flush started') + this.tracer._exporter.flush(() => { + log.debug('Playwright test session payload flush finished') + appClosingTelemetry(() => { + log.debug('Playwright test session telemetry flush finished') + onDone() + }) + }) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined } if (this.pendingTestFinishes > 0) { + log.debug('Playwright test session is waiting for %d pending test finishes', this.pendingTestFinishes) this.finishSession = finishSession } else { finishSession() From 97ed3a5d415e1af84311343af0b6fd17d5b93355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 17:18:26 +0200 Subject: [PATCH 11/25] test(playwright): enable finalization debug output --- integration-tests/playwright/playwright-active-test-span.spec.js | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index fed767aed7..da4babc495 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -263,6 +263,7 @@ versions.forEach((version) => { ...getCiVisEvpProxyConfig(receiver.port), DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', DD_TRACE_DEBUG: 'true', + DD_TRACE_LOG_LEVEL: 'debug', }, (payloads, childOutput) => { const telemetryEvents = payloads.flatMap(({ payload }) => payload.payload.series) From cd775cb59f17df4c8e05fb2e0ce74c90fb3ca4bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 17:25:23 +0200 Subject: [PATCH 12/25] test(playwright): surface finalization stages --- packages/datadog-plugin-playwright/src/index.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 716c272045..57ff56ccc6 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -134,7 +134,7 @@ class PlaywrightPlugin extends CiPlugin { error, onDone, }) => { - log.debug('Playwright test session finalization started') + log.warn('Playwright test session finalization started') if (error) { this.#isFinalizingAfterError = true for (const testSuiteSpan of this._testSuiteSpansByTestSuiteAbsolutePath.values()) { @@ -148,7 +148,7 @@ class PlaywrightPlugin extends CiPlugin { } const finishSession = () => { - log.debug('Playwright test session spans are being finished') + log.warn('Playwright test session spans are being finished') this.testModuleSpan.setTag(TEST_STATUS, status) this.testSessionSpan.setTag(TEST_STATUS, status) @@ -184,11 +184,11 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - log.debug('Playwright test session payload flush started') + log.warn('Playwright test session payload flush started') this.tracer._exporter.flush(() => { - log.debug('Playwright test session payload flush finished') + log.warn('Playwright test session payload flush finished') appClosingTelemetry(() => { - log.debug('Playwright test session telemetry flush finished') + log.warn('Playwright test session telemetry flush finished') onDone() }) }) @@ -198,7 +198,7 @@ class PlaywrightPlugin extends CiPlugin { } if (this.pendingTestFinishes > 0) { - log.debug('Playwright test session is waiting for %d pending test finishes', this.pendingTestFinishes) + log.warn('Playwright test session is waiting for %d pending test finishes', this.pendingTestFinishes) this.finishSession = finishSession } else { finishSession() From 00cab1772d5961b0534be855dc3ab09eddc69e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 17:33:34 +0200 Subject: [PATCH 13/25] test(playwright): retain finalization markers --- .../playwright/playwright-active-test-span.spec.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index da4babc495..63b8bbf31b 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -201,6 +201,7 @@ versions.forEach((version) => { const runRumTest = async (receiver, { isRedirecting }, extraEnvVars, onTelemetryPayloads) => { const testAssertionsPromise = getTestAssertions(receiver, { isRedirecting }) let childOutput = '' + let finalizationOutput = '' let proc try { proc = exec( @@ -216,15 +217,22 @@ versions.forEach((version) => { } ) - proc.stdout.on('data', chunk => { childOutput += chunk }) - proc.stderr.on('data', chunk => { childOutput += chunk }) + const captureOutput = (chunk) => { + const output = chunk.toString() + childOutput += output + for (const line of output.split('\n')) { + if (line.includes('Playwright test session')) finalizationOutput += `${line}\n` + } + } + proc.stdout.on('data', captureOutput) + proc.stderr.on('data', captureOutput) const assertions = [once(proc, 'exit'), testAssertionsPromise] if (onTelemetryPayloads) { assertions.push(receiver.gatherPayloadsUntilChildExit( proc, ({ url }) => url.endsWith('/api/v2/apmtelemetry'), - (payloads) => onTelemetryPayloads(payloads, childOutput.slice(-20_000)), + (payloads) => onTelemetryPayloads(payloads, `${finalizationOutput}\n${childOutput.slice(-20_000)}`), { hardTimeout: 70_000 } )) } From fe1c8a79391ef436b0a49d730bedd7bd52413ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 17:42:25 +0200 Subject: [PATCH 14/25] test(playwright): expose telemetry child exit --- .../playwright/playwright-active-test-span.spec.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index 63b8bbf31b..d6c034488d 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -232,7 +232,12 @@ versions.forEach((version) => { assertions.push(receiver.gatherPayloadsUntilChildExit( proc, ({ url }) => url.endsWith('/api/v2/apmtelemetry'), - (payloads) => onTelemetryPayloads(payloads, `${finalizationOutput}\n${childOutput.slice(-20_000)}`), + (payloads) => onTelemetryPayloads( + payloads, + `${finalizationOutput}\n${childOutput.slice(-20_000)}`, + proc.exitCode, + proc.signalCode + ), { hardTimeout: 70_000 } )) } @@ -273,7 +278,7 @@ versions.forEach((version) => { DD_TRACE_DEBUG: 'true', DD_TRACE_LOG_LEVEL: 'debug', }, - (payloads, childOutput) => { + (payloads, childOutput, exitCode, signalCode) => { const telemetryEvents = payloads.flatMap(({ payload }) => payload.payload.series) const testSessionPayload = payloads.find(({ payload }) => { return payload.payload.series.some(({ metric }) => metric === 'test_session') @@ -282,7 +287,7 @@ versions.forEach((version) => { testSessionPayload, `test_session telemetry metric should be sent. Got: ${inspect( telemetryEvents.map(({ metric, tags }) => ({ metric, tags })) - )}\nChild output:\n${childOutput}` + )}\nChild exit: ${exitCode ?? signalCode}\nChild output:\n${childOutput}` ) assert.strictEqual(testSessionPayload.url, '/telemetry/proxy/api/v2/apmtelemetry') From ab07ed489796c130bbc76108021a1778703958ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 18:23:29 +0200 Subject: [PATCH 15/25] test(playwright): isolate final telemetry assertion --- .../playwright-active-test-span.spec.js | 39 +++++-------------- .../datadog-plugin-playwright/src/index.js | 12 +----- 2 files changed, 11 insertions(+), 40 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index d6c034488d..747202914c 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -17,6 +17,7 @@ const { getCiVisEvpProxyConfig, assertObjectContains, createParallelIt, + withReceiver, } = require('../helpers') const { createWebAppServer } = require('../ci-visibility/web-app-server') const { createWebAppServerWithRedirect } = require('../ci-visibility/web-app-server-with-redirect') @@ -200,8 +201,6 @@ versions.forEach((version) => { const runRumTest = async (receiver, { isRedirecting }, extraEnvVars, onTelemetryPayloads) => { const testAssertionsPromise = getTestAssertions(receiver, { isRedirecting }) - let childOutput = '' - let finalizationOutput = '' let proc try { proc = exec( @@ -217,28 +216,12 @@ versions.forEach((version) => { } ) - const captureOutput = (chunk) => { - const output = chunk.toString() - childOutput += output - for (const line of output.split('\n')) { - if (line.includes('Playwright test session')) finalizationOutput += `${line}\n` - } - } - proc.stdout.on('data', captureOutput) - proc.stderr.on('data', captureOutput) - const assertions = [once(proc, 'exit'), testAssertionsPromise] if (onTelemetryPayloads) { assertions.push(receiver.gatherPayloadsUntilChildExit( proc, ({ url }) => url.endsWith('/api/v2/apmtelemetry'), - (payloads) => onTelemetryPayloads( - payloads, - `${finalizationOutput}\n${childOutput.slice(-20_000)}`, - proc.exitCode, - proc.signalCode - ), - { hardTimeout: 70_000 } + onTelemetryPayloads )) } @@ -268,17 +251,19 @@ versions.forEach((version) => { }) }) - it('sends telemetry for RUM browser tests when telemetry is enabled', async (receiver) => { + it('do not crash when redirecting and RUM sessions are not active', async (receiver) => { + await runRumTest(receiver, { isRedirecting: true }) + }) + + global.it('sends telemetry for RUM browser tests when telemetry is enabled', withReceiver(async (receiver) => { await runRumTest( receiver, { isRedirecting: false }, { ...getCiVisEvpProxyConfig(receiver.port), DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', - DD_TRACE_DEBUG: 'true', - DD_TRACE_LOG_LEVEL: 'debug', }, - (payloads, childOutput, exitCode, signalCode) => { + (payloads) => { const telemetryEvents = payloads.flatMap(({ payload }) => payload.payload.series) const testSessionPayload = payloads.find(({ payload }) => { return payload.payload.series.some(({ metric }) => metric === 'test_session') @@ -287,7 +272,7 @@ versions.forEach((version) => { testSessionPayload, `test_session telemetry metric should be sent. Got: ${inspect( telemetryEvents.map(({ metric, tags }) => ({ metric, tags })) - )}\nChild exit: ${exitCode ?? signalCode}\nChild output:\n${childOutput}` + )}` ) assert.strictEqual(testSessionPayload.url, '/telemetry/proxy/api/v2/apmtelemetry') @@ -301,11 +286,7 @@ versions.forEach((version) => { }) } ) - }) - - it('do not crash when redirecting and RUM sessions are not active', async (receiver) => { - await runRumTest(receiver, { isRedirecting: true }) - }) + })) }) contextNewVersions('check retries tagging', () => { diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 57ff56ccc6..915dedb272 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -134,7 +134,6 @@ class PlaywrightPlugin extends CiPlugin { error, onDone, }) => { - log.warn('Playwright test session finalization started') if (error) { this.#isFinalizingAfterError = true for (const testSuiteSpan of this._testSuiteSpansByTestSuiteAbsolutePath.values()) { @@ -148,7 +147,6 @@ class PlaywrightPlugin extends CiPlugin { } const finishSession = () => { - log.warn('Playwright test session spans are being finished') this.testModuleSpan.setTag(TEST_STATUS, status) this.testSessionSpan.setTag(TEST_STATUS, status) @@ -184,21 +182,13 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - log.warn('Playwright test session payload flush started') - this.tracer._exporter.flush(() => { - log.warn('Playwright test session payload flush finished') - appClosingTelemetry(() => { - log.warn('Playwright test session telemetry flush finished') - onDone() - }) - }) + this.tracer._exporter.flush(() => appClosingTelemetry(onDone)) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined } if (this.pendingTestFinishes > 0) { - log.warn('Playwright test session is waiting for %d pending test finishes', this.pendingTestFinishes) this.finishSession = finishSession } else { finishSession() From 16fafac4ae86c9f2e3323a2271abd6ceaa9e2a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 18:35:34 +0200 Subject: [PATCH 16/25] fix(test-optimization): flush telemetry alongside payloads --- .../playwright-active-test-span.spec.js | 13 ++++----- .../src/cypress-plugin.js | 28 +++++++------------ packages/datadog-plugin-jest/src/index.js | 14 +++++----- .../datadog-plugin-playwright/src/index.js | 8 +++++- 4 files changed, 30 insertions(+), 33 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index 747202914c..d6a0c5c525 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -17,7 +17,6 @@ const { getCiVisEvpProxyConfig, assertObjectContains, createParallelIt, - withReceiver, } = require('../helpers') const { createWebAppServer } = require('../ci-visibility/web-app-server') const { createWebAppServerWithRedirect } = require('../ci-visibility/web-app-server-with-redirect') @@ -251,11 +250,7 @@ versions.forEach((version) => { }) }) - it('do not crash when redirecting and RUM sessions are not active', async (receiver) => { - await runRumTest(receiver, { isRedirecting: true }) - }) - - global.it('sends telemetry for RUM browser tests when telemetry is enabled', withReceiver(async (receiver) => { + it('sends telemetry for RUM browser tests when telemetry is enabled', async (receiver) => { await runRumTest( receiver, { isRedirecting: false }, @@ -286,7 +281,11 @@ versions.forEach((version) => { }) } ) - })) + }) + + it('do not crash when redirecting and RUM sessions are not active', async (receiver) => { + await runRumTest(receiver, { isRedirecting: true }) + }) }) contextNewVersions('check retries tagging', () => { diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 858a51acb4..373b6c0f28 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -1327,27 +1327,19 @@ class CypressPlugin { } return new Promise(resolve => { - const finishAfterRun = () => { + const exporter = this.tracer._tracer._exporter + const flushExporter = exporter?.flush?.bind(exporter) || exporter?._writer?.flush?.bind(exporter._writer) + let pendingFlushes = flushExporter ? 2 : 1 + const onFlush = () => { + pendingFlushes-- + if (pendingFlushes !== 0) return + this._isInit = false - appClosingTelemetry(() => resolve(null)) + resolve(null) } + appClosingTelemetry(onFlush) - const exporter = this.tracer._tracer._exporter - if (!exporter) { - finishAfterRun() - return - } - if (exporter.flush) { - exporter.flush(() => { - finishAfterRun() - }) - } else if (exporter._writer) { - exporter._writer.flush(() => { - finishAfterRun() - }) - } else { - finishAfterRun() - } + flushExporter?.(onFlush) }) } diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index d9ccc43a7a..89257c44d2 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -172,13 +172,13 @@ class JestPlugin extends CiPlugin { autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - this.tracer._exporter.flush(() => { - appClosingTelemetry(() => { - if (onDone) { - onDone() - } - }) - }) + let pendingFlushes = 2 + const onFlush = () => { + pendingFlushes-- + if (pendingFlushes === 0) onDone?.() + } + appClosingTelemetry(onFlush) + this.tracer._exporter.flush(onFlush) } if (this.pendingTestSuiteFinishes.size > 0) { diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 915dedb272..3973fd0a24 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -182,7 +182,13 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - this.tracer._exporter.flush(() => appClosingTelemetry(onDone)) + let pendingFlushes = 2 + const onFlush = () => { + pendingFlushes-- + if (pendingFlushes === 0) onDone() + } + appClosingTelemetry(onFlush) + this.tracer._exporter.flush(onFlush) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined From 3b9a84b593cf8997a45f78f8529dec8b541ef3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 19:37:12 +0200 Subject: [PATCH 17/25] test(playwright): await runner telemetry --- .../playwright-active-test-span.spec.js | 38 +++++++++---------- .../src/cypress-plugin.js | 28 +++++++++----- packages/datadog-plugin-jest/src/index.js | 14 +++---- .../datadog-plugin-playwright/src/index.js | 8 +--- 4 files changed, 43 insertions(+), 45 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index d6a0c5c525..a739c06420 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -198,7 +198,7 @@ versions.forEach((version) => { }) }) - const runRumTest = async (receiver, { isRedirecting }, extraEnvVars, onTelemetryPayloads) => { + const runRumTest = async (receiver, { isRedirecting }, extraEnvVars) => { const testAssertionsPromise = getTestAssertions(receiver, { isRedirecting }) let proc try { @@ -215,16 +215,7 @@ versions.forEach((version) => { } ) - const assertions = [once(proc, 'exit'), testAssertionsPromise] - if (onTelemetryPayloads) { - assertions.push(receiver.gatherPayloadsUntilChildExit( - proc, - ({ url }) => url.endsWith('/api/v2/apmtelemetry'), - onTelemetryPayloads - )) - } - - const [[exitCode]] = await Promise.all(assertions) + const [[exitCode]] = await Promise.all([once(proc, 'exit'), testAssertionsPromise]) assert.strictEqual(exitCode, isRedirecting ? 1 : 0) } finally { @@ -251,14 +242,8 @@ versions.forEach((version) => { }) it('sends telemetry for RUM browser tests when telemetry is enabled', async (receiver) => { - await runRumTest( - receiver, - { isRedirecting: false }, - { - ...getCiVisEvpProxyConfig(receiver.port), - DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', - }, - (payloads) => { + const telemetryPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/apmtelemetry'), (payloads) => { const telemetryEvents = payloads.flatMap(({ payload }) => payload.payload.series) const testSessionPayload = payloads.find(({ payload }) => { return payload.payload.series.some(({ metric }) => metric === 'test_session') @@ -279,8 +264,19 @@ versions.forEach((version) => { assert.ok(tags.includes('is_rum'), `Got: ${inspect(tags)}`) assert.ok(tags.includes('test_framework:playwright'), `Got: ${inspect(tags)}`) }) - } - ) + }) + + await Promise.all([ + runRumTest( + receiver, + { isRedirecting: false }, + { + ...getCiVisEvpProxyConfig(receiver.port), + DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', + } + ), + telemetryPromise, + ]) }) it('do not crash when redirecting and RUM sessions are not active', async (receiver) => { diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 373b6c0f28..858a51acb4 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -1327,19 +1327,27 @@ class CypressPlugin { } return new Promise(resolve => { - const exporter = this.tracer._tracer._exporter - const flushExporter = exporter?.flush?.bind(exporter) || exporter?._writer?.flush?.bind(exporter._writer) - let pendingFlushes = flushExporter ? 2 : 1 - const onFlush = () => { - pendingFlushes-- - if (pendingFlushes !== 0) return - + const finishAfterRun = () => { this._isInit = false - resolve(null) + appClosingTelemetry(() => resolve(null)) } - appClosingTelemetry(onFlush) - flushExporter?.(onFlush) + const exporter = this.tracer._tracer._exporter + if (!exporter) { + finishAfterRun() + return + } + if (exporter.flush) { + exporter.flush(() => { + finishAfterRun() + }) + } else if (exporter._writer) { + exporter._writer.flush(() => { + finishAfterRun() + }) + } else { + finishAfterRun() + } }) } diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index 89257c44d2..d9ccc43a7a 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -172,13 +172,13 @@ class JestPlugin extends CiPlugin { autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - let pendingFlushes = 2 - const onFlush = () => { - pendingFlushes-- - if (pendingFlushes === 0) onDone?.() - } - appClosingTelemetry(onFlush) - this.tracer._exporter.flush(onFlush) + this.tracer._exporter.flush(() => { + appClosingTelemetry(() => { + if (onDone) { + onDone() + } + }) + }) } if (this.pendingTestSuiteFinishes.size > 0) { diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 3973fd0a24..915dedb272 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -182,13 +182,7 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - let pendingFlushes = 2 - const onFlush = () => { - pendingFlushes-- - if (pendingFlushes === 0) onDone() - } - appClosingTelemetry(onFlush) - this.tracer._exporter.flush(onFlush) + this.tracer._exporter.flush(() => appClosingTelemetry(onDone)) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined From c57f1ec2b397ee7b5bc8e3bae9c0a6a62967a089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 20:46:06 +0200 Subject: [PATCH 18/25] fix(test-optimization): dispatch final telemetry before flush --- .../src/cypress-plugin.js | 3 +- packages/datadog-plugin-jest/src/index.js | 9 ++-- .../datadog-plugin-playwright/src/index.js | 3 +- packages/dd-trace/src/telemetry/index.js | 8 +-- packages/dd-trace/src/telemetry/metrics.js | 35 ++---------- packages/dd-trace/src/telemetry/telemetry.js | 11 +--- .../dd-trace/test/telemetry/index.spec.js | 18 ++----- .../dd-trace/test/telemetry/metrics.spec.js | 53 ------------------- 8 files changed, 18 insertions(+), 122 deletions(-) diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 858a51acb4..1cdffd58b6 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -1329,7 +1329,8 @@ class CypressPlugin { return new Promise(resolve => { const finishAfterRun = () => { this._isInit = false - appClosingTelemetry(() => resolve(null)) + appClosingTelemetry() + resolve(null) } const exporter = this.tracer._tracer._exporter diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index d9ccc43a7a..7e9513b18a 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -172,12 +172,11 @@ class JestPlugin extends CiPlugin { autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) + appClosingTelemetry() this.tracer._exporter.flush(() => { - appClosingTelemetry(() => { - if (onDone) { - onDone() - } - }) + if (onDone) { + onDone() + } }) } diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 915dedb272..ee7281d948 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -182,7 +182,8 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - this.tracer._exporter.flush(() => appClosingTelemetry(onDone)) + appClosingTelemetry() + this.tracer._exporter.flush(onDone) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined diff --git a/packages/dd-trace/src/telemetry/index.js b/packages/dd-trace/src/telemetry/index.js index 8b566768bf..bc277ddafb 100644 --- a/packages/dd-trace/src/telemetry/index.js +++ b/packages/dd-trace/src/telemetry/index.js @@ -19,11 +19,7 @@ module.exports = { updateIntegrations () { telemetry?.updateIntegrations() }, - appClosing (onMetricsSent) { - if (telemetry) { - telemetry.appClosing(onMetricsSent) - } else { - onMetricsSent?.() - } + appClosing () { + telemetry?.appClosing() }, } diff --git a/packages/dd-trace/src/telemetry/metrics.js b/packages/dd-trace/src/telemetry/metrics.js index ca33df6569..3f244576a7 100644 --- a/packages/dd-trace/src/telemetry/metrics.js +++ b/packages/dd-trace/src/telemetry/metrics.js @@ -308,42 +308,16 @@ class NamespaceManager extends Map { return mapToJsonArray(this) } - /** - * Sends all pending metrics and invokes the callback after every request settles. - * - * @param {import('../config/config-base')} config - * @param {import('./send-data').TelemetryApplication} application - * @param {import('./send-data').TelemetryHost} host - * @param {() => void} [onDone] - * @returns {void} - */ - send (config, application, host, onDone) { - let pendingRequests = 0 - let dispatching = true - - const requestDone = () => { - pendingRequests-- - if (!dispatching && pendingRequests === 0) onDone() - } - - const send = (requestType, payload) => { - if (!onDone) { - sendData(config, application, host, requestType, payload) - return - } - pendingRequests++ - sendData(config, application, host, requestType, payload, requestDone) - } - + send (config, application, host) { for (const namespace of this.values()) { const { metrics, sketches } = namespace.toJSON() if (metrics) { - send('generate-metrics', metrics) + sendData(config, application, host, 'generate-metrics', metrics) } if (sketches) { - send('sketches', sketches) + sendData(config, application, host, 'sketches', sketches) } // TODO: This could also be clear() but then it'd have to rebuild all @@ -351,9 +325,6 @@ class NamespaceManager extends Map { // with high cardinality and variability over time. namespace.reset() } - - dispatching = false - if (onDone && pendingRequests === 0) onDone() } } diff --git a/packages/dd-trace/src/telemetry/telemetry.js b/packages/dd-trace/src/telemetry/telemetry.js index 0f9b9aa7a5..cce74abb31 100644 --- a/packages/dd-trace/src/telemetry/telemetry.js +++ b/packages/dd-trace/src/telemetry/telemetry.js @@ -192,15 +192,8 @@ function appStarted (config) { return app } -/** - * Flushes final telemetry, optionally reporting when metric requests settle. - * - * @param {() => void} [onMetricsSent] - * @returns {void} - */ -function appClosing (onMetricsSent) { +function appClosing () { if (!config?.telemetry.DD_INSTRUMENTATION_TELEMETRY_ENABLED) { - onMetricsSent?.() return } // Give chance to listeners to update metrics before shutting down. @@ -208,7 +201,7 @@ function appClosing (onMetricsSent) { const { reqType, payload } = createPayload('app-closing') sendData(config, application, host, reqType, payload) // We flush before shutting down. - metricsManager.send(config, application, host, onMetricsSent) + metricsManager.send(config, application, host) telemetryLogger.send(config, application, host) } diff --git a/packages/dd-trace/test/telemetry/index.spec.js b/packages/dd-trace/test/telemetry/index.spec.js index a6584f57dd..493b8a35cd 100644 --- a/packages/dd-trace/test/telemetry/index.spec.js +++ b/packages/dd-trace/test/telemetry/index.spec.js @@ -44,26 +44,16 @@ describe('telemetry (proxy)', () => { it('should proxy when enabled', () => { const config = { telemetry: { DD_INSTRUMENTATION_TELEMETRY_ENABLED: true } } - const onMetricsSent = sinon.spy() proxy.start(config) proxy.updateIntegrations() proxy.updateConfig([], config) - proxy.appClosing(onMetricsSent) + proxy.appClosing() sinon.assert.calledWith(telemetry.start, config) sinon.assert.called(telemetry.updateIntegrations) sinon.assert.called(telemetry.updateConfig) - sinon.assert.calledWith(telemetry.appClosing, onMetricsSent) - }) - - it('should complete app closing when telemetry has not started', () => { - const onMetricsSent = sinon.spy() - - proxy.appClosing(onMetricsSent) - - sinon.assert.notCalled(telemetry.appClosing) - sinon.assert.calledOnce(onMetricsSent) + sinon.assert.called(telemetry.appClosing) }) it('should proxy when enabled from updateConfig', () => { @@ -283,7 +273,6 @@ describe('telemetry', () => { it('should not send app-closing if telemetry is not enabled', () => { const sendDataStub = sinon.stub() - const onMetricsSent = sinon.spy() const notEnabledTelemetry = proxyquire('../../src/telemetry/telemetry', { './send-data': { sendData: sendDataStub, @@ -300,9 +289,8 @@ describe('telemetry', () => { }, { _pluginsByName: pluginsByName, }) - notEnabledTelemetry.appClosing(onMetricsSent) + notEnabledTelemetry.appClosing() assert.strictEqual(sendDataStub.called, false) - sinon.assert.calledOnce(onMetricsSent) }) }) diff --git a/packages/dd-trace/test/telemetry/metrics.spec.js b/packages/dd-trace/test/telemetry/metrics.spec.js index c50981c420..b7a3114fb2 100644 --- a/packages/dd-trace/test/telemetry/metrics.spec.js +++ b/packages/dd-trace/test/telemetry/metrics.spec.js @@ -226,59 +226,6 @@ describe('metrics', () => { assert.strictEqual(sketch.count, 1) }) - it('should call onDone after every request settles', () => { - const manager = new metrics.NamespaceManager() - - manager.namespace('test').count('metric').inc() - manager.namespace('test').distribution('duration').track(42) - - const config = { - hostname: 'localhost', - port: 12345, - tags: { - 'runtime-id': 'abc123', - }, - } - const application = { - language_name: 'nodejs', - tracer_version: '1.2.3', - } - const host = {} - const onDone = sinon.spy() - - manager.send(config, application, host, onDone) - - sinon.assert.notCalled(onDone) - sendData.firstCall.args[5]() - sinon.assert.notCalled(onDone) - sendData.secondCall.args[5]() - sinon.assert.calledOnce(onDone) - }) - - it('should call onDone when there are no requests', () => { - const manager = new metrics.NamespaceManager() - const onDone = sinon.spy() - - manager.send({}, {}, {}, onDone) - - sinon.assert.notCalled(sendData) - sinon.assert.calledOnce(onDone) - }) - - it('should call onDone once after synchronous request callbacks', () => { - const manager = new metrics.NamespaceManager() - const onDone = sinon.spy() - - manager.namespace('test1').count('metric').inc() - manager.namespace('test2').count('metric').inc() - sendData.callsFake((config, application, host, requestType, payload, callback) => callback()) - - manager.send({}, {}, {}, onDone) - - sinon.assert.calledTwice(sendData) - sinon.assert.calledOnce(onDone) - }) - it('should not send empty metrics', () => { const manager = new metrics.NamespaceManager() From 084173b52a071bbf2c578aa0e146ff3bd7c3364f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 21:41:21 +0200 Subject: [PATCH 19/25] test(playwright): collect telemetry through process exit --- .../playwright-active-test-span.spec.js | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index a739c06420..d6a0c5c525 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -198,7 +198,7 @@ versions.forEach((version) => { }) }) - const runRumTest = async (receiver, { isRedirecting }, extraEnvVars) => { + const runRumTest = async (receiver, { isRedirecting }, extraEnvVars, onTelemetryPayloads) => { const testAssertionsPromise = getTestAssertions(receiver, { isRedirecting }) let proc try { @@ -215,7 +215,16 @@ versions.forEach((version) => { } ) - const [[exitCode]] = await Promise.all([once(proc, 'exit'), testAssertionsPromise]) + const assertions = [once(proc, 'exit'), testAssertionsPromise] + if (onTelemetryPayloads) { + assertions.push(receiver.gatherPayloadsUntilChildExit( + proc, + ({ url }) => url.endsWith('/api/v2/apmtelemetry'), + onTelemetryPayloads + )) + } + + const [[exitCode]] = await Promise.all(assertions) assert.strictEqual(exitCode, isRedirecting ? 1 : 0) } finally { @@ -242,8 +251,14 @@ versions.forEach((version) => { }) it('sends telemetry for RUM browser tests when telemetry is enabled', async (receiver) => { - const telemetryPromise = receiver - .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/apmtelemetry'), (payloads) => { + await runRumTest( + receiver, + { isRedirecting: false }, + { + ...getCiVisEvpProxyConfig(receiver.port), + DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', + }, + (payloads) => { const telemetryEvents = payloads.flatMap(({ payload }) => payload.payload.series) const testSessionPayload = payloads.find(({ payload }) => { return payload.payload.series.some(({ metric }) => metric === 'test_session') @@ -264,19 +279,8 @@ versions.forEach((version) => { assert.ok(tags.includes('is_rum'), `Got: ${inspect(tags)}`) assert.ok(tags.includes('test_framework:playwright'), `Got: ${inspect(tags)}`) }) - }) - - await Promise.all([ - runRumTest( - receiver, - { isRedirecting: false }, - { - ...getCiVisEvpProxyConfig(receiver.port), - DD_INSTRUMENTATION_TELEMETRY_ENABLED: 'true', - } - ), - telemetryPromise, - ]) + } + ) }) it('do not crash when redirecting and RUM sessions are not active', async (receiver) => { From a25ae0654a7b026d41026d51b717fa4d622f3618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 21:53:43 +0200 Subject: [PATCH 20/25] fix(test-optimization): await parallel final delivery --- .../src/cypress-plugin.js | 27 ++++------ packages/datadog-plugin-jest/src/index.js | 13 ++--- .../datadog-plugin-playwright/src/index.js | 9 +++- packages/dd-trace/src/telemetry/index.js | 8 ++- packages/dd-trace/src/telemetry/metrics.js | 35 ++++++++++-- packages/dd-trace/src/telemetry/telemetry.js | 11 +++- .../dd-trace/test/telemetry/index.spec.js | 18 +++++-- .../dd-trace/test/telemetry/metrics.spec.js | 53 +++++++++++++++++++ 8 files changed, 138 insertions(+), 36 deletions(-) diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 1cdffd58b6..373b6c0f28 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -1327,28 +1327,19 @@ class CypressPlugin { } return new Promise(resolve => { - const finishAfterRun = () => { + const exporter = this.tracer._tracer._exporter + const flushExporter = exporter?.flush?.bind(exporter) || exporter?._writer?.flush?.bind(exporter._writer) + let pendingFlushes = flushExporter ? 2 : 1 + const onFlush = () => { + pendingFlushes-- + if (pendingFlushes !== 0) return + this._isInit = false - appClosingTelemetry() resolve(null) } + appClosingTelemetry(onFlush) - const exporter = this.tracer._tracer._exporter - if (!exporter) { - finishAfterRun() - return - } - if (exporter.flush) { - exporter.flush(() => { - finishAfterRun() - }) - } else if (exporter._writer) { - exporter._writer.flush(() => { - finishAfterRun() - }) - } else { - finishAfterRun() - } + flushExporter?.(onFlush) }) } diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index 7e9513b18a..89257c44d2 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -172,12 +172,13 @@ class JestPlugin extends CiPlugin { autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - appClosingTelemetry() - this.tracer._exporter.flush(() => { - if (onDone) { - onDone() - } - }) + let pendingFlushes = 2 + const onFlush = () => { + pendingFlushes-- + if (pendingFlushes === 0) onDone?.() + } + appClosingTelemetry(onFlush) + this.tracer._exporter.flush(onFlush) } if (this.pendingTestSuiteFinishes.size > 0) { diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index ee7281d948..3973fd0a24 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -182,8 +182,13 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - appClosingTelemetry() - this.tracer._exporter.flush(onDone) + let pendingFlushes = 2 + const onFlush = () => { + pendingFlushes-- + if (pendingFlushes === 0) onDone() + } + appClosingTelemetry(onFlush) + this.tracer._exporter.flush(onFlush) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined diff --git a/packages/dd-trace/src/telemetry/index.js b/packages/dd-trace/src/telemetry/index.js index bc277ddafb..8b566768bf 100644 --- a/packages/dd-trace/src/telemetry/index.js +++ b/packages/dd-trace/src/telemetry/index.js @@ -19,7 +19,11 @@ module.exports = { updateIntegrations () { telemetry?.updateIntegrations() }, - appClosing () { - telemetry?.appClosing() + appClosing (onMetricsSent) { + if (telemetry) { + telemetry.appClosing(onMetricsSent) + } else { + onMetricsSent?.() + } }, } diff --git a/packages/dd-trace/src/telemetry/metrics.js b/packages/dd-trace/src/telemetry/metrics.js index 3f244576a7..ca33df6569 100644 --- a/packages/dd-trace/src/telemetry/metrics.js +++ b/packages/dd-trace/src/telemetry/metrics.js @@ -308,16 +308,42 @@ class NamespaceManager extends Map { return mapToJsonArray(this) } - send (config, application, host) { + /** + * Sends all pending metrics and invokes the callback after every request settles. + * + * @param {import('../config/config-base')} config + * @param {import('./send-data').TelemetryApplication} application + * @param {import('./send-data').TelemetryHost} host + * @param {() => void} [onDone] + * @returns {void} + */ + send (config, application, host, onDone) { + let pendingRequests = 0 + let dispatching = true + + const requestDone = () => { + pendingRequests-- + if (!dispatching && pendingRequests === 0) onDone() + } + + const send = (requestType, payload) => { + if (!onDone) { + sendData(config, application, host, requestType, payload) + return + } + pendingRequests++ + sendData(config, application, host, requestType, payload, requestDone) + } + for (const namespace of this.values()) { const { metrics, sketches } = namespace.toJSON() if (metrics) { - sendData(config, application, host, 'generate-metrics', metrics) + send('generate-metrics', metrics) } if (sketches) { - sendData(config, application, host, 'sketches', sketches) + send('sketches', sketches) } // TODO: This could also be clear() but then it'd have to rebuild all @@ -325,6 +351,9 @@ class NamespaceManager extends Map { // with high cardinality and variability over time. namespace.reset() } + + dispatching = false + if (onDone && pendingRequests === 0) onDone() } } diff --git a/packages/dd-trace/src/telemetry/telemetry.js b/packages/dd-trace/src/telemetry/telemetry.js index cce74abb31..0f9b9aa7a5 100644 --- a/packages/dd-trace/src/telemetry/telemetry.js +++ b/packages/dd-trace/src/telemetry/telemetry.js @@ -192,8 +192,15 @@ function appStarted (config) { return app } -function appClosing () { +/** + * Flushes final telemetry, optionally reporting when metric requests settle. + * + * @param {() => void} [onMetricsSent] + * @returns {void} + */ +function appClosing (onMetricsSent) { if (!config?.telemetry.DD_INSTRUMENTATION_TELEMETRY_ENABLED) { + onMetricsSent?.() return } // Give chance to listeners to update metrics before shutting down. @@ -201,7 +208,7 @@ function appClosing () { const { reqType, payload } = createPayload('app-closing') sendData(config, application, host, reqType, payload) // We flush before shutting down. - metricsManager.send(config, application, host) + metricsManager.send(config, application, host, onMetricsSent) telemetryLogger.send(config, application, host) } diff --git a/packages/dd-trace/test/telemetry/index.spec.js b/packages/dd-trace/test/telemetry/index.spec.js index 493b8a35cd..a6584f57dd 100644 --- a/packages/dd-trace/test/telemetry/index.spec.js +++ b/packages/dd-trace/test/telemetry/index.spec.js @@ -44,16 +44,26 @@ describe('telemetry (proxy)', () => { it('should proxy when enabled', () => { const config = { telemetry: { DD_INSTRUMENTATION_TELEMETRY_ENABLED: true } } + const onMetricsSent = sinon.spy() proxy.start(config) proxy.updateIntegrations() proxy.updateConfig([], config) - proxy.appClosing() + proxy.appClosing(onMetricsSent) sinon.assert.calledWith(telemetry.start, config) sinon.assert.called(telemetry.updateIntegrations) sinon.assert.called(telemetry.updateConfig) - sinon.assert.called(telemetry.appClosing) + sinon.assert.calledWith(telemetry.appClosing, onMetricsSent) + }) + + it('should complete app closing when telemetry has not started', () => { + const onMetricsSent = sinon.spy() + + proxy.appClosing(onMetricsSent) + + sinon.assert.notCalled(telemetry.appClosing) + sinon.assert.calledOnce(onMetricsSent) }) it('should proxy when enabled from updateConfig', () => { @@ -273,6 +283,7 @@ describe('telemetry', () => { it('should not send app-closing if telemetry is not enabled', () => { const sendDataStub = sinon.stub() + const onMetricsSent = sinon.spy() const notEnabledTelemetry = proxyquire('../../src/telemetry/telemetry', { './send-data': { sendData: sendDataStub, @@ -289,8 +300,9 @@ describe('telemetry', () => { }, { _pluginsByName: pluginsByName, }) - notEnabledTelemetry.appClosing() + notEnabledTelemetry.appClosing(onMetricsSent) assert.strictEqual(sendDataStub.called, false) + sinon.assert.calledOnce(onMetricsSent) }) }) diff --git a/packages/dd-trace/test/telemetry/metrics.spec.js b/packages/dd-trace/test/telemetry/metrics.spec.js index b7a3114fb2..c50981c420 100644 --- a/packages/dd-trace/test/telemetry/metrics.spec.js +++ b/packages/dd-trace/test/telemetry/metrics.spec.js @@ -226,6 +226,59 @@ describe('metrics', () => { assert.strictEqual(sketch.count, 1) }) + it('should call onDone after every request settles', () => { + const manager = new metrics.NamespaceManager() + + manager.namespace('test').count('metric').inc() + manager.namespace('test').distribution('duration').track(42) + + const config = { + hostname: 'localhost', + port: 12345, + tags: { + 'runtime-id': 'abc123', + }, + } + const application = { + language_name: 'nodejs', + tracer_version: '1.2.3', + } + const host = {} + const onDone = sinon.spy() + + manager.send(config, application, host, onDone) + + sinon.assert.notCalled(onDone) + sendData.firstCall.args[5]() + sinon.assert.notCalled(onDone) + sendData.secondCall.args[5]() + sinon.assert.calledOnce(onDone) + }) + + it('should call onDone when there are no requests', () => { + const manager = new metrics.NamespaceManager() + const onDone = sinon.spy() + + manager.send({}, {}, {}, onDone) + + sinon.assert.notCalled(sendData) + sinon.assert.calledOnce(onDone) + }) + + it('should call onDone once after synchronous request callbacks', () => { + const manager = new metrics.NamespaceManager() + const onDone = sinon.spy() + + manager.namespace('test1').count('metric').inc() + manager.namespace('test2').count('metric').inc() + sendData.callsFake((config, application, host, requestType, payload, callback) => callback()) + + manager.send({}, {}, {}, onDone) + + sinon.assert.calledTwice(sendData) + sinon.assert.calledOnce(onDone) + }) + it('should not send empty metrics', () => { const manager = new metrics.NamespaceManager() From 3d0137004c143ae139277d4644ca8bf485a9f1dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 22:13:08 +0200 Subject: [PATCH 21/25] test(playwright): allow final delivery on CI --- .../playwright/playwright-active-test-span.spec.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index d6a0c5c525..69af4f3ca4 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -220,7 +220,8 @@ versions.forEach((version) => { assertions.push(receiver.gatherPayloadsUntilChildExit( proc, ({ url }) => url.endsWith('/api/v2/apmtelemetry'), - onTelemetryPayloads + onTelemetryPayloads, + { hardTimeout: 55_000 } )) } From 164649c682ffa99c409d7da13ad342a6ff12a43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 23:12:32 +0200 Subject: [PATCH 22/25] fix(test-optimization): keep final telemetry retries alive --- .../playwright-active-test-span.spec.js | 3 +- .../dd-trace/src/exporters/common/request.js | 6 +-- packages/dd-trace/src/telemetry/metrics.js | 2 +- packages/dd-trace/src/telemetry/send-data.js | 12 +++++- .../test/exporters/common/request.spec.js | 39 +++++++++++++++++++ .../dd-trace/test/telemetry/metrics.spec.js | 4 ++ .../dd-trace/test/telemetry/send-data.spec.js | 20 ++++++++++ 7 files changed, 78 insertions(+), 8 deletions(-) diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index 69af4f3ca4..d6a0c5c525 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -220,8 +220,7 @@ versions.forEach((version) => { assertions.push(receiver.gatherPayloadsUntilChildExit( proc, ({ url }) => url.endsWith('/api/v2/apmtelemetry'), - onTelemetryPayloads, - { hardTimeout: 55_000 } + onTelemetryPayloads )) } diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index 77c93deed4..b0c3d9cb45 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -212,10 +212,8 @@ function request (data, options, callback) { isRetriableNetworkError(error)) { settled = true finalize() - // Unref so a pending retry never keeps the host process alive past - // its natural exit point; long-running apps still retry because the - // event loop is held open by their own work. - setTimeout(attempt, getRetryDelay(options, attemptIndex), attemptIndex + 1).unref?.() + const retryTimer = setTimeout(attempt, getRetryDelay(options, attemptIndex), attemptIndex + 1) + if (!options.keepRetryTimerReferenced) retryTimer.unref?.() } else { complete(error) } diff --git a/packages/dd-trace/src/telemetry/metrics.js b/packages/dd-trace/src/telemetry/metrics.js index ca33df6569..fd09ac7ae6 100644 --- a/packages/dd-trace/src/telemetry/metrics.js +++ b/packages/dd-trace/src/telemetry/metrics.js @@ -332,7 +332,7 @@ class NamespaceManager extends Map { return } pendingRequests++ - sendData(config, application, host, requestType, payload, requestDone) + sendData(config, application, host, requestType, payload, requestDone, config.isCiVisibility === true) } for (const namespace of this.values()) { diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 32981bde2e..29447c0f69 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -133,8 +133,17 @@ function getPayload (payload) { * @param {TelemetryRequestType} reqType * @param {TelemetryPayload} [payload] * @param {SendDataCallback} [cb] + * @param {boolean} [keepRetryTimerReferenced] */ -function sendData (config, application, host, reqType, payload = {}, cb = () => {}) { +function sendData ( + config, + application, + host, + reqType, + payload = {}, + cb = () => {}, + keepRetryTimerReferenced = false +) { const { hostname, port, @@ -168,6 +177,7 @@ function sendData (config, application, host, reqType, payload = {}, cb = () => path: isCiVisibilityAgentlessMode ? '/api/v2/apmtelemetry' : '/telemetry/proxy/api/v2/apmtelemetry', headers: getHeaders(config, application, reqType), } + if (keepRetryTimerReferenced) options.keepRetryTimerReferenced = true if (isCiVisibility) options.agent = getTestOptimizationAgent(url) const data = JSON.stringify({ diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index 0c6d5d3b90..11d92ef88a 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -430,6 +430,45 @@ describe('request', function () { }) }) + it('only keeps retry timers referenced when requested', () => { + const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }) + const createRequest = () => { + const requestMessage = new EventEmitter() + requestMessage.abort = sinon.spy() + requestMessage.setTimeout = sinon.spy() + requestMessage.write = sinon.spy() + requestMessage.end = () => requestMessage.emit('error', error) + return requestMessage + } + const retryingRequest = proxyquire('../../../src/exporters/common/request', { + '../../../../datadog-core': { + storage: () => ({ run: runInNoopContext }), + }, + http: { ...http, request: createRequest }, + './docker': docker, + '../../log': log, + './retry': { + ...require('../../../src/exporters/common/retry'), + ...retryStubs, + }, + }) + const defaultTimer = { unref: sinon.spy() } + const referencedTimer = { unref: sinon.spy() } + const setTimeoutStub = sinon.stub(global, 'setTimeout') + setTimeoutStub.onFirstCall().returns(defaultTimer) + setTimeoutStub.onSecondCall().returns(referencedTimer) + + try { + retryingRequest('', { method: 'GET' }, () => {}) + retryingRequest('', { method: 'GET', keepRetryTimerReferenced: true }, () => {}) + } finally { + setTimeoutStub.restore() + } + + sinon.assert.calledOnce(defaultTimer.unref) + sinon.assert.notCalled(referencedTimer.unref) + }) + it('should not retry on a non-retriable error code', (done) => { const error = Object.assign(new Error('not found'), { code: 'ENOTFOUND' }) diff --git a/packages/dd-trace/test/telemetry/metrics.spec.js b/packages/dd-trace/test/telemetry/metrics.spec.js index c50981c420..a713e78f48 100644 --- a/packages/dd-trace/test/telemetry/metrics.spec.js +++ b/packages/dd-trace/test/telemetry/metrics.spec.js @@ -234,6 +234,7 @@ describe('metrics', () => { const config = { hostname: 'localhost', + isCiVisibility: true, port: 12345, tags: { 'runtime-id': 'abc123', @@ -249,6 +250,8 @@ describe('metrics', () => { manager.send(config, application, host, onDone) sinon.assert.notCalled(onDone) + assert.strictEqual(sendData.firstCall.args[6], true) + assert.strictEqual(sendData.secondCall.args[6], true) sendData.firstCall.args[5]() sinon.assert.notCalled(onDone) sendData.secondCall.args[5]() @@ -276,6 +279,7 @@ describe('metrics', () => { manager.send({}, {}, {}, onDone) sinon.assert.calledTwice(sendData) + assert.strictEqual(sendData.firstCall.args[6], false) sinon.assert.calledOnce(onDone) }) diff --git a/packages/dd-trace/test/telemetry/send-data.spec.js b/packages/dd-trace/test/telemetry/send-data.spec.js index 5c1d7cb8d3..caa1334613 100644 --- a/packages/dd-trace/test/telemetry/send-data.spec.js +++ b/packages/dd-trace/test/telemetry/send-data.spec.js @@ -53,6 +53,26 @@ describe('sendData', () => { port: '12345', }) assert.strictEqual(options.agent, undefined) + assert.strictEqual(options.keepRetryTimerReferenced, undefined) + }) + + it('keeps retry timers referenced when requested', () => { + sendDataModule.sendData( + { + hostname: '', + port: '12345', + tags: { 'runtime-id': '123' }, + }, + application, + host, + 'req-type', + {}, + () => {}, + true + ) + + const options = request.firstCall.args[1] + assert.strictEqual(options.keepRetryTimerReferenced, true) }) it('sends telemetry to the configured socket url', () => { From 6a24f767efbb13139a587e65e3fd3cb15aff2381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 23:49:31 +0200 Subject: [PATCH 23/25] fix(test-optimization): order final telemetry delivery Flush worker-exported telemetry before app-closing telemetry snapshots metrics, while leaving retry timers unreferenced so failed endpoints cannot hold the process open. Reuse the merged screenshot retry cap from #10056 because this stacked branch does not yet contain it. --- packages/datadog-plugin-jest/src/index.js | 14 +++---- .../datadog-plugin-playwright/src/index.js | 8 +--- .../src/ci-visibility/exporters/request.js | 6 +-- .../requests/upload-test-screenshot.js | 1 + .../dd-trace/src/exporters/common/request.js | 6 ++- packages/dd-trace/src/telemetry/metrics.js | 2 +- packages/dd-trace/src/telemetry/send-data.js | 12 +----- .../ci-visibility/exporters/request.spec.js | 14 +++++++ .../requests/upload-test-screenshot.spec.js | 5 ++- .../test/exporters/common/request.spec.js | 39 ------------------- .../dd-trace/test/telemetry/metrics.spec.js | 4 -- .../dd-trace/test/telemetry/send-data.spec.js | 20 ---------- 12 files changed, 35 insertions(+), 96 deletions(-) diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index 89257c44d2..d9ccc43a7a 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -172,13 +172,13 @@ class JestPlugin extends CiPlugin { autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - let pendingFlushes = 2 - const onFlush = () => { - pendingFlushes-- - if (pendingFlushes === 0) onDone?.() - } - appClosingTelemetry(onFlush) - this.tracer._exporter.flush(onFlush) + this.tracer._exporter.flush(() => { + appClosingTelemetry(() => { + if (onDone) { + onDone() + } + }) + }) } if (this.pendingTestSuiteFinishes.size > 0) { diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 3973fd0a24..915dedb272 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -182,13 +182,7 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - let pendingFlushes = 2 - const onFlush = () => { - pendingFlushes-- - if (pendingFlushes === 0) onDone() - } - appClosingTelemetry(onFlush) - this.tracer._exporter.flush(onFlush) + this.tracer._exporter.flush(() => appClosingTelemetry(onDone)) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined diff --git a/packages/dd-trace/src/ci-visibility/exporters/request.js b/packages/dd-trace/src/ci-visibility/exporters/request.js index 7346df58b2..bdebe504fa 100644 --- a/packages/dd-trace/src/ci-visibility/exporters/request.js +++ b/packages/dd-trace/src/ci-visibility/exporters/request.js @@ -287,13 +287,13 @@ function requestBuffered (data, options, callback, reservedPayloadSize) { const isRetriableError = attemptTimedOut || isRetriableNetworkError(error) || isUnknownNetworkError || isRetriableHttpStatusCode(responseStatus) - const reachedBackgroundAttemptLimit = - options.deadline === undefined && attemptIndex >= getMaxAttempts(attemptOptions) + const retryUntilDeadline = options.deadline !== undefined && options.retryUntilDeadline !== false + const reachedAttemptLimit = !retryUntilDeadline && attemptIndex >= getMaxAttempts(attemptOptions) const reachedUnknownNetworkAttemptLimit = isUnknownNetworkError && attemptIndex >= 2 if ( options.retry === false || !isRetriableError || - reachedBackgroundAttemptLimit || + reachedAttemptLimit || reachedUnknownNetworkAttemptLimit ) { complete(requestError, result, statusCode, headers) diff --git a/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js b/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js index 55c230624e..1c9c066269 100644 --- a/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +++ b/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js @@ -130,6 +130,7 @@ function uploadTestScreenshot ( timeout: UPLOAD_TIMEOUT_MS, url, deadline, + retryUntilDeadline: false, signal, } diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index b0c3d9cb45..77c93deed4 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -212,8 +212,10 @@ function request (data, options, callback) { isRetriableNetworkError(error)) { settled = true finalize() - const retryTimer = setTimeout(attempt, getRetryDelay(options, attemptIndex), attemptIndex + 1) - if (!options.keepRetryTimerReferenced) retryTimer.unref?.() + // Unref so a pending retry never keeps the host process alive past + // its natural exit point; long-running apps still retry because the + // event loop is held open by their own work. + setTimeout(attempt, getRetryDelay(options, attemptIndex), attemptIndex + 1).unref?.() } else { complete(error) } diff --git a/packages/dd-trace/src/telemetry/metrics.js b/packages/dd-trace/src/telemetry/metrics.js index fd09ac7ae6..ca33df6569 100644 --- a/packages/dd-trace/src/telemetry/metrics.js +++ b/packages/dd-trace/src/telemetry/metrics.js @@ -332,7 +332,7 @@ class NamespaceManager extends Map { return } pendingRequests++ - sendData(config, application, host, requestType, payload, requestDone, config.isCiVisibility === true) + sendData(config, application, host, requestType, payload, requestDone) } for (const namespace of this.values()) { diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 29447c0f69..32981bde2e 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -133,17 +133,8 @@ function getPayload (payload) { * @param {TelemetryRequestType} reqType * @param {TelemetryPayload} [payload] * @param {SendDataCallback} [cb] - * @param {boolean} [keepRetryTimerReferenced] */ -function sendData ( - config, - application, - host, - reqType, - payload = {}, - cb = () => {}, - keepRetryTimerReferenced = false -) { +function sendData (config, application, host, reqType, payload = {}, cb = () => {}) { const { hostname, port, @@ -177,7 +168,6 @@ function sendData ( path: isCiVisibilityAgentlessMode ? '/api/v2/apmtelemetry' : '/telemetry/proxy/api/v2/apmtelemetry', headers: getHeaders(config, application, reqType), } - if (keepRetryTimerReferenced) options.keepRetryTimerReferenced = true if (isCiVisibility) options.agent = getTestOptimizationAgent(url) const data = JSON.stringify({ diff --git a/packages/dd-trace/test/ci-visibility/exporters/request.spec.js b/packages/dd-trace/test/ci-visibility/exporters/request.spec.js index 781ae50ded..1211ac357b 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/request.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/request.spec.js @@ -70,6 +70,20 @@ describe('Test Optimization exporter request', () => { sinon.assert.calledOnceWithExactly(done, null, 'ok', 200, {}) }) + it('keeps the ordinary attempt cap when deadline retries are disabled', () => { + const done = sinon.spy() + request('payload', { deadline: Date.now() + 30_000, retryUntilDeadline: false }, done) + const error = Object.assign(new Error('unavailable'), { status: 503 }) + + pendingRequests[0].callback(error, null, 503, {}) + clock.tick(6000) + pendingRequests[1].callback(error, null, 503, {}) + clock.tick(6000) + + assert.strictEqual(pendingRequests.length, 2) + sinon.assert.calledOnceWithExactly(done, error, null, 503, {}) + }) + it('allows one more attempt when an overlapping final flush extends the deadline', () => { const done = sinon.spy() const options = { deadline: Date.now() + 1000, timeout: 2000 } diff --git a/packages/dd-trace/test/ci-visibility/requests/upload-test-screenshot.spec.js b/packages/dd-trace/test/ci-visibility/requests/upload-test-screenshot.spec.js index 46b7906c47..9594080cc9 100644 --- a/packages/dd-trace/test/ci-visibility/requests/upload-test-screenshot.spec.js +++ b/packages/dd-trace/test/ci-visibility/requests/upload-test-screenshot.spec.js @@ -38,9 +38,9 @@ describe('ci-visibility/requests/upload-test-screenshot', () => { ) assert.ok(requestStub.calledOnce) - const { path, headers, deadline, signal } = requestStub.getCall(0).args[1] + const { path, headers, deadline, retryUntilDeadline, signal } = requestStub.getCall(0).args[1] const query = new URL(path, 'http://localhost:8126').searchParams - return { path, headers, query, deadline, signal } + return { path, headers, query, deadline, retryUntilDeadline, signal } } before(() => { @@ -87,6 +87,7 @@ describe('ci-visibility/requests/upload-test-screenshot', () => { const requestOptions = uploadForFile('screenshot.png', { deadline, signal: abortController.signal }) assert.strictEqual(requestOptions.deadline, deadline) + assert.strictEqual(requestOptions.retryUntilDeadline, false) assert.strictEqual(requestOptions.signal, abortController.signal) }) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index 11d92ef88a..0c6d5d3b90 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -430,45 +430,6 @@ describe('request', function () { }) }) - it('only keeps retry timers referenced when requested', () => { - const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }) - const createRequest = () => { - const requestMessage = new EventEmitter() - requestMessage.abort = sinon.spy() - requestMessage.setTimeout = sinon.spy() - requestMessage.write = sinon.spy() - requestMessage.end = () => requestMessage.emit('error', error) - return requestMessage - } - const retryingRequest = proxyquire('../../../src/exporters/common/request', { - '../../../../datadog-core': { - storage: () => ({ run: runInNoopContext }), - }, - http: { ...http, request: createRequest }, - './docker': docker, - '../../log': log, - './retry': { - ...require('../../../src/exporters/common/retry'), - ...retryStubs, - }, - }) - const defaultTimer = { unref: sinon.spy() } - const referencedTimer = { unref: sinon.spy() } - const setTimeoutStub = sinon.stub(global, 'setTimeout') - setTimeoutStub.onFirstCall().returns(defaultTimer) - setTimeoutStub.onSecondCall().returns(referencedTimer) - - try { - retryingRequest('', { method: 'GET' }, () => {}) - retryingRequest('', { method: 'GET', keepRetryTimerReferenced: true }, () => {}) - } finally { - setTimeoutStub.restore() - } - - sinon.assert.calledOnce(defaultTimer.unref) - sinon.assert.notCalled(referencedTimer.unref) - }) - it('should not retry on a non-retriable error code', (done) => { const error = Object.assign(new Error('not found'), { code: 'ENOTFOUND' }) diff --git a/packages/dd-trace/test/telemetry/metrics.spec.js b/packages/dd-trace/test/telemetry/metrics.spec.js index a713e78f48..c50981c420 100644 --- a/packages/dd-trace/test/telemetry/metrics.spec.js +++ b/packages/dd-trace/test/telemetry/metrics.spec.js @@ -234,7 +234,6 @@ describe('metrics', () => { const config = { hostname: 'localhost', - isCiVisibility: true, port: 12345, tags: { 'runtime-id': 'abc123', @@ -250,8 +249,6 @@ describe('metrics', () => { manager.send(config, application, host, onDone) sinon.assert.notCalled(onDone) - assert.strictEqual(sendData.firstCall.args[6], true) - assert.strictEqual(sendData.secondCall.args[6], true) sendData.firstCall.args[5]() sinon.assert.notCalled(onDone) sendData.secondCall.args[5]() @@ -279,7 +276,6 @@ describe('metrics', () => { manager.send({}, {}, {}, onDone) sinon.assert.calledTwice(sendData) - assert.strictEqual(sendData.firstCall.args[6], false) sinon.assert.calledOnce(onDone) }) diff --git a/packages/dd-trace/test/telemetry/send-data.spec.js b/packages/dd-trace/test/telemetry/send-data.spec.js index caa1334613..5c1d7cb8d3 100644 --- a/packages/dd-trace/test/telemetry/send-data.spec.js +++ b/packages/dd-trace/test/telemetry/send-data.spec.js @@ -53,26 +53,6 @@ describe('sendData', () => { port: '12345', }) assert.strictEqual(options.agent, undefined) - assert.strictEqual(options.keepRetryTimerReferenced, undefined) - }) - - it('keeps retry timers referenced when requested', () => { - sendDataModule.sendData( - { - hostname: '', - port: '12345', - tags: { 'runtime-id': '123' }, - }, - application, - host, - 'req-type', - {}, - () => {}, - true - ) - - const options = request.firstCall.args[1] - assert.strictEqual(options.keepRetryTimerReferenced, true) }) it('sends telemetry to the configured socket url', () => { From 0b70479cd464a58c917cc785999a11fc0e45831f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Mon, 31 Aug 2026 23:59:15 +0200 Subject: [PATCH 24/25] fix(test-optimization): await in-flight telemetry metrics Track metric requests across flush calls so app-closing finalization joins a heartbeat send that already snapshotted final metrics. Keep framework exporter and telemetry flushes parallel to minimize the shutdown window. --- packages/datadog-plugin-jest/src/index.js | 14 +++++------ .../datadog-plugin-playwright/src/index.js | 8 +++++- packages/dd-trace/src/telemetry/metrics.js | 25 ++++++++++--------- .../dd-trace/test/telemetry/metrics.spec.js | 13 ++++++++++ 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index d9ccc43a7a..89257c44d2 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -172,13 +172,13 @@ class JestPlugin extends CiPlugin { autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - this.tracer._exporter.flush(() => { - appClosingTelemetry(() => { - if (onDone) { - onDone() - } - }) - }) + let pendingFlushes = 2 + const onFlush = () => { + pendingFlushes-- + if (pendingFlushes === 0) onDone?.() + } + appClosingTelemetry(onFlush) + this.tracer._exporter.flush(onFlush) } if (this.pendingTestSuiteFinishes.size > 0) { diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 915dedb272..3973fd0a24 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -182,7 +182,13 @@ class PlaywrightPlugin extends CiPlugin { provider: this.ciProviderName, autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) - this.tracer._exporter.flush(() => appClosingTelemetry(onDone)) + let pendingFlushes = 2 + const onFlush = () => { + pendingFlushes-- + if (pendingFlushes === 0) onDone() + } + appClosingTelemetry(onFlush) + this.tracer._exporter.flush(onFlush) this.numFailedTests = 0 this.numFailedSuites = 0 this.finishSession = undefined diff --git a/packages/dd-trace/src/telemetry/metrics.js b/packages/dd-trace/src/telemetry/metrics.js index ca33df6569..ce90909b3f 100644 --- a/packages/dd-trace/src/telemetry/metrics.js +++ b/packages/dd-trace/src/telemetry/metrics.js @@ -295,6 +295,9 @@ class Namespace { } class NamespaceManager extends Map { + #pendingRequests = 0 + #pendingCallbacks = [] + namespace (name) { let ns = this.get(name) if (ns) return ns @@ -318,20 +321,17 @@ class NamespaceManager extends Map { * @returns {void} */ send (config, application, host, onDone) { - let pendingRequests = 0 - let dispatching = true - const requestDone = () => { - pendingRequests-- - if (!dispatching && pendingRequests === 0) onDone() + this.#pendingRequests-- + if (this.#pendingRequests !== 0) return + + const callbacks = this.#pendingCallbacks + this.#pendingCallbacks = [] + for (const callback of callbacks) callback() } const send = (requestType, payload) => { - if (!onDone) { - sendData(config, application, host, requestType, payload) - return - } - pendingRequests++ + this.#pendingRequests++ sendData(config, application, host, requestType, payload, requestDone) } @@ -352,8 +352,9 @@ class NamespaceManager extends Map { namespace.reset() } - dispatching = false - if (onDone && pendingRequests === 0) onDone() + if (!onDone) return + if (this.#pendingRequests === 0) onDone() + else this.#pendingCallbacks.push(onDone) } } diff --git a/packages/dd-trace/test/telemetry/metrics.spec.js b/packages/dd-trace/test/telemetry/metrics.spec.js index c50981c420..96139ab809 100644 --- a/packages/dd-trace/test/telemetry/metrics.spec.js +++ b/packages/dd-trace/test/telemetry/metrics.spec.js @@ -255,6 +255,19 @@ describe('metrics', () => { sinon.assert.calledOnce(onDone) }) + it('should call onDone after requests from an earlier send settle', () => { + const manager = new metrics.NamespaceManager() + const onDone = sinon.spy() + + manager.namespace('test').count('metric').inc() + manager.send({}, {}, {}) + manager.send({}, {}, {}, onDone) + + sinon.assert.notCalled(onDone) + sendData.firstCall.args[5]() + sinon.assert.calledOnce(onDone) + }) + it('should call onDone when there are no requests', () => { const manager = new metrics.NamespaceManager() const onDone = sinon.spy() From c0d96959da1de881a42879836b70b994840e7f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Antonio=20Fern=C3=A1ndez=20de=20Alba?= Date: Tue, 1 Sep 2026 00:12:13 +0200 Subject: [PATCH 25/25] fix(test-optimization): retain final telemetry retry Keep only final Test Optimization metric retries referenced so a transport timeout during shutdown can retry before process exit. Heartbeat and non-Test Optimization retries remain unreferenced. --- .../dd-trace/src/exporters/common/request.js | 6 +-- packages/dd-trace/src/telemetry/metrics.js | 5 ++- packages/dd-trace/src/telemetry/send-data.js | 12 +++++- packages/dd-trace/src/telemetry/telemetry.js | 2 +- .../test/exporters/common/request.spec.js | 39 +++++++++++++++++++ .../dd-trace/test/telemetry/metrics.spec.js | 12 ++++++ .../dd-trace/test/telemetry/send-data.spec.js | 20 ++++++++++ 7 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/dd-trace/src/exporters/common/request.js b/packages/dd-trace/src/exporters/common/request.js index 77c93deed4..b0c3d9cb45 100644 --- a/packages/dd-trace/src/exporters/common/request.js +++ b/packages/dd-trace/src/exporters/common/request.js @@ -212,10 +212,8 @@ function request (data, options, callback) { isRetriableNetworkError(error)) { settled = true finalize() - // Unref so a pending retry never keeps the host process alive past - // its natural exit point; long-running apps still retry because the - // event loop is held open by their own work. - setTimeout(attempt, getRetryDelay(options, attemptIndex), attemptIndex + 1).unref?.() + const retryTimer = setTimeout(attempt, getRetryDelay(options, attemptIndex), attemptIndex + 1) + if (!options.keepRetryTimerReferenced) retryTimer.unref?.() } else { complete(error) } diff --git a/packages/dd-trace/src/telemetry/metrics.js b/packages/dd-trace/src/telemetry/metrics.js index ce90909b3f..f0b54a6c63 100644 --- a/packages/dd-trace/src/telemetry/metrics.js +++ b/packages/dd-trace/src/telemetry/metrics.js @@ -318,9 +318,10 @@ class NamespaceManager extends Map { * @param {import('./send-data').TelemetryApplication} application * @param {import('./send-data').TelemetryHost} host * @param {() => void} [onDone] + * @param {boolean} [keepRetryTimerReferenced] * @returns {void} */ - send (config, application, host, onDone) { + send (config, application, host, onDone, keepRetryTimerReferenced = false) { const requestDone = () => { this.#pendingRequests-- if (this.#pendingRequests !== 0) return @@ -332,7 +333,7 @@ class NamespaceManager extends Map { const send = (requestType, payload) => { this.#pendingRequests++ - sendData(config, application, host, requestType, payload, requestDone) + sendData(config, application, host, requestType, payload, requestDone, keepRetryTimerReferenced) } for (const namespace of this.values()) { diff --git a/packages/dd-trace/src/telemetry/send-data.js b/packages/dd-trace/src/telemetry/send-data.js index 32981bde2e..29447c0f69 100644 --- a/packages/dd-trace/src/telemetry/send-data.js +++ b/packages/dd-trace/src/telemetry/send-data.js @@ -133,8 +133,17 @@ function getPayload (payload) { * @param {TelemetryRequestType} reqType * @param {TelemetryPayload} [payload] * @param {SendDataCallback} [cb] + * @param {boolean} [keepRetryTimerReferenced] */ -function sendData (config, application, host, reqType, payload = {}, cb = () => {}) { +function sendData ( + config, + application, + host, + reqType, + payload = {}, + cb = () => {}, + keepRetryTimerReferenced = false +) { const { hostname, port, @@ -168,6 +177,7 @@ function sendData (config, application, host, reqType, payload = {}, cb = () => path: isCiVisibilityAgentlessMode ? '/api/v2/apmtelemetry' : '/telemetry/proxy/api/v2/apmtelemetry', headers: getHeaders(config, application, reqType), } + if (keepRetryTimerReferenced) options.keepRetryTimerReferenced = true if (isCiVisibility) options.agent = getTestOptimizationAgent(url) const data = JSON.stringify({ diff --git a/packages/dd-trace/src/telemetry/telemetry.js b/packages/dd-trace/src/telemetry/telemetry.js index 0f9b9aa7a5..8df14169a7 100644 --- a/packages/dd-trace/src/telemetry/telemetry.js +++ b/packages/dd-trace/src/telemetry/telemetry.js @@ -208,7 +208,7 @@ function appClosing (onMetricsSent) { const { reqType, payload } = createPayload('app-closing') sendData(config, application, host, reqType, payload) // We flush before shutting down. - metricsManager.send(config, application, host, onMetricsSent) + metricsManager.send(config, application, host, onMetricsSent, config.isCiVisibility === true) telemetryLogger.send(config, application, host) } diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index 0c6d5d3b90..11d92ef88a 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -430,6 +430,45 @@ describe('request', function () { }) }) + it('only keeps retry timers referenced when requested', () => { + const error = Object.assign(new Error('ECONNRESET'), { code: 'ECONNRESET' }) + const createRequest = () => { + const requestMessage = new EventEmitter() + requestMessage.abort = sinon.spy() + requestMessage.setTimeout = sinon.spy() + requestMessage.write = sinon.spy() + requestMessage.end = () => requestMessage.emit('error', error) + return requestMessage + } + const retryingRequest = proxyquire('../../../src/exporters/common/request', { + '../../../../datadog-core': { + storage: () => ({ run: runInNoopContext }), + }, + http: { ...http, request: createRequest }, + './docker': docker, + '../../log': log, + './retry': { + ...require('../../../src/exporters/common/retry'), + ...retryStubs, + }, + }) + const defaultTimer = { unref: sinon.spy() } + const referencedTimer = { unref: sinon.spy() } + const setTimeoutStub = sinon.stub(global, 'setTimeout') + setTimeoutStub.onFirstCall().returns(defaultTimer) + setTimeoutStub.onSecondCall().returns(referencedTimer) + + try { + retryingRequest('', { method: 'GET' }, () => {}) + retryingRequest('', { method: 'GET', keepRetryTimerReferenced: true }, () => {}) + } finally { + setTimeoutStub.restore() + } + + sinon.assert.calledOnce(defaultTimer.unref) + sinon.assert.notCalled(referencedTimer.unref) + }) + it('should not retry on a non-retriable error code', (done) => { const error = Object.assign(new Error('not found'), { code: 'ENOTFOUND' }) diff --git a/packages/dd-trace/test/telemetry/metrics.spec.js b/packages/dd-trace/test/telemetry/metrics.spec.js index 96139ab809..e10081b587 100644 --- a/packages/dd-trace/test/telemetry/metrics.spec.js +++ b/packages/dd-trace/test/telemetry/metrics.spec.js @@ -268,6 +268,18 @@ describe('metrics', () => { sinon.assert.calledOnce(onDone) }) + it('should only keep retry timers referenced when requested', () => { + const manager = new metrics.NamespaceManager() + const onDone = sinon.spy() + + manager.namespace('test').count('metric').inc() + manager.send({}, {}, {}, onDone, true) + + assert.strictEqual(sendData.firstCall.args[6], true) + sendData.firstCall.args[5]() + sinon.assert.calledOnce(onDone) + }) + it('should call onDone when there are no requests', () => { const manager = new metrics.NamespaceManager() const onDone = sinon.spy() diff --git a/packages/dd-trace/test/telemetry/send-data.spec.js b/packages/dd-trace/test/telemetry/send-data.spec.js index 5c1d7cb8d3..caa1334613 100644 --- a/packages/dd-trace/test/telemetry/send-data.spec.js +++ b/packages/dd-trace/test/telemetry/send-data.spec.js @@ -53,6 +53,26 @@ describe('sendData', () => { port: '12345', }) assert.strictEqual(options.agent, undefined) + assert.strictEqual(options.keepRetryTimerReferenced, undefined) + }) + + it('keeps retry timers referenced when requested', () => { + sendDataModule.sendData( + { + hostname: '', + port: '12345', + tags: { 'runtime-id': '123' }, + }, + application, + host, + 'req-type', + {}, + () => {}, + true + ) + + const options = request.firstCall.args[1] + assert.strictEqual(options.keepRetryTimerReferenced, true) }) it('sends telemetry to the configured socket url', () => {