UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

582 lines (523 loc) 19.8 kB
'use strict' const { hostname: getHostname } = require('node:os') const URL = require('url').URL const { version: tracerVersion } = require('../../../../../package.json') const { getLibraryConfiguration: getLibraryConfigurationRequest } = require('../requests/get-library-configuration') const { getSkippableSuites: getSkippableSuitesRequest } = require('../intelligent-test-runner/get-skippable-suites') const { getKnownTests: getKnownTestsRequest } = require('../early-flake-detection/get-known-tests') const { getTestManagementTests: getTestManagementTestsRequest } = require('../test-management/get-test-management-tests') const { writeSettingsToCache } = require('../test-optimization-cache') const { CACHE_MISS, TestOptimizationHttpCache } = require('../test-optimization-http-cache') const { uploadCoverageReport: uploadCoverageReportRequest } = require('../requests/upload-coverage-report') const { uploadTestScreenshot: uploadTestScreenshotRequest } = require('../requests/upload-test-screenshot') const { parsers } = require('../../config/parsers') const log = require('../../log') const BufferingExporter = require('../../exporters/common/buffering-exporter') const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA } = require('../../plugins/util/tags') const { sendGitMetadata: sendGitMetadataRequest } = require('./git/git_metadata') const hostname = getHostname() function getTestConfigurationTags (tags) { if (!tags) { return {} } return Object.keys(tags).reduce((acc, key) => { if (key.startsWith('test.configuration.')) { const [, configKey] = key.split('test.configuration.') acc[configKey] = tags[key] } return acc }, {}) } function getIsTestSessionTrace (trace) { return trace.some(span => span.type === 'test_session_end' || span.type === 'test_suite_end' || span.type === 'test_module_end' ) } const GIT_UPLOAD_TIMEOUT = 60_000 // 60 seconds const CAN_USE_CI_VIS_PROTOCOL_TIMEOUT = GIT_UPLOAD_TIMEOUT const MAX_COVERAGE_REPORT_FLAGS = 32 function appendLogTag (tags, key, value) { if (value !== undefined) { tags.push(`${key}:${value}`) } } function getLogTags (logMessage, { env, version }, gitRepositoryUrl, gitCommitSha) { const tags = [] if (Array.isArray(logMessage.ddtags)) { for (const tag of logMessage.ddtags) { tags.push(tag) } } else if (logMessage.ddtags) { for (const tag of logMessage.ddtags.split(',')) { tags.push(tag) } } appendLogTag(tags, 'env', env) appendLogTag(tags, 'version', version) appendLogTag(tags, 'debugger_version', tracerVersion) appendLogTag(tags, 'host_name', hostname) appendLogTag(tags, GIT_COMMIT_SHA, gitCommitSha) appendLogTag(tags, GIT_REPOSITORY_URL, gitRepositoryUrl) return tags.join(',') } class CiVisibilityExporter extends BufferingExporter { constructor (config, options = {}) { super(config) this._timer = undefined this._coverageTimer = undefined this._logsTimer = undefined this._coverageBuffer = [] this._testOptimizationHttpCache = options.testOptimizationHttpCache || new TestOptimizationHttpCache() this._isTestOptimizationCacheOnly = options.cacheOnly === true const coverageReportFlags = parsers.ARRAY(config?.testOptimization?.DD_CODE_COVERAGE_FLAGS) if (coverageReportFlags?.length > MAX_COVERAGE_REPORT_FLAGS) { log.warn( 'Maximum of %d coverage report flags allowed, but %d flags were provided. Omitting coverage report flags.', MAX_COVERAGE_REPORT_FLAGS, coverageReportFlags.length ) } else if (coverageReportFlags?.length) { this._coverageReportFlags = [...coverageReportFlags] } // The library can use new features like ITR and test suite level visibility // AKA CI Vis Protocol this._canUseCiVisProtocol = false this._isTestFailureScreenshotsEnabled = Boolean(config?.testOptimization?.DD_TEST_FAILURE_SCREENSHOTS_ENABLED) const gitUploadTimeoutId = setTimeout(() => { this._resolveGit(new Error('Timeout while uploading git metadata')) }, GIT_UPLOAD_TIMEOUT) gitUploadTimeoutId.unref?.() const canUseCiVisProtocolTimeoutId = setTimeout(() => { this._resolveCanUseCiVisProtocol(false) }, CAN_USE_CI_VIS_PROTOCOL_TIMEOUT) canUseCiVisProtocolTimeoutId.unref?.() this._gitUploadPromise = new Promise(resolve => { this._resolveGit = (err) => { clearTimeout(gitUploadTimeoutId) resolve(err) } }) this._canUseCiVisProtocolPromise = new Promise(resolve => { this._resolveCanUseCiVisProtocol = (canUseCiVisProtocol) => { clearTimeout(canUseCiVisProtocolTimeoutId) this._canUseCiVisProtocol = canUseCiVisProtocol resolve(canUseCiVisProtocol) } }) const flush = () => { if (this._writer) { this._writer.flush() } if (this._coverageWriter) { this._coverageWriter.flush() } if (this._logsWriter) { this._logsWriter.flush() } } globalThis[Symbol.for('dd-trace')].beforeExitHandlers.add(flush.bind(this)) } shouldRequestSkippableSuites () { return !!(this._config.testOptimization.DD_CIVISIBILITY_ITR_ENABLED && this._canUseCiVisProtocol && this._libraryConfig?.isSuitesSkippingEnabled) } shouldRequestKnownTests () { return !!( this._canUseCiVisProtocol && this._libraryConfig?.isKnownTestsEnabled ) } shouldRequestTestManagementTests () { return !!( this._canUseCiVisProtocol && this._config.testOptimization.DD_TEST_MANAGEMENT_ENABLED && this._libraryConfig?.isTestManagementEnabled ) } canReportSessionTraces () { return this._canUseCiVisProtocol } canReportCodeCoverage () { return this._canUseCiVisProtocol } getRequestConfiguration (testConfiguration) { return { url: this._getApiUrl(), env: this._config.env, service: this._config.service, isEvpProxy: !!this._isUsingEvpProxy, isGzipCompatible: this._isGzipCompatible, evpProxyPrefix: this.evpProxyPrefix, custom: getTestConfigurationTags(this._config.tags), ...testConfiguration, } } // We can't call the skippable endpoint until git upload has finished, // hence the this._gitUploadPromise.then getSkippableSuites (testConfiguration, callback) { if (!this.shouldRequestSkippableSuites()) { return callback(null, []) } const requestConfiguration = this.getRequestConfiguration(testConfiguration) const cachedSkippableSuites = this._testOptimizationHttpCache.readSkippableSuites({ testLevel: requestConfiguration.testLevel, isCoverageReportUploadEnabled: requestConfiguration.isCoverageReportUploadEnabled, }) if (cachedSkippableSuites !== CACHE_MISS) { const { skippableSuites, correlationId, coverage } = cachedSkippableSuites return callback(null, skippableSuites, correlationId, coverage) } if (this._isTestOptimizationCacheOnly) { return callback(this._getCacheOnlyError('skippable tests'), []) } this._gitUploadPromise.then(gitUploadError => { if (gitUploadError) { return callback(gitUploadError, []) } getSkippableSuitesRequest(requestConfiguration, callback) }) } getKnownTests (testConfiguration, callback) { if (!this.shouldRequestKnownTests()) { return callback(null) } const cachedKnownTests = this._testOptimizationHttpCache.readKnownTests() if (cachedKnownTests !== CACHE_MISS) { return callback(null, cachedKnownTests) } if (this._isTestOptimizationCacheOnly) { return callback(this._getCacheOnlyError('known tests')) } getKnownTestsRequest(this.getRequestConfiguration(testConfiguration), callback) } getTestManagementTests (testConfiguration, callback) { if (!this.shouldRequestTestManagementTests()) { return callback(null) } const cachedTestManagementTests = this._testOptimizationHttpCache.readTestManagementTests() if (cachedTestManagementTests !== CACHE_MISS) { return callback(null, cachedTestManagementTests) } if (this._isTestOptimizationCacheOnly) { return callback(this._getCacheOnlyError('test management tests')) } getTestManagementTestsRequest(this.getRequestConfiguration(testConfiguration), callback) } /** * We can't request library configuration until we know whether we can use the * CI Visibility Protocol, hence the this._canUseCiVisProtocol promise. */ getLibraryConfiguration (testConfiguration, callback) { const { repositoryUrl } = testConfiguration this._canUseCiVisProtocolPromise.then((canUseCiVisProtocol) => { if (!canUseCiVisProtocol) { return callback(null, {}) } const configuration = this.getRequestConfiguration(testConfiguration) const cachedLibraryConfig = this._testOptimizationHttpCache.readSettings() if (cachedLibraryConfig !== CACHE_MISS) { log.debug('Test Optimization HTTP cache settings found, skipping settings request') writeSettingsToCache(cachedLibraryConfig) this._libraryConfig = this.filterConfiguration(cachedLibraryConfig) const canUseCachedSkippableSuites = !this.shouldRequestSkippableSuites() || this._testOptimizationHttpCache.hasValidSkippableSuites({ testLevel: configuration.testLevel, isCoverageReportUploadEnabled: configuration.isCoverageReportUploadEnabled, }) if (this._libraryConfig.requireGit && !canUseCachedSkippableSuites) { this.sendGitMetadata(repositoryUrl) } else { this._resolveGit() } return callback(null, this._libraryConfig) } if (this._isTestOptimizationCacheOnly) { return callback(this._getCacheOnlyError('settings'), {}) } this.sendGitMetadata(repositoryUrl) getLibraryConfigurationRequest(configuration, (err, libraryConfig) => { /** * **Important**: this._libraryConfig remains empty in testing frameworks * where the tests run in a subprocess, like Jest, * because `getLibraryConfiguration` is called only once in the main process. */ this._libraryConfig = this.filterConfiguration(libraryConfig) if (err) { callback(err, {}) } else if (libraryConfig?.requireGit) { // If the backend requires git, we'll wait for the upload to finish and request settings again this._gitUploadPromise.then(gitUploadError => { if (gitUploadError) { return callback(gitUploadError, {}) } getLibraryConfigurationRequest(configuration, (err, finalLibraryConfig) => { this._libraryConfig = this.filterConfiguration(finalLibraryConfig) callback(err, this._libraryConfig) }) }) } else { callback(null, this._libraryConfig) } }) }) } /** * Returns the deterministic cache error for offline exporters. * * @param {string} input required cache input * @returns {Error} cache error */ _getCacheOnlyError (input) { return this._testOptimizationHttpCache.getLastError?.() || new Error(`Offline Test Optimization validation requires a valid ${input} cache fixture.`) } // Takes into account potential kill switches filterConfiguration (remoteConfiguration) { if (!remoteConfiguration) { return {} } const { isCodeCoverageEnabled, isSuitesSkippingEnabled, isItrEnabled, requireGit, isEarlyFlakeDetectionEnabled, earlyFlakeDetectionNumRetries, earlyFlakeDetectionSlowTestRetries, earlyFlakeDetectionFaultyThreshold, isFlakyTestRetriesEnabled, isDiEnabled, isKnownTestsEnabled, isTestManagementEnabled, testManagementAttemptToFixRetries, isImpactedTestsEnabled, isCoverageReportUploadEnabled, } = remoteConfiguration const { testOptimization } = this._config const earlyFlakeDetectionRetryCount = testOptimization.DD_TEST_EARLY_FLAKE_DETECTION_RETRY_COUNT const hasEarlyFlakeDetectionRetryCount = earlyFlakeDetectionRetryCount !== undefined return { isCodeCoverageEnabled, isSuitesSkippingEnabled, isItrEnabled, requireGit, isEarlyFlakeDetectionEnabled: isEarlyFlakeDetectionEnabled && testOptimization.DD_CIVISIBILITY_EARLY_FLAKE_DETECTION_ENABLED, earlyFlakeDetectionNumRetries: hasEarlyFlakeDetectionRetryCount ? earlyFlakeDetectionRetryCount : earlyFlakeDetectionNumRetries, earlyFlakeDetectionSlowTestRetries: hasEarlyFlakeDetectionRetryCount ? { '5s': earlyFlakeDetectionRetryCount, '10s': earlyFlakeDetectionRetryCount, '30s': earlyFlakeDetectionRetryCount, '5m': earlyFlakeDetectionRetryCount, } : earlyFlakeDetectionSlowTestRetries, earlyFlakeDetectionFaultyThreshold, isFlakyTestRetriesEnabled: isFlakyTestRetriesEnabled && testOptimization.DD_CIVISIBILITY_FLAKY_RETRY_ENABLED, flakyTestRetriesCount: testOptimization.DD_CIVISIBILITY_FLAKY_RETRY_COUNT, isDiEnabled: isDiEnabled && testOptimization.DD_TEST_FAILED_TEST_REPLAY_ENABLED, isKnownTestsEnabled, isTestManagementEnabled: isTestManagementEnabled && testOptimization.DD_TEST_MANAGEMENT_ENABLED, testManagementAttemptToFixRetries: testManagementAttemptToFixRetries ?? testOptimization.DD_TEST_MANAGEMENT_ATTEMPT_TO_FIX_RETRIES, isImpactedTestsEnabled: isImpactedTestsEnabled && testOptimization.DD_CIVISIBILITY_IMPACTED_TESTS_DETECTION_ENABLED, isCoverageReportUploadEnabled, } } sendGitMetadata (repositoryUrl) { if (!this._config.testOptimization.DD_CIVISIBILITY_GIT_UPLOAD_ENABLED) { this._resolveGit() return } this._canUseCiVisProtocolPromise.then((canUseCiVisProtocol) => { if (!canUseCiVisProtocol) { return } sendGitMetadataRequest( this._getApiUrl(), { isEvpProxy: !!this._isUsingEvpProxy, evpProxyPrefix: this.evpProxyPrefix }, repositoryUrl, (err) => { if (err) { log.error('Error uploading git metadata: %s', err.message) } else { log.debug('Successfully uploaded git metadata') } this._resolveGit(err) } ) }) } export (trace) { // Until it's initialized, we just store the traces as is if (!this._isInitialized) { this._traceBuffer.push(trace) return } if (!this.canReportSessionTraces() && getIsTestSessionTrace(trace)) { return } this._export(trace) } exportCoverage (formattedCoverage) { // Until it's initialized, we just store the coverages as is if (!this._isInitialized) { this._coverageBuffer.push(formattedCoverage) return } if (!this.canReportCodeCoverage()) { return } this._export(formattedCoverage, this._coverageWriter, '_coverageTimer') } formatLogMessage (testEnvironmentMetadata, logMessage) { const { [GIT_REPOSITORY_URL]: gitRepositoryUrl, [GIT_COMMIT_SHA]: gitCommitSha, } = testEnvironmentMetadata const { service, env, version } = this._config return { ...logMessage, ddtags: getLogTags(logMessage, { env, version }, gitRepositoryUrl, gitCommitSha), level: 'error', service, hostname, dd: { ...logMessage.dd, service, env, version, }, ddsource: 'dd_debugger', } } // DI logs exportDiLogs (testEnvironmentMetadata, logMessage) { // TODO: could we lose logs if it's not initialized? if (!this._config.testOptimization.DD_TEST_FAILED_TEST_REPLAY_ENABLED || !this._isInitialized || !this._canForwardLogs) { return } this._export( this.formatLogMessage(testEnvironmentMetadata, logMessage), this._logsWriter, '_logsTimer' ) } flush (done = () => {}) { if (!this._isInitialized) { return done() } // TODO: safe to do them at once? Or do we want to do them one by one? const writers = [ this._writer, this._coverageWriter, this._logsWriter, ].filter(Boolean) let remaining = writers.length if (remaining === 0) { return done() } const onFlushComplete = () => { remaining -= 1 if (remaining === 0) { done() } } for (const writer of writers) writer.flush(onFlushComplete) } exportUncodedCoverages () { for (const oldCoveragePayload of this._coverageBuffer) { this.exportCoverage(oldCoveragePayload) } this._coverageBuffer = [] } _setUrl (url, coverageUrl = url) { try { url = new URL(url) coverageUrl = new URL(coverageUrl) this._url = url this._coverageUrl = coverageUrl this._writer.setUrl(url) this._coverageWriter.setUrl(coverageUrl) } catch (e) { log.error('Error setting CI exporter url', e) } } _getApiUrl () { return this._url } // By the time addMetadataTags is called, the agent info request might not have finished addMetadataTags (tags) { if (this._writer?.addMetadataTags) { this._writer.addMetadataTags(tags) } else { this._canUseCiVisProtocolPromise.then(() => { if (this._writer?.addMetadataTags) { this._writer.addMetadataTags(tags) } }) } } /** * Uploads a single coverage report to the CI intake. * @param {object} options - Upload options * @param {string} options.filePath - Path to the coverage report file * @param {string} options.format - Format of the coverage report * @param {object} options.testEnvironmentMetadata - Test environment metadata containing git/CI tags * @param {(error: Error|null) => void} callback - Callback function */ uploadCoverageReport ({ filePath, format, testEnvironmentMetadata }, callback) { if (!this._codeCoverageReportUrl) { return callback(new Error('Coverage report upload URL not configured')) } uploadCoverageReportRequest({ filePath, format, flags: this._coverageReportFlags, testEnvironmentMetadata, url: this._codeCoverageReportUrl, isEvpProxy: !!this._isUsingEvpProxy, evpProxyPrefix: this.evpProxyPrefix, }, callback) } /** * Returns whether the exporter can upload test failure screenshots. * * @returns {boolean} */ canUploadTestScreenshots () { return Boolean(this._testScreenshotUploadUrl) && this._isTestFailureScreenshotsEnabled } /** * Uploads a single test screenshot to the Test Optimization media intake. * * @param {object} options - Upload options * @param {string} options.filePath - Path to the screenshot file * @param {string} options.traceId - Test trace id used as the screenshot key * @param {string} options.idempotencyKey - Stable per-artifact key, reused on retry * @param {number} options.capturedAtMs - Capture time in epoch milliseconds * @param {Function} callback - Callback function (err) */ uploadTestScreenshot ({ filePath, traceId, idempotencyKey, capturedAtMs }, callback) { if (!this._testScreenshotUploadUrl) { return callback(new Error('Test screenshot upload URL not configured')) } uploadTestScreenshotRequest({ filePath, traceId, idempotencyKey, capturedAtMs, url: this._testScreenshotUploadUrl, isEvpProxy: !!this._isUsingEvpProxy, evpProxyPrefix: this.evpProxyPrefix, }, callback) } } module.exports = CiVisibilityExporter