UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

658 lines (580 loc) 23.3 kB
'use strict' const fs = require('fs') const path = require('path') const { runCommand } = require('../command-runner') const { getDatadogCleanCommand, getLocalValidationCommand } = require('../local-command') const { basicEventEvidence, error, failWithDebugRerun, findInterestingLines, frameworkOutDir, hasAllBasicEventTypes, inconclusive, pass, runInstrumentedCommand, tailInterestingLines, } = require('./helpers') async function runBasicReporting ({ framework, out, options }) { const scenarioName = 'basic-reporting' try { const command = getBasicReportingCommand(framework) const { result, events, offline, outDir } = await runInstrumentedCommand({ framework, out, scenarioName, command, options, allowMissingInitialization: true, }) const evidence = { commandExitCode: result.exitCode, commandTimedOut: result.timedOut, commandDescription: command?.description, commandOutputSummary: summarizeTestOutput(result.stdout, result.stderr), manifestNotes: Array.isArray(framework.notes) ? framework.notes : [], preflight: summarizePreflight(framework.preflight), offlineExporterInitialized: offline.initialized, settingsLoadedFromCache: offline.inputs.settings?.status === 'loaded', offlineExporterSummary: offline.summary, ...basicEventEvidence(events), } if (evidence.offlineExporterInitialized && !evidence.settingsLoadedFromCache) { return error( framework, scenarioName, new Error('Basic Reporting settings were not loaded from the authoritative offline cache fixture.'), outDir ) } if (!hasAllBasicEventTypes(events)) { if (result.exitCode !== 0) { evidence.commandFailure = summarizeCommandFailure(result, evidence) } const eventLevelFailure = getMissingEventDiagnosis({ framework, result, evidence }) evidence.eventLevelFailure = eventLevelFailure return failBasicReportingWithDebugRerun({ command, diagnosis: eventLevelFailure.summary, evidence, framework, options, out, outDir, scenarioName, skipDebug: evidence.commandFailure?.buildErrors?.length > 0 || !shouldRunDebugRerun(eventLevelFailure, result), }) } if (result.exitCode === 0) { return pass( framework, scenarioName, 'Basic reporting emitted session, module, suite, and test events.', evidence, outDir ) } if (matchesPreflightExitCode(framework.preflight, result.exitCode)) { evidence.commandFailure = summarizeCommandFailure(result, evidence) evidence.commandExitMatchesPreflight = true return pass( framework, scenarioName, 'Basic reporting emitted session, module, suite, and test events. ' + `The command exited ${result.exitCode}, matching the dd-trace-less preflight run.`, evidence, outDir ) } evidence.commandFailure = summarizeCommandFailure(result, evidence) evidence.commandExitMatchesPreflight = false const cleanConfirmation = await runCleanConfirmation({ command, framework, options, out, scenarioName }) evidence.cleanConfirmation = cleanConfirmation.evidence if (!cleanConfirmation.evidence.exitMatchesPreflight) { return inconclusive( framework, scenarioName, getUnstableBaselineDiagnosis(framework.preflight, result, cleanConfirmation.result), evidence, outDir, cleanConfirmation.artifacts ) } const failure = await failBasicReportingWithDebugRerun({ command, diagnosis: getPossibleCompatibilityDiagnosis(framework.preflight, result), evidence, framework, options, out, outDir, scenarioName, }) failure.artifacts.push(...cleanConfirmation.artifacts) return failure } catch (err) { return error(framework, scenarioName, err) } } /** * Repeats the selected command without Datadog after an instrumented exit-code mismatch. * * @param {object} input confirmation inputs * @param {object} input.command selected project command * @param {object} input.framework manifest framework entry * @param {object} input.options validator options * @param {string} input.out validation output directory * @param {string} input.scenarioName scenario identifier * @returns {Promise<{artifacts: string[], evidence: object, result: object}>} clean confirmation outcome */ async function runCleanConfirmation ({ command, framework, options, out, scenarioName }) { const outDir = frameworkOutDir(out, framework, `${scenarioName}-clean-confirmation`) const result = await runCommand(getDatadogCleanCommand(command), { artifactRoot: out, envMode: 'clean', label: `${framework.id}:${scenarioName}:clean-confirmation`, outDir, repositoryRoot: options.repositoryRoot, requireExecutableApproval: options.requireExecutableApproval, verbose: options.verbose, }) const exitMatchesPreflight = !result.timedOut && matchesPreflightExitCode(framework.preflight, result.exitCode) return { artifacts: Object.values(result.artifacts), result, evidence: { ran: true, exitCode: result.exitCode, timedOut: result.timedOut, exitMatchesPreflight, commandOutputSummary: summarizeTestOutput(result.stdout, result.stderr), }, } } /** * Describes an exit mismatch that cannot be attributed because clean executions disagreed. * * @param {object} preflight initial clean preflight evidence * @param {object} instrumented instrumented command result * @param {object} cleanConfirmation repeated clean command result * @returns {string} customer-facing diagnosis */ function getUnstableBaselineDiagnosis (preflight, instrumented, cleanConfirmation) { const confirmation = cleanConfirmation.timedOut ? 'timed out' : `exited ${cleanConfirmation.exitCode}` return `The selected command exited ${preflight.exitCode} during the initial clean preflight, exited ` + `${instrumented.exitCode} with Datadog initialized, and ${confirmation} during a second clean run. The ` + 'non-Datadog baseline was not stable, so no Test Optimization compatibility conclusion was reached.' } /** * Describes an instrumented-only exit mismatch after the clean baseline was reproduced. * * @param {object} preflight initial clean preflight evidence * @param {object} instrumented instrumented command result * @returns {string} customer-facing diagnosis */ function getPossibleCompatibilityDiagnosis (preflight, instrumented) { return `The selected command exited ${preflight.exitCode} in both runs without Datadog, but exited ` + `${instrumented.exitCode} with Datadog initialized after reporting Test Optimization events. This may indicate ` + 'a dd-trace compatibility issue rather than a project baseline failure.' } function getBasicReportingCommand (framework) { return getLocalValidationCommand(framework, framework.existingTestCommand) } async function failBasicReportingWithDebugRerun (options) { const failure = await failWithDebugRerun(options) return refineBasicReportingFailure(failure) } function refineBasicReportingFailure (failure) { const evidence = failure.evidence || {} if (['dd-trace-preload-failed', 'command-setup-failed'].includes(evidence.eventLevelFailure?.kind)) { failure.status = 'error' evidence.localDiagnosis = evidence.eventLevelFailure return failure } const diagnosis = getDebugAwareDiagnosis(failure.diagnosis, evidence) if (!diagnosis) return failure failure.diagnosis = diagnosis.summary evidence.localDiagnosis = diagnosis if (evidence.eventLevelFailure) { evidence.eventLevelFailure = { ...evidence.eventLevelFailure, summary: diagnosis.summary, recommendation: diagnosis.recommendation, signals: diagnosis.signals, } } return failure } function getDebugAwareDiagnosis (currentDiagnosis, evidence) { if (evidence.eventLevelFailure?.kind !== 'no-test-optimization-events') return null const debugRerun = evidence.debugRerun if (!debugRerun || debugRerun.ran !== true) return null const testOutputSummary = summarizeTestOutput( (evidence.commandOutputSummary || []).join('\n'), (debugRerun.stdoutExcerpt || []).join('\n') ) const testsRan = commandOutputShowsTestsRan(testOutputSummary) const debugLine = findDebugLine(debugRerun, /dd-trace is not initialized in a package manager/i) const noDebugEvents = !hasAnyTestOptimizationEvent(debugRerun) if (testsRan && debugLine && noDebugEvents) { return { kind: 'tests-ran-tracer-not-initialized', summary: 'The selected command ran tests, but no Test Optimization events reached the offline event artifact. ' + `The debug rerun printed "${debugLine}", which means the preload executed in the package-manager ` + 'wrapper without producing Test Optimization events from the test process.', recommendation: 'Try a direct test-runner command, or verify NODE_OPTIONS with dd-trace/ci/init reaches the ' + 'final test process rather than only the package-manager wrapper.', signals: getDebugSignals({ debugLine, debugRerun, testOutputSummary, }), } } if (testsRan && noDebugEvents) { return { kind: 'tests-ran-no-test-optimization-events', summary: 'The selected command ran tests, but no Test Optimization events reached the offline event artifact. ' + 'The debug rerun did not emit Test Optimization events either.', recommendation: 'Inspect the debug rerun excerpt for tracer initialization or offline exporter errors, then ' + 'verify NODE_OPTIONS with dd-trace/ci/init reaches the final test process.', signals: getDebugSignals({ debugRerun, testOutputSummary, }), } } if (debugLine && noDebugEvents) { return { kind: 'tracer-not-initialized', summary: `${currentDiagnosis} The debug rerun printed "${debugLine}".`, recommendation: 'Verify NODE_OPTIONS with dd-trace/ci/init reaches the final test process.', signals: getDebugSignals({ debugLine, debugRerun, testOutputSummary, }), } } } function shouldRunDebugRerun (eventLevelFailure, result) { return result.timedOut !== true && eventLevelFailure.kind !== 'vitest-benchmark' && eventLevelFailure.kind !== 'custom-jest-runner' } function matchesPreflightExitCode (preflight, exitCode) { return preflight?.ran === true && Number.isInteger(preflight.exitCode) && preflight.exitCode === exitCode } function summarizePreflight (preflight) { if (!preflight || preflight.ran !== true) { return { ran: false, reason: 'No dd-trace-less preflight result was recorded in the manifest.', } } return { ran: true, exitCode: preflight.exitCode, observedTestCount: preflight.observedTestCount, stdoutSummary: preflight.stdoutSummary, stderrSummary: preflight.stderrSummary, } } function summarizeTestOutput (stdout = '', stderr = '') { return findInterestingLines(`${stdout}\n${stderr}`, [ /\b\d+\s+passing\b/i, /\b\d+\s+pending\b/i, /\b\d+\s+failing\b/i, /\b\d+\s+passed\b/i, /\b\d+\s+failed\b/i, /\btests?\b.*\bpassed\b/i, /\btests?\b.*\bfailed\b/i, /\bSuccessfully ran target\b.*\btest\b/i, /\bsuccess:\s*\d+\b/i, /\bTasks:\s*\d+\s+successful\b/i, ], 8) } function commandOutputShowsTestsRan (lines) { return lines.some(line => { return /\b\d+\s+(?:passing|passed)\b/i.test(line) || /\btests?\b.*\bpassed\b/i.test(line) || /\bSuccessfully ran target\b.*\btest\b/i.test(line) || /\bsuccess:\s*[1-9]\d*\b/i.test(line) || /\bfailed:\s*[1-9]\d*\b/i.test(line) || /\bTasks:\s*[1-9]\d*\s+successful\b/i.test(line) }) } function findDebugLine (debugRerun, pattern) { const lines = [ ...(debugRerun.debugLines || []), ...(debugRerun.stdoutExcerpt || []), ...(debugRerun.stderrExcerpt || []), ] return lines.find(line => pattern.test(line)) } function getDebugSignals ({ debugLine, debugRerun, testOutputSummary }) { return { debugLine, debugLines: debugRerun.debugLines || [], stdoutExcerpt: debugRerun.stdoutExcerpt || [], stderrExcerpt: debugRerun.stderrExcerpt || [], testOutputSummary, } } function summarizeCommandFailure (result, evidence) { const output = `${result.stdout}\n${result.stderr}` const buildErrors = findInterestingLines(output, [ /Could not resolve /, /Cannot find module/, /Module not found/, /Error \[ERR_MODULE_NOT_FOUND\]/, ]) const assertionFailures = findInterestingLines(output, [ /AssertionError/, /Timed out retrying/, /^\s+\d+\) /, ]) const testEventsWereReported = evidence.testSessionEvents > 0 && evidence.testModuleEvents > 0 && evidence.testSuiteEvents > 0 && evidence.testEvents > 0 const summary = getFailureSummary({ buildErrors, assertionFailures, exitCode: result.exitCode, testEventsWereReported, timedOut: result.timedOut, }) return { assertionFailures, buildErrors, stderrExcerpt: tailInterestingLines(result.stderr), stdoutExcerpt: tailInterestingLines(result.stdout), summary, testEventsWereReported, } } function getMissingEventDiagnosis ({ framework, result, evidence }) { const missingLevels = getMissingLevels(evidence) const commandFailure = evidence.commandFailure const vitestBenchmark = detectVitestBenchmark(framework, result) const frameworkSourceTreeRunner = detectFrameworkSourceTreeRunner(framework, result) const customJestRunner = detectCustomJestRunner(framework) if (commandFailure?.buildErrors?.length > 0 && !hasAnyTestOptimizationEvent(evidence)) { const preloadFailed = /(?:ci\/init\.js|internal\/preload)/.test(`${result.stdout}\n${result.stderr}`) return { kind: preloadFailed ? 'dd-trace-preload-failed' : 'command-setup-failed', missingLevels, signals: commandFailure.buildErrors, summary: preloadFailed ? `The Test Optimization preload failed before tests started: ${commandFailure.buildErrors[0]}. ` + 'No Test Optimization conclusion was reached.' : `${commandFailure.summary} No Test Optimization conclusion was reached.`, recommendation: preloadFailed ? 'Repair or reinstall dd-trace so its declared dependencies resolve, then rerun validation.' : 'Fix the selected project command or its setup, then rerun validation.', } } if (vitestBenchmark) { return { kind: 'vitest-benchmark', missingLevels, signals: vitestBenchmark.signals, summary: 'The selected Vitest command appears to run benchmark mode, not normal tests. ' + 'Test Optimization reported session/module/suite events, but Vitest benchmark mode did not emit ' + 'per-test events. Choose a normal Vitest test command such as "vitest run <test-file>" for validation.', recommendation: 'Replace the selected command with a normal Vitest test command; do not use `vitest bench` ' + 'or benchmark-only `*.bench.*` files for Test Optimization validation.', } } if (frameworkSourceTreeRunner && !hasAnyTestOptimizationEvent(evidence)) { return { kind: 'framework-source-tree-runner', missingLevels, signals: frameworkSourceTreeRunner.signals, summary: 'The selected command ran tests from the test framework source tree, but no Test Optimization ' + 'events reached the offline event artifact. This command is not equivalent to a customer project running an ' + 'installed test-runner package.', recommendation: 'Choose a project test command that uses an installed supported framework package. If this ' + 'repository is the framework itself, treat this entry as not runnable for Test Optimization validation.', } } if (!hasAnyTestOptimizationEvent(evidence)) { return { kind: 'no-test-optimization-events', missingLevels, summary: 'No Test Optimization test events reached the offline event artifact. The tracer may not have ' + 'initialized in the test process, or the selected command may not have executed tests.', recommendation: 'Check the debug rerun output for tracer initialization or offline exporter errors.', } } if (evidence.testEvents === 0) { if (customJestRunner) { return { kind: 'custom-jest-runner', missingLevels, customTestRunner: customJestRunner, signals: customJestRunner.signals, summary: `We detected a custom Jest-compatible runner: \`${customJestRunner.name}\`. The test command ` + 'executes tests, but it does not use a currently supported Jest entrypoint. Test Optimization ' + 'initialized, but this runner did not emit the Jest lifecycle events needed to report individual ' + 'suites and tests.', recommendation: 'Try a standard Jest runner command for validation, or choose a test command that does not ' + 'use the custom runner. If this project must use the custom runner, dd-trace may need explicit support ' + 'for that runner before per-test reporting and advanced Test Optimization features can work.', } } return { kind: 'missing-test-events', missingLevels, summary: 'Test Optimization initialized and emitted higher-level events, but per-test events were missing. ' + 'This usually points to an unsupported runner mode, unsupported framework configuration, or per-test hooks ' + 'not firing for the selected command.', recommendation: 'Choose a smaller standard test command, then inspect the debug rerun output for hook or ' + 'exporter errors.', } } return { kind: 'missing-event-levels', missingLevels, summary: `The command ran, but these required Test Optimization event levels were missing: ${ missingLevels.join(', ') }.`, recommendation: 'Inspect the debug rerun output for tracer initialization, hook, or exporter errors.', } } function getMissingLevels (evidence) { const missing = [] if (evidence.testSessionEvents === 0) missing.push('test_session_end') if (evidence.testModuleEvents === 0) missing.push('test_module_end') if (evidence.testSuiteEvents === 0) missing.push('test_suite_end') if (evidence.testEvents === 0) missing.push('test') return missing } function hasAnyTestOptimizationEvent (evidence) { return evidence.testSessionEvents > 0 || evidence.testModuleEvents > 0 || evidence.testSuiteEvents > 0 || evidence.testEvents > 0 } function detectVitestBenchmark (framework, result) { if (framework.framework !== 'vitest') return null const command = result.command || '' const output = `${result.stdout}\n${result.stderr}` const signals = [] if (/\bvitest\s+bench\b/.test(command)) signals.push('command contains `vitest bench`') if (/\.bench\.[cm]?[jt]sx?\b/.test(command)) signals.push('command targets a `*.bench.*` file') if (/^\s*BENCH\s+Summary\b/m.test(output)) signals.push('stdout contains a Vitest BENCH summary') if (/Benchmarking is an experimental feature/.test(output)) { signals.push('stderr says Vitest benchmarking is experimental') } return signals.length > 0 ? { signals } : null } function detectCustomJestRunner (framework) { if (framework.framework !== 'jest') return null const configRunner = findJestRunnerInConfigFiles(framework.project?.configFiles || []) if (configRunner && isCustomJestRunner(configRunner.name)) return configRunner const packageRunner = findJestRunnerInPackageJson(framework.project?.packageJson) if (packageRunner && isCustomJestRunner(packageRunner.name)) return packageRunner } function findJestRunnerInConfigFiles (configFiles) { for (const configFile of configFiles) { const content = readFile(configFile) if (!content) continue const match = /(?:^|[,{]\s*)runner\s*:\s*['"]([^'"]+)['"]/.exec(content) if (!match) continue return { name: match[1], source: configFile, sourceType: 'config', signals: [ `Jest config ${configFile} sets runner: ${match[1]}`, ], } } } function findJestRunnerInPackageJson (packageJsonPath) { if (!packageJsonPath) return try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) const runner = packageJson.jest?.runner if (typeof runner !== 'string' || runner.length === 0) return return { name: runner, source: packageJsonPath, sourceType: 'package.json', signals: [ `package.json jest.runner is ${runner}`, ], } } catch {} } function isCustomJestRunner (runner) { return runner !== 'jest-runner' } function detectFrameworkSourceTreeRunner (framework, result) { if (framework.framework !== 'mocha') return null const projectName = framework.project?.name || '' const commandAndOutput = [ result.command || '', result.stdout || '', result.stderr || '', ].join('\n') const signals = [] if (projectName === 'mocha') { signals.push('repository package name is `mocha`') } if (/\bnode\s+\.\/bin\/mocha\.js\b/.test(commandAndOutput) || /\bnode\s+bin\/mocha\.js\b/.test(commandAndOutput)) { signals.push('selected command invokes the repository-local `bin/mocha.js` source-tree runner') } if (fileExists(framework.project?.root, 'lib/mocha.cjs') && fileExists(framework.project?.root, 'lib/runner.cjs')) { signals.push('repository contains Mocha source files `lib/mocha.cjs` and `lib/runner.cjs`') } return signals.length >= 2 ? { signals } : null } function fileExists (root, filename) { if (!root) return false try { return fs.existsSync(path.join(root, filename)) } catch { return false } } function readFile (filename) { try { return fs.readFileSync(filename, 'utf8') } catch {} } function getFailureSummary ({ buildErrors, assertionFailures, exitCode, testEventsWereReported, timedOut }) { if (timedOut) { return 'The selected test command timed out before payload validation could pass.' } if (buildErrors.length > 0) { if (testEventsWereReported) { return 'The selected test command reported Datadog test events, but project setup/build errors made it fail.' } return 'The selected test command failed during project setup/build before payload validation could pass.' } if (assertionFailures.length > 0 && testEventsWereReported) { return 'The selected test command reported Datadog test events, but the tests failed.' } if (testEventsWereReported) { return `The selected test command reported Datadog test events, but exited ${exitCode}.` } return `The selected test command exited ${exitCode} before payload validation could pass.` } module.exports = { getDebugAwareDiagnosis, getBasicReportingCommand, getMissingEventDiagnosis, refineBasicReportingFailure, runBasicReporting, shouldRunDebugRerun, summarizeTestOutput, }