From b930b7b91ce7c60ef84797979661725da5bc15e3 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 04:15:43 +0000 Subject: [PATCH 1/5] Emit shadow Rush lifecycle events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + ...er-r3b-shadow-events_2026-08-28-04-20.json | 11 + common/reviews/api/rush-lib.api.md | 2 + common/reviews/api/rush-reporter.api.md | 6 + .../diagnostics/RushDiagnosticCodeRegistry.ts | 39 +-- .../src/diagnostics/templates/operation.ts | 3 +- .../rush-lib/src/cli/RushCommandLineParser.ts | 110 +++++- .../cli/scriptActions/PhasedScriptAction.ts | 2 + .../cli/test/RushCommandLineParser.test.ts | 33 +- ...RushCommandLineParserReporterClose.test.ts | 25 ++ libraries/rush-lib/src/cli/test/TestUtils.ts | 6 +- libraries/rush-lib/src/logic/Telemetry.ts | 33 ++ .../operations/ReporterOperationEventSink.ts | 314 ++++++++++++++++++ .../test/OperationGraphEventSink.test.ts | 235 ++++++++++++- .../rush-lib/src/logic/test/Telemetry.test.ts | 44 ++- .../src/pluginFramework/RushSession.test.ts | 95 +++++- .../src/pluginFramework/RushSession.ts | 250 +++++++++++++- 17 files changed, 1179 insertions(+), 40 deletions(-) create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json create mode 100644 libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts diff --git a/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..71b7d371662 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Emit shadow Rush lifecycle, phase-aware operation, diagnostic, telemetry, and command-result events without changing legacy terminal output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json new file mode 100644 index 00000000000..5f23b51eea9 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r3b-shadow-events_2026-08-28-04-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add a stable structured diagnostic code for Rush command failures.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 13d17d81750..bf214916f0b 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -29,6 +29,7 @@ import { IRushDiagnostic } from '@rushstack/rush-reporter'; import { IScopedLogger } from '@rushstack/rush-reporter'; import { IScopedMessageOptions } from '@rushstack/rush-reporter'; import { IScopedReporter } from '@rushstack/rush-reporter'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import { ITerminal } from '@rushstack/terminal'; import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; @@ -1044,6 +1045,7 @@ export interface ITelemetryData { readonly operationResults?: Record; readonly performanceEntries?: readonly PerformanceEntry_2[]; readonly platform?: string; + readonly reporterData?: ITelemetryAggregate; readonly result: 'Succeeded' | 'Failed'; readonly rushVersion?: string; readonly timestampMs?: number; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2dae..ecf09047dbd 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1506,6 +1506,12 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ readonly defaultSeverity: "error"; readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; readonly detailKey: undefined; +}, { + readonly code: "RUSH_COMMAND_FAILED"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_COMMAND_FAILED.summary"; + readonly detailKey: undefined; }]; // @beta diff --git a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts index 11f5c1d933a..0d697f893e6 100644 --- a/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts +++ b/libraries/reporter/src/diagnostics/RushDiagnosticCodeRegistry.ts @@ -111,16 +111,13 @@ type AreValidRushDiagnosticCodeSegments< ? IsValidRushDiagnosticCodeSegment : false; -type ValidateRushDiagnosticCode = - TCode extends `RUSH_${infer Segments}` - ? AreValidRushDiagnosticCodeSegments extends true - ? TCode - : never - : never; +type ValidateRushDiagnosticCode = TCode extends `RUSH_${infer Segments}` + ? AreValidRushDiagnosticCodeSegments extends true + ? TCode + : never + : never; -type ValidatedRushDiagnosticCodeDefinitions< - TDefinitions extends readonly IRushDiagnosticCodeDefinition[] -> = { +type ValidatedRushDiagnosticCodeDefinitions = { readonly [K in keyof TDefinitions]: TDefinitions[K] extends IRushDiagnosticCodeDefinition ? TDefinitions[K] & { readonly code: ValidateRushDiagnosticCode; @@ -130,9 +127,7 @@ type ValidatedRushDiagnosticCodeDefinitions< function defineRushDiagnosticCodeDefinitions< const TDefinitions extends readonly IRushDiagnosticCodeDefinition[] ->( - definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions -): TDefinitions { +>(definitions: TDefinitions & ValidatedRushDiagnosticCodeDefinitions): TDefinitions { return definitions; } @@ -233,6 +228,13 @@ export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS = defineRushDiagnosticCodeDefiniti defaultSeverity: 'error', summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', detailKey: undefined + }, + { + code: 'RUSH_COMMAND_FAILED', + category: 'operation', + defaultSeverity: 'error', + summaryKey: 'diagnostic.RUSH_COMMAND_FAILED.summary', + detailKey: undefined } ]); @@ -257,12 +259,11 @@ export type RushDiagnosticTemplateKey = NonNullable< * * @beta */ -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = - new Map( - RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( - (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const - ) - ); +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap = new Map( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition) => [definition.code, definition] as const + ) +); export { isValidRushDiagnosticCode } from './RushDiagnosticCode'; -export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; \ No newline at end of file +export { RUSH_DIAGNOSTIC_TEMPLATES } from './templates'; diff --git a/libraries/reporter/src/diagnostics/templates/operation.ts b/libraries/reporter/src/diagnostics/templates/operation.ts index 32107668384..456adc6c8eb 100644 --- a/libraries/reporter/src/diagnostics/templates/operation.ts +++ b/libraries/reporter/src/diagnostics/templates/operation.ts @@ -11,5 +11,6 @@ // eslint-disable-next-line @typescript-eslint/typedef -- literal keys are required for the Record aggregate check export const OPERATION_DIAGNOSTIC_TEMPLATES = { 'diagnostic.RUSH_OPERATION_FAILED.summary': 'The operation for {projectName} failed.', - 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}' + 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary': '{tool} reported {code}: {message}', + 'diagnostic.RUSH_COMMAND_FAILED.summary': 'The Rush command {commandName} failed.' } as const; diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index ba043788d88..7a149ca5744 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -16,6 +16,7 @@ import { Colorize, type ITerminal } from '@rushstack/terminal'; +import { createRushDiagnostic, type IRushDiagnostic, type LifecycleEmitter } from '@rushstack/rush-reporter'; import { RushConfiguration } from '../api/RushConfiguration'; import { RushConstants } from '../logic/RushConstants'; @@ -64,6 +65,13 @@ import { RushAlerts } from '../utilities/RushAlerts'; import { initializeDotEnv } from '../logic/dotenv'; import { measureAsyncFn } from '../utilities/performance'; import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; +import { + _correlateRushSessionError, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionReporterSourceVersion, + _isRushSessionErrorRepresented +} from '../pluginFramework/RushSession'; /** * Options for `RushCommandLineParser`. @@ -91,6 +99,11 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _terminal: Terminal; private readonly _autocreateBuildCommand: boolean; private _initializationFailed: boolean = false; + private _sessionLifecycleEmitter: LifecycleEmitter | undefined; + private _commandLifecycleEmitter: LifecycleEmitter | undefined; + private _sessionStartTimeMs: number | undefined; + private _commandStartTimeMs: number | undefined; + private _reporterCompletionEmitted: boolean = false; private _reporterClosePromise: Promise | undefined; /** @@ -264,12 +277,30 @@ export class RushCommandLineParser extends CommandLineParser { this._terminalProvider.verboseEnabled = this._terminalProvider.debugEnabled = rushArgv.includes('--debug') || rushArgv.includes('-d'); + this._sessionLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession); + if (this._sessionLifecycleEmitter) { + this._sessionStartTimeMs = performance.now(); + this._sessionLifecycleEmitter.emitSessionStarted({ + rushVersion: _getRushSessionReporterSourceVersion(this.rushSession)! + }); + } + try { await measureAsyncFn('rush:initializeUnassociatedPlugins', () => this.pluginManager.tryInitializeUnassociatedPluginsAsync() ); - return await super.executeAsync(args); + const succeeded: boolean = await super.executeAsync(args); + if (!this._reporterCompletionEmitted) { + this._emitReporterCompletion(succeeded ? 0 : _getNumericProcessExitCode(1)); + } + return succeeded; + } catch (error) { + if (!process.exitCode) { + process.exitCode = 1; + } + this._reportErrorAndSetExitCode(error as Error); + return false; } finally { await this._closeReporterAsync(); } @@ -287,6 +318,17 @@ export class RushCommandLineParser extends CommandLineParser { InternalError.breakInDebugger = true; } + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName) { + this._commandLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession, { + commandName + }); + if (this._commandLifecycleEmitter) { + this._commandStartTimeMs = performance.now(); + this._commandLifecycleEmitter.emitCommandStarted({ commandName }); + } + } + try { await this._wrapOnExecuteAsync(); @@ -327,6 +369,7 @@ export class RushCommandLineParser extends CommandLineParser { // If we make it here, everything went fine, so reset the exit code back to 0 process.exitCode = 0; + this._emitReporterCompletion(0); } catch (error) { this._reportErrorAndSetExitCode(error as Error); } @@ -544,6 +587,20 @@ export class RushCommandLineParser extends CommandLineParser { } private _reportErrorAndSetExitCode(error: Error): void { + const rushSession: RushSession | undefined = this.rushSession; + if (rushSession && !_isRushSessionErrorRepresented(rushSession, error)) { + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED', { + parameters: { + commandName: { + value: this.selectedAction?.actionName ?? 'unknown', + privacy: 'public' + } + } + }); + this._commandLifecycleEmitter?.emitDiagnostic(diagnostic); + _correlateRushSessionError(rushSession, error, diagnostic.diagnosticId); + } + if (!(error instanceof AlreadyReportedError)) { const prefix: string = 'ERROR: '; @@ -564,6 +621,7 @@ export class RushCommandLineParser extends CommandLineParser { console.error(`\n${error.stack}`); } + this._emitReporterCompletion(_getNumericProcessExitCode(1)); this.flushTelemetry(); const configuredExitCode: string | number | undefined = process.exitCode; @@ -620,4 +678,54 @@ export class RushCommandLineParser extends CommandLineParser { } return this._reporterClosePromise; } + + private _emitReporterCompletion(exitCode: number): void { + if (this._reporterCompletionEmitted) { + return; + } + this._reporterCompletionEmitted = true; + + const commandName: string | undefined = this.selectedAction?.actionName; + if (commandName && this._commandLifecycleEmitter) { + const durationMs: number | undefined = + this._commandStartTimeMs === undefined ? undefined : performance.now() - this._commandStartTimeMs; + this._commandLifecycleEmitter.emitCommandResult({ + commandName, + succeeded: exitCode === 0, + exitCode + }); + this._commandLifecycleEmitter.emitCommandCompleted({ + commandName, + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + if (this._sessionLifecycleEmitter) { + const durationMs: number | undefined = + this._sessionStartTimeMs === undefined ? undefined : performance.now() - this._sessionStartTimeMs; + this._sessionLifecycleEmitter.emitSessionCompleted({ + exitCode, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + + // Shadow derivation is deliberately observational. process.exitCode remains authoritative. + const rushSession: RushSession | undefined = this.rushSession; + if (rushSession) { + _getRushSessionDerivedExitStatus(rushSession); + } + } +} + +function _getNumericProcessExitCode(fallback: number): number { + const { exitCode } = process; + if (typeof exitCode === 'number') { + return exitCode; + } + if (typeof exitCode === 'string') { + const parsed: number = Number(exitCode); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; } diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 1b2b7aa5812..e310a7c32d1 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -62,6 +62,7 @@ import { IgnoredParametersPlugin } from '../../logic/operations/IgnoredParameter import { TrimRushEnvironmentVariablesPlugin } from '../../logic/operations/TrimRushEnvironmentVariablesPlugin'; import { DebugHashesPlugin } from '../../logic/operations/DebugHashesPlugin'; import { measureAsyncFn, measureFn } from '../../utilities/performance'; +import { attachReporterOperationEventSink } from '../../logic/operations/ReporterOperationEventSink'; const PERF_PREFIX: 'rush:phasedScriptAction' = 'rush:phasedScriptAction'; @@ -677,6 +678,7 @@ export class PhasedScriptAction extends BaseScriptAction i await measureAsyncFn(`${PERF_PREFIX}:executionManager`, async () => { await hooks.onGraphCreatedAsync.promise(graph, graphContext); }); + attachReporterOperationEventSink(graph, this.rushSession, this.actionName); const executeOptions: IExecuteOperationsOptions = { graph, diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 64d47c1cfdf..6f1184c7dde 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -31,6 +31,7 @@ import './mockRushCommandLineParser'; import type { SpawnOptions } from 'node:child_process'; import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { IDetailedRepoState } from '@rushstack/package-deps-hash'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { Autoinstaller } from '../../logic/Autoinstaller'; import type { ITelemetryData } from '../../logic/Telemetry'; import { @@ -47,6 +48,15 @@ import { IS_WINDOWS } from '../../utilities/executionUtilities'; // we only reference the one that is common. const SPAWN_ARG_OPTIONS: number = 2; +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function spawnOptionEquals( spawnCall: SpawnMockCall, optionName: TOption, @@ -93,7 +103,11 @@ describe('RushCommandLineParser', () => { describe("'build' action", () => { it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunBuildActionRepo'; - const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build'); + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const { parser, spawnMock, repoPath } = await getCommandLineParserInstanceAsync(repoName, 'build', { + eventSink: reporterSink, + sessionId: 'parser-shadow' + }); await expect(parser.executeAsync()).resolves.toEqual(true); @@ -111,6 +125,23 @@ describe('RushCommandLineParser', () => { const secondSpawn: SpawnMockArgs = spawnMock.mock.calls[1]; expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp); cwdOptionEquals(secondSpawn, `${repoPath}/b`); + + const eventTypes: string[] = reporterSink.inputs.map(({ type }) => type); + expect(eventTypes[0]).toBe('sessionStarted'); + expect(eventTypes[1]).toBe('commandStarted'); + expect(eventTypes).toContain('operationRegistered'); + expect(eventTypes).toContain('operationStatusChanged'); + expect(eventTypes.slice(-3)).toEqual(['commandResult', 'commandCompleted', 'sessionCompleted']); + expect(reporterSink.inputs.at(-3)?.payload).toMatchObject({ + commandName: 'build', + succeeded: true, + exitCode: 0 + }); + for (const event of reporterSink.inputs.filter(({ type }) => type === 'operationRegistered')) { + const scope = event.scope!; + expect(scope.operationId).toBe(`${scope.projectName}#${scope.phaseName}`); + } + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); }); }); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 601bb70185d..1b46d180f4c 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -147,4 +147,29 @@ describe('RushCommandLineParser reporter close', () => { expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith('[reporter] Unable to finalize reporters: close failed\n'); }); + + it('shares one reporter close operation across failure and finalization paths', async () => { + let resolveClose: (() => void) | undefined; + const closeAsync: jest.Mock, []> = jest.fn( + () => + new Promise((resolve: () => void) => { + resolveClose = resolve; + }) + ); + const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); + Object.defineProperty(parser, '_rushOptions', { value: { reporterCloseAsync: closeAsync } }); + + const closeReporterAsync: () => Promise = ( + parser as unknown as { + _closeReporterAsync(): Promise; + } + )._closeReporterAsync.bind(parser); + const firstClose: Promise = closeReporterAsync(); + const secondClose: Promise = closeReporterAsync(); + + expect(closeAsync).toHaveBeenCalledTimes(1); + resolveClose!(); + await expect(Promise.all([firstClose, secondClose])).resolves.toEqual([undefined, undefined]); + expect(closeAsync).toHaveBeenCalledTimes(1); + }); }); diff --git a/libraries/rush-lib/src/cli/test/TestUtils.ts b/libraries/rush-lib/src/cli/test/TestUtils.ts index c8191358c2c..29fa4482233 100644 --- a/libraries/rush-lib/src/cli/test/TestUtils.ts +++ b/libraries/rush-lib/src/cli/test/TestUtils.ts @@ -4,6 +4,7 @@ import { AlreadyExistsBehavior, FileSystem, PackageJsonLookup } from '@rushstack/node-core-library'; import type { RushCommandLineParser as RushCommandLineParserType } from '../RushCommandLineParser'; +import type { IRushSessionReporterOptions } from '../../pluginFramework/RushSession'; import { FlagFile } from '../../api/FlagFile'; import { RushConstants } from '../../logic/RushConstants'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; @@ -76,7 +77,8 @@ export const TEST_REPO_FOLDER_PATH: string = `${PROJECT_ROOT}/temp/test/unit-tes */ export async function getCommandLineParserInstanceAsync( repoName: string, - taskName: string + taskName: string, + reporter?: IRushSessionReporterOptions ): Promise { // Copy the test repo to a sandbox folder const repoPath: string = `${TEST_REPO_FOLDER_PATH}/${repoName}-${performance.now()}`; @@ -100,7 +102,7 @@ export async function getCommandLineParserInstanceAsync( // to exit and clear the Rush file lock. So running multiple `it` or `describe` test blocks over the same test // repo will fail due to contention over the same lock which is kept until the test runner process // ends. - const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath }); + const parser: RushCommandLineParserType = new RushCommandLineParser({ cwd: repoPath, reporter }); // Bulk tasks are hard-coded to expect install to have been completed. So, ensure the last-link.flag // file exists and is valid diff --git a/libraries/rush-lib/src/logic/Telemetry.ts b/libraries/rush-lib/src/logic/Telemetry.ts index 8d855cd46a0..e9d0f54ad95 100644 --- a/libraries/rush-lib/src/logic/Telemetry.ts +++ b/libraries/rush-lib/src/logic/Telemetry.ts @@ -6,10 +6,12 @@ import * as path from 'node:path'; import type { PerformanceEntry } from 'node:perf_hooks'; import { FileSystem, type FileSystemStats, JsonFile } from '@rushstack/node-core-library'; +import type { ITelemetryAggregate } from '@rushstack/rush-reporter'; import type { RushConfiguration } from '../api/RushConfiguration'; import { Rush } from '../api/Rush'; import type { RushSession } from '../pluginFramework/RushSession'; +import { _getRushSessionTelemetryAggregate } from '../pluginFramework/RushSession'; import { collectPerformanceEntries } from '../utilities/performance'; /** @@ -138,6 +140,16 @@ export interface ITelemetryData { * This is an array of `PerformanceEntry` objects, which can include marks, measures, and function timings. */ readonly performanceEntries?: readonly PerformanceEntry[]; + + /** + * The allowlisted projection derived from shadow reporter events. + * + * @remarks + * This is present only when the Rush frontend supplied a reporter event sink. + * It never contains messages, paths, arguments, raw output, remediation + * parameters, stack traces, or non-public envelope metadata. + */ + readonly reporterData?: ITelemetryAggregate; } const MAX_FILE_COUNT: number = 100; @@ -166,9 +178,30 @@ export class Telemetry { if (!this._enabled) { return; } + const reporterAggregate: ITelemetryAggregate | undefined = _getRushSessionTelemetryAggregate( + this._rushSession + ); + const processExitCode: number = + typeof process.exitCode === 'number' ? process.exitCode : Number(process.exitCode); const cpus: os.CpuInfo[] = os.cpus(); const data: ITelemetryData = { ...telemetryData, + reporterData: reporterAggregate + ? { + ...reporterAggregate, + commandName: reporterAggregate.commandName ?? telemetryData.name, + result: + reporterAggregate.result ?? (telemetryData.result === 'Succeeded' ? 'succeeded' : 'failed'), + exitCode: + reporterAggregate.exitCode ?? + (telemetryData.result === 'Succeeded' + ? 0 + : Number.isFinite(processExitCode) + ? processExitCode + : 1), + durationMs: reporterAggregate.durationMs ?? telemetryData.durationInSeconds * 1000 + } + : telemetryData.reporterData, performanceEntries: telemetryData.performanceEntries || collectPerformanceEntries(this._telemetryStartTime), machineInfo: telemetryData.machineInfo || { diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts new file mode 100644 index 00000000000..11a100e68bd --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + createRushDiagnostic, + type IRushDiagnostic, + type LifecycleEmitter, + type OperationStatus as ReporterOperationStatus +} from '@rushstack/rush-reporter'; +import type { ITerminalChunk } from '@rushstack/terminal'; + +import type { RushSession } from '../../pluginFramework/RushSession'; +import { + _correlateRushSessionError, + _getRushSessionLifecycleEmitter +} from '../../pluginFramework/RushSession'; +import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; +import type { Operation } from './Operation'; +import { OperationStatus } from './OperationStatus'; +import type { OperationGraph } from './OperationGraph'; + +interface IReporterOperation { + readonly emitter: LifecycleEmitter; + readonly legacyOperationIds: Set; + readonly operationId: string; + readonly phaseName: string; + readonly projectName: string; + readonly registeredOperationIds: Set; + readonly statuses: Map; + lastEmittedStatus: ReporterOperationStatus | undefined; + silent: boolean; +} + +class ReporterOperationEventSink implements IOperationGraphEventSink { + private readonly _operationsByLegacyId: Map = new Map(); + private readonly _diagnosedOperations: Set = new Set(); + private readonly _rushSession: RushSession; + + public constructor(rushSession: RushSession, commandName: string, operations: Iterable) { + this._rushSession = rushSession; + const operationsByReporterId: Map = new Map(); + + for (const operation of operations) { + const projectName: string = operation.associatedProject.packageName; + const phaseName: string = operation.associatedPhase.name; + const operationId: string = `${projectName}#${phaseName}`; + let reporterOperation: IReporterOperation | undefined = operationsByReporterId.get(operationId); + if (!reporterOperation) { + const emitter: LifecycleEmitter | undefined = _getRushSessionLifecycleEmitter(rushSession, { + commandName, + operationId, + projectName, + phaseName + }); + if (!emitter) { + continue; + } + reporterOperation = { + emitter, + legacyOperationIds: new Set(), + operationId, + phaseName, + projectName, + registeredOperationIds: new Set(), + statuses: new Map(), + lastEmittedStatus: undefined, + silent: true + }; + operationsByReporterId.set(operationId, reporterOperation); + } + reporterOperation.legacyOperationIds.add(operation.name); + reporterOperation.silent &&= !operation.enabled || operation.runner?.silent === true; + this._operationsByLegacyId.set(operation.name, reporterOperation); + } + } + + public get isEnabled(): boolean { + return this._operationsByLegacyId.size > 0; + } + + public onOperationRegistered(operationId: string, silent: boolean): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + if (!operation) { + return; + } + + if (operation.registeredOperationIds.size === operation.legacyOperationIds.size) { + operation.registeredOperationIds.clear(); + operation.statuses.clear(); + operation.lastEmittedStatus = undefined; + operation.silent = true; + this._diagnosedOperations.delete(operation.operationId); + } + + operation.registeredOperationIds.add(operationId); + operation.silent &&= silent; + if (operation.registeredOperationIds.size !== operation.legacyOperationIds.size || operation.silent) { + return; + } + + operation.emitter.emitOperationRegistered({ + operationId: operation.operationId, + projectName: operation.projectName, + phaseName: operation.phaseName + }); + } + + public onOperationStatusChanged(result: IOperationExecutionResult): void { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(result.operation.name); + if (!operation) { + return; + } + + if ( + result.status === OperationStatus.Ready && + operation.registeredOperationIds.size === operation.legacyOperationIds.size + ) { + return; + } + + operation.statuses.set(result.operation.name, result.status); + if (result.status === OperationStatus.Failure && !this._diagnosedOperations.has(operation.operationId)) { + this._diagnosedOperations.add(operation.operationId); + const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: operation.projectName, privacy: 'public' } + } + }); + operation.emitter.emitDiagnostic(diagnostic); + if (result.error) { + _correlateRushSessionError(this._rushSession, result.error, diagnostic.diagnosticId); + } + } + + const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation); + if (status === undefined || status === operation.lastEmittedStatus) { + return; + } + operation.lastEmittedStatus = status; + if (!operation.silent) { + const durationMs: number | undefined = + operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined + ? result.stopwatch.duration * 1000 + : undefined; + operation.emitter.emitOperationStatusChanged({ + operationId: operation.operationId, + status, + ...(durationMs === undefined ? {} : { durationMs }) + }); + } + } +} + +class CompositeOperationGraphEventSink implements IOperationGraphEventSink { + public readonly onOperationChunk: ((operationId: string, chunk: ITerminalChunk) => void) | undefined; + public readonly onOperationStreamClosed: ((operationId: string) => void) | undefined; + + private readonly _first: IOperationGraphEventSink; + private readonly _second: IOperationGraphEventSink; + + public constructor(first: IOperationGraphEventSink, second: IOperationGraphEventSink) { + this._first = first; + this._second = second; + this.onOperationChunk = + first.onOperationChunk || second.onOperationChunk + ? (operationId, chunk) => { + first.onOperationChunk?.(operationId, chunk); + second.onOperationChunk?.(operationId, chunk); + } + : undefined; + this.onOperationStreamClosed = + first.onOperationStreamClosed || second.onOperationStreamClosed + ? (operationId) => { + first.onOperationStreamClosed?.(operationId); + second.onOperationStreamClosed?.(operationId); + } + : undefined; + } + + public onOperationRegistered(operationId: string, silent: boolean): void { + this._first.onOperationRegistered?.(operationId, silent); + this._second.onOperationRegistered?.(operationId, silent); + } + + public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { + this._first.onOperationStatusChanged?.(result, previousStatus); + this._second.onOperationStatusChanged?.(result, previousStatus); + } + + public onOperationHeader(operationId: string, completedOperations: number, totalOperations: number): void { + this._first.onOperationHeader?.(operationId, completedOperations, totalOperations); + this._second.onOperationHeader?.(operationId, completedOperations, totalOperations); + } + + public onActivity(text: string, options?: IOperationActivityOptions): void { + this._first.onActivity?.(text, options); + this._second.onActivity?.(text, options); + } +} + +/** + * Adds status-only reporter emission without changing the graph's visible output or raw chunk routing. + * + * @internal + */ +export function attachReporterOperationEventSink( + graph: OperationGraph, + rushSession: RushSession, + commandName: string +): void { + const reporterSink: ReporterOperationEventSink = new ReporterOperationEventSink( + rushSession, + commandName, + graph.operations + ); + if (!reporterSink.isEnabled) { + return; + } + + graph.eventSink = graph.eventSink + ? new CompositeOperationGraphEventSink(graph.eventSink, reporterSink) + : reporterSink; +} + +function _toReporterStatus(status: OperationStatus): ReporterOperationStatus { + switch (status) { + case OperationStatus.Ready: + return 'ready'; + case OperationStatus.Waiting: + return 'waiting'; + case OperationStatus.Queued: + return 'queued'; + case OperationStatus.Executing: + return 'executing'; + case OperationStatus.Success: + return 'success'; + case OperationStatus.SuccessWithWarning: + return 'successWithWarnings'; + case OperationStatus.Failure: + return 'failure'; + case OperationStatus.Blocked: + return 'blocked'; + case OperationStatus.Skipped: + return 'skipped'; + case OperationStatus.FromCache: + return 'fromCache'; + case OperationStatus.NoOp: + return 'noOp'; + case OperationStatus.Aborted: + return 'aborted'; + } +} + +function _getAggregateStatus(operation: IReporterOperation): ReporterOperationStatus | undefined { + const statuses: readonly OperationStatus[] = [...operation.statuses.values()]; + if ( + statuses.some((status) => status === OperationStatus.Executing) || + operation.lastEmittedStatus === 'executing' + ) { + if ( + operation.statuses.size !== operation.legacyOperationIds.size || + statuses.some((status) => !_isTerminalStatus(status)) + ) { + return 'executing'; + } + } + if ( + operation.statuses.size === operation.legacyOperationIds.size && + statuses.every((status) => _isTerminalStatus(status)) + ) { + return _getAggregateTerminalStatus(statuses); + } + if (statuses.some((status) => status === OperationStatus.Queued)) { + return 'queued'; + } + if (statuses.some((status) => status === OperationStatus.Ready)) { + return 'ready'; + } + if (statuses.some((status) => status === OperationStatus.Waiting)) { + return 'waiting'; + } + return operation.legacyOperationIds.size === 1 + ? _toReporterStatus(statuses[0] ?? OperationStatus.Ready) + : undefined; +} + +function _getAggregateTerminalStatus(operationStatuses: Iterable): ReporterOperationStatus { + const statuses: Set = new Set(operationStatuses); + if (statuses.has(OperationStatus.Failure)) return 'failure'; + if (statuses.has(OperationStatus.Aborted)) return 'aborted'; + if (statuses.has(OperationStatus.Blocked)) return 'blocked'; + if (statuses.has(OperationStatus.SuccessWithWarning)) return 'successWithWarnings'; + if (statuses.has(OperationStatus.Success)) return 'success'; + if (statuses.has(OperationStatus.FromCache)) return 'fromCache'; + if (statuses.has(OperationStatus.Skipped)) return 'skipped'; + return 'noOp'; +} + +function _isTerminalStatus(status: OperationStatus): boolean { + switch (status) { + case OperationStatus.Success: + case OperationStatus.SuccessWithWarning: + case OperationStatus.Failure: + case OperationStatus.Blocked: + case OperationStatus.Skipped: + case OperationStatus.FromCache: + case OperationStatus.NoOp: + case OperationStatus.Aborted: + return true; + default: + return false; + } +} diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index c16d6c91f32..fc6e58fe38a 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -34,7 +34,8 @@ jest.mock('../ProjectLogWritable', () => { }; }); -import { MockWritable, type ITerminalChunk } from '@rushstack/terminal'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; +import { MockWritable, StringBufferTerminalProvider, type ITerminalChunk } from '@rushstack/terminal'; import type { CollatedTerminal } from '@rushstack/stream-collator'; import type { IPhase } from '../../../api/CommandLineConfiguration'; @@ -46,6 +47,13 @@ import { OperationStatus } from '../OperationStatus'; import { Operation } from '../Operation'; import type { IOperationRunner, IOperationRunnerContext } from '../IOperationRunner'; import { MockOperationRunner } from './MockOperationRunner'; +import { + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + RushSession +} from '../../../pluginFramework/RushSession'; +import { attachReporterOperationEventSink } from '../ReporterOperationEventSink'; const mockPhase: IPhase = { name: 'phase', @@ -57,12 +65,17 @@ const mockPhase: IPhase = { missingScriptBehavior: 'silent' }; -function createOperation(name: string, runner: IOperationRunner): Operation { +function createOperation( + name: string, + runner: IOperationRunner, + phase: IPhase = mockPhase, + projectName: string = name +): Operation { return new Operation({ runner, logFilenameIdentifier: name, - phase: mockPhase, - project: { packageName: name } as unknown as RushConfigurationProject + phase, + project: { packageName: projectName } as unknown as RushConfigurationProject }); } @@ -95,6 +108,15 @@ class RecordingSink implements IOperationGraphEventSink { } } +class CapturingReporterSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} + function createGraphOptions(mockWritable: MockWritable, quietMode: boolean): IOperationGraphOptions { return { quietMode, @@ -207,4 +229,209 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(tappedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); }); + + it('emits phase-aware status and diagnostic events without routing operation chunks', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'operation-shadow' } + }); + const createFailingOperation = (): Operation => + createOperation( + '@scope/project', + new MockOperationRunner('@scope/project (phase)', async () => OperationStatus.Failure) + ); + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createFailingOperation()]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const operation: Operation = createFailingOperation(); + const graph: OperationGraph = new OperationGraph( + new Set([operation]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationEvents: IReporterEmitEventInput[] = reporterSink.inputs.filter( + ({ type }) => type === 'operationRegistered' || type === 'operationStatusChanged' + ); + expect(operationEvents.length).toBeGreaterThan(1); + for (const event of operationEvents) { + expect(event.scope).toMatchObject({ + commandName: 'build', + operationId: '@scope/project#phase', + projectName: '@scope/project', + phaseName: 'phase' + }); + } + expect(reporterSink.inputs).toContainEqual( + expect.objectContaining({ + type: 'diagnosticEmitted', + payload: expect.objectContaining({ code: 'RUSH_OPERATION_FAILED' }) + }) + ); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + expect(mockWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + }); + + it('aggregates sharded records across mixed outcomes and repeated watch-style iterations', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'sharded-operation-shadow' } + }); + const projectName: string = '@scope/sharded'; + const preShardRunner: IOperationRunner = { + name: `${projectName} (phase) - pre-shard`, + reportTiming: false, + silent: true, + cacheable: false, + warningsAreAllowed: false, + isNoOp: true, + executeAsync: async () => OperationStatus.NoOp, + getConfigHash: () => 'pre-shard' + }; + const shardOneRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 1/2`, + async () => OperationStatus.Success + ); + let shardTwoOutcome: OperationStatus = OperationStatus.Failure; + const shardTwoRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - shard 2/2`, + async () => shardTwoOutcome + ); + const collatorRunner: MockOperationRunner = new MockOperationRunner( + `${projectName} (phase) - collate`, + async () => OperationStatus.Success + ); + const preShard: Operation = createOperation('pre-shard', preShardRunner, mockPhase, projectName); + const shardOne: Operation = createOperation('shard-one', shardOneRunner, mockPhase, projectName); + const shardTwo: Operation = createOperation('shard-two', shardTwoRunner, mockPhase, projectName); + const collator: Operation = createOperation('collator', collatorRunner, mockPhase, projectName); + shardOne.addDependency(preShard); + shardTwo.addDependency(preShard); + collator.addDependency(shardOne); + collator.addDependency(shardTwo); + const graph: OperationGraph = new OperationGraph( + new Set([collator, preShard, shardOne, shardTwo]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const reporterOperationId: string = `${projectName}#phase`; + const operationEvents = (): IReporterEmitEventInput[] => + reporterSink.inputs.filter(({ scope }) => scope?.operationId === reporterOperationId); + expect(operationEvents().filter(({ type }) => type === 'operationRegistered')).toHaveLength(1); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'failure' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + failure: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + + shardTwoOutcome = OperationStatus.Success; + graph.invalidateOperations(undefined, 'watch iteration'); + await graph.executeAsync({}); + + expect( + operationEvents() + .filter(({ type }) => type === 'operationRegistered') + .map(({ scope }) => scope?.operationId) + ).toEqual([reporterOperationId, reporterOperationId]); + expect( + operationEvents() + .filter(({ type }) => type === 'operationStatusChanged') + .at(-1)?.payload + ).toMatchObject({ operationId: reporterOperationId, status: 'success' }); + expect( + operationEvents().filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(1); + expect(_getRushSessionTelemetryAggregate(rushSession)?.operationStatusCounts).toEqual({ + success: 1 + }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + + const lifecycleEmitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + lifecycleEmitter.emitCommandResult({ commandName: 'build', succeeded: true, exitCode: 0 }); + lifecycleEmitter.emitCommandCompleted({ commandName: 'build', exitCode: 0 }); + lifecycleEmitter.emitSessionCompleted({ exitCode: 0 }); + expect(_getRushSessionDerivedExitStatus(rushSession)).toEqual({ + exitCode: 0, + outcome: 'succeeded' + }); + expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); + }); + + it('recomputes grouped silence for each watch-style iteration', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'grouped-silence-shadow' } + }); + const projectName: string = '@scope/silence'; + const first: Operation = createOperation( + 'first', + new MockOperationRunner(`${projectName} (phase) - first`), + mockPhase, + projectName + ); + const second: Operation = createOperation( + 'second', + new MockOperationRunner(`${projectName} (phase) - second`), + mockPhase, + projectName + ); + const graph: OperationGraph = new OperationGraph( + new Set([first, second]), + createGraphOptions(mockWritable, false) + ); + + attachReporterOperationEventSink(graph, rushSession, 'build'); + await graph.executeAsync({}); + + const operationId: string = `${projectName}#phase`; + const countEvents = (type: IReporterEmitEventInput['type']): number => + reporterSink.inputs.filter( + ({ type: eventType, scope }) => eventType === type && scope?.operationId === operationId + ).length; + const registrationCount: number = countEvents('operationRegistered'); + const statusCount: number = countEvents('operationStatusChanged'); + expect(registrationCount).toBe(1); + expect(statusCount).toBeGreaterThan(0); + + first.enabled = false; + second.enabled = false; + graph.invalidateOperations(undefined, 'disable group'); + await graph.executeAsync({}); + + expect(countEvents('operationRegistered')).toBe(registrationCount); + expect(countEvents('operationStatusChanged')).toBe(statusCount); + }); }); diff --git a/libraries/rush-lib/src/logic/test/Telemetry.test.ts b/libraries/rush-lib/src/logic/test/Telemetry.test.ts index aebc60ef4a9..4afccd7e86e 100644 --- a/libraries/rush-lib/src/logic/test/Telemetry.test.ts +++ b/libraries/rush-lib/src/logic/test/Telemetry.test.ts @@ -2,12 +2,22 @@ // See LICENSE in the project root for license information. import { JsonFile } from '@rushstack/node-core-library'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; import { ConsoleTerminalProvider } from '@rushstack/terminal'; import { RushConfiguration } from '../../api/RushConfiguration'; import { Rush } from '../../api/Rush'; import { Telemetry, type ITelemetryData, type ITelemetryMachineInfo } from '../Telemetry'; -import { RushSession } from '../../pluginFramework/RushSession'; +import { _getRushSessionLifecycleEmitter, RushSession } from '../../pluginFramework/RushSession'; + +class CapturingSink implements IReporterEventSink { + public readonly inputs: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.inputs.push(event); + return `event-${this.inputs.length}`; + } +} interface ITelemetryPrivateMembers extends Omit { _flushAsyncTasks: Map>; @@ -136,6 +146,38 @@ describe(Telemetry.name, () => { expect(result.timestampMs).toBeDefined(); }); + it('projects public shadow events into legacy telemetry without exposing command arguments', () => { + const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; + const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); + const sink: CapturingSink = new CapturingSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new ConsoleTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: sink, sessionId: 'telemetry-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(rushSession, { commandName: 'build' })!; + emitter.emitCommandStarted({ commandName: 'build', argv: ['--auth-token=secret'] }); + emitter.emitOperationStatusChanged({ operationId: '@scope/project#_phase:build', status: 'success' }); + + const telemetry: Telemetry = new Telemetry(rushConfig, rushSession); + telemetry.log({ + name: 'build', + durationInSeconds: 2, + result: 'Succeeded', + machineInfo: {} as ITelemetryMachineInfo, + performanceEntries: [] + }); + + expect(telemetry.store[0].reporterData).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0, + durationMs: 2000, + operationStatusCounts: { success: 1 } + }); + expect(JSON.stringify(telemetry.store[0].reporterData)).not.toContain('--auth-token=secret'); + }); + it('calls custom flush telemetry', async () => { const filename: string = `${__dirname}/telemetry/telemetryEnabled.json`; const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(filename); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts index 26a48160731..c4287f8d580 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.test.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.test.ts @@ -3,16 +3,27 @@ import * as os from 'node:os'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSource, IReporterEventSink } from '@rushstack/rush-reporter'; +import { createRushDiagnostic } from '@rushstack/rush-reporter'; import { StringBufferTerminalProvider } from '@rushstack/terminal'; import { Rush } from '../api/Rush'; import { RushCommandLineParser } from '../cli/RushCommandLineParser'; -import { _createRushSessionForPlugin, type IRushSessionReporterOptions, RushSession } from './RushSession'; +import { + _correlateRushSessionError, + _createRushSessionForPlugin, + _getRushSessionDerivedExitStatus, + _getRushSessionLifecycleEmitter, + _getRushSessionTelemetryAggregate, + _isRushSessionErrorRepresented, + type IRushSessionReporterOptions, + RushSession +} from './RushSession'; class CapturingSink implements IReporterEventSink { public readonly inputs: IReporterEmitEventInput[] = []; @@ -149,4 +160,86 @@ describe(RushSession.name, () => { action!.reporter!.emitMessage({ severity: 'debug', text: 'action' }); expect(sink.inputs[0].scope).toEqual({ commandName: 'list' }); }); + + it('observes shadow lifecycle, diagnostics, telemetry, and legacy correlation without terminal output', () => { + const sink: CapturingSink = new CapturingSink(); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const session: RushSession = new RushSession({ + getIsDebugMode: () => false, + terminalProvider, + reporter: { eventSink: sink, sessionId: 'session-shadow' } + }); + const emitter = _getRushSessionLifecycleEmitter(session, { commandName: 'build' })!; + const error: AlreadyReportedError = new AlreadyReportedError(); + + emitter.emitSessionStarted({ rushVersion: Rush.version }); + emitter.emitCommandStarted({ commandName: 'build' }); + emitter.emitOperationRegistered({ + operationId: '@scope/project#_phase:test', + projectName: '@scope/project', + phaseName: '_phase:test' + }); + emitter.emitOperationStatusChanged({ + operationId: '@scope/project#_phase:test', + status: 'failure' + }); + const diagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + projectName: { value: '@scope/project', privacy: 'public' } + } + }); + emitter.emitDiagnostic(diagnostic); + _correlateRushSessionError(session, error, diagnostic.diagnosticId); + emitter.emitCommandResult({ commandName: 'build', succeeded: false, exitCode: 1 }); + emitter.emitCommandCompleted({ commandName: 'build', exitCode: 1, durationMs: 25 }); + emitter.emitSessionCompleted({ exitCode: 1, durationMs: 30 }); + + expect(sink.inputs.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'operationRegistered', + 'operationStatusChanged', + 'diagnosticEmitted', + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + expect(_isRushSessionErrorRepresented(session, error)).toBe(true); + expect(_getRushSessionDerivedExitStatus(session)).toEqual({ exitCode: 1, outcome: 'failed' }); + expect(_getRushSessionTelemetryAggregate(session)).toMatchObject({ + commandName: 'build', + result: 'failed', + exitCode: 1, + operationStatusCounts: { failure: 1 }, + diagnosticCodes: ['RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1 } + }); + expect(terminalProvider.getAllOutput(false)).toEqual({ + log: '', + warning: '', + error: '', + verbose: '', + debug: '' + }); + }); + + it('excludes non-public plugin envelopes from the shadow telemetry projection', () => { + const sink: CapturingSink = new CapturingSink(); + const session: RushSession = createSession({ eventSink: sink, sessionId: 'session-private' }); + const pluginSession: RushSession = _createRushSessionForPlugin(session, () => ({ + packageName: '@private/plugin', + packageVersion: '1.0.0' + })); + + pluginSession.getReporter()!.emitMessage({ + severity: 'info', + text: '/local/private/path' + }); + _getRushSessionLifecycleEmitter(session)!.emitSessionStarted({ rushVersion: Rush.version }); + + const aggregate = _getRushSessionTelemetryAggregate(session)!; + expect(JSON.stringify(aggregate)).not.toContain('@private/plugin'); + expect(JSON.stringify(aggregate)).not.toContain('/local/private/path'); + expect(aggregate.producerVersions).toEqual([`@microsoft/rush-lib@${Rush.version}`]); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index e017a9a8cbc..fa9771ccb08 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -3,10 +3,19 @@ import { InternalError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; import { + LifecycleEmitter, + LegacyErrorBridge, RushSessionReporting, + TelemetrySubscriber, + isReporterEventRequired, + resolveExitStatus, + type IReporterEmitEventInput, + type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, type IReporterEventSource, + type IRushExitStatus, + type ITelemetryAggregate, type IScopedLogger, type IScopedReporter } from '@rushstack/rush-reporter'; @@ -77,7 +86,23 @@ interface IRushSessionState { readonly cloudBuildCacheProviderFactories: Map; readonly cobuildLockProviderFactories: Map; readonly hooks: RushLifecycleHooks; - readonly reporting: RushSessionReporting | undefined; + readonly reporting: IRushSessionReportingState | undefined; +} + +interface IRushSessionReportingState { + readonly eventSink: IReporterEventSink; + readonly sessionId: string; + readonly source: IReporterEventSource; + readonly sessionReporting: RushSessionReporting; + readonly observer: IRushSessionShadowEventObserver; +} + +interface IRushSessionShadowEventObserver { + ingest(event: IReporterEmitEventInput, eventId: string): void; + buildTelemetryAggregate(): ITelemetryAggregate; + resolveExitStatus(): IRushExitStatus; + correlateError(error: unknown, diagnosticId: string): void; + isErrorRepresented(error: unknown): boolean; } let _rushLibSource: IReporterEventSource | undefined; @@ -107,8 +132,9 @@ function _getRushLibSource(): IReporterEventSource { function _createReporting( reporterOptions: IRushSessionReporterOptions | undefined, - source: IReporterEventSource -): RushSessionReporting | undefined { + source: IReporterEventSource, + observer?: IRushSessionShadowEventObserver +): IRushSessionReportingState | undefined { if (!reporterOptions) { return undefined; } @@ -121,10 +147,148 @@ function _createReporting( throw new TypeError('RushSession reporter.sessionId must be a non-empty string'); } - return new RushSessionReporting({ - sink: eventSink, + const shadowObserver: IRushSessionShadowEventObserver = observer ?? _createRushSessionShadowEventObserver(); + const observedEventSink: IReporterEventSink = { + emit(event: IReporterEmitEventInput): string { + const eventId: string = eventSink.emit(event); + shadowObserver.ingest(event, eventId); + return eventId; + } + }; + const boundSource: IReporterEventSource = { ...source }; + + return { + eventSink: observedEventSink, sessionId, - source: { ...source } + source: boundSource, + observer: shadowObserver, + sessionReporting: new RushSessionReporting({ + sink: observedEventSink, + sessionId, + source: boundSource + }) + }; +} + +function _createRushSessionShadowEventObserver(): IRushSessionShadowEventObserver { + const legacyErrorBridge: LegacyErrorBridge = new LegacyErrorBridge(); + const telemetrySubscriber: TelemetrySubscriber = new TelemetrySubscriber(); + const operationStatuses: Map = new Map(); + let sequence: number = 0; + let derivedExitStatus: IRushExitStatus = { exitCode: 0, outcome: 'succeeded' }; + let hasUnscopedFailure: boolean = false; + + const updateDerivedOperationStatus = (): void => { + const hasOperationFailure: boolean = [...operationStatuses.values()].some( + (status) => status === 'failure' || status === 'aborted' + ); + derivedExitStatus = resolveExitStatus({ + hasFailures: hasUnscopedFailure || hasOperationFailure + }); + }; + + return { + ingest(event: IReporterEmitEventInput, eventId: string): void { + const envelope: IReporterEventEnvelope = { + ...event, + eventId, + sequence: ++sequence, + timestamp: new Date().toISOString(), + required: isReporterEventRequired(event.type) + }; + legacyErrorBridge.ingest(envelope); + + if (envelope.parentSessionId === undefined) { + switch (envelope.type) { + case 'commandStarted': { + operationStatuses.clear(); + hasUnscopedFailure = false; + derivedExitStatus = { exitCode: 0, outcome: 'succeeded' }; + break; + } + case 'operationRegistered': { + const { operationId } = envelope.payload as { operationId: string }; + operationStatuses.set(operationId, 'ready'); + updateDerivedOperationStatus(); + break; + } + case 'operationStatusChanged': { + const { operationId, status } = envelope.payload as { + operationId: string; + status: string; + }; + operationStatuses.set(operationId, status); + updateDerivedOperationStatus(); + break; + } + case 'diagnosticEmitted': { + const { severity } = envelope.payload as { severity?: string }; + if (severity === 'error' && envelope.scope?.operationId === undefined) { + hasUnscopedFailure = true; + updateDerivedOperationStatus(); + } + break; + } + case 'commandResult': { + const { succeeded, exitCode } = envelope.payload as { + succeeded: boolean; + exitCode: number; + }; + derivedExitStatus = resolveExitStatus({ + hasFailures: !succeeded || exitCode !== 0 + }); + break; + } + case 'commandCompleted': + case 'sessionCompleted': { + const { exitCode } = envelope.payload as { exitCode: number }; + derivedExitStatus = resolveExitStatus({ hasFailures: exitCode !== 0 }); + break; + } + default: + break; + } + } + + // Match the privacy behavior from #5990 without duplicating its reporter-package changes: + // only public envelopes contribute source, protocol, lifecycle, or diagnostic telemetry. + // Remove this outer gate after #5990 reaches shared main and the hardened subscriber is in this ancestry. + if (envelope.privacy === 'public') { + telemetrySubscriber.ingest(envelope); + } + }, + + buildTelemetryAggregate(): ITelemetryAggregate { + return telemetrySubscriber.buildAggregate(); + }, + + resolveExitStatus(): IRushExitStatus { + return derivedExitStatus; + }, + + correlateError(error: unknown, diagnosticId: string): void { + legacyErrorBridge.correlate(error, diagnosticId); + }, + + isErrorRepresented(error: unknown): boolean { + return legacyErrorBridge.shouldSuppressRendering(error); + } + }; +} + +function _createLifecycleEmitter( + state: IRushSessionReportingState | undefined, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + if (!state) { + return undefined; + } + + return new LifecycleEmitter({ + sink: state.eventSink, + sessionId: state.sessionId, + source: state.source, + scope: scope ? { ...scope } : undefined }); } @@ -181,7 +345,9 @@ export class RushSession { * source identity bound by Rush. */ public getReporter(scope?: IReporterEventScope): IScopedReporter | undefined { - return _getSessionState(this).reporting?.createScopedReporter(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedReporter( + scope ? { ...scope } : undefined + ); } /** @@ -193,7 +359,9 @@ export class RushSession { * available during the pre-major compatibility period. */ public getScopedLogger(scope?: IReporterEventScope): IScopedLogger | undefined { - return _getSessionState(this).reporting?.createScopedLogger(scope ? { ...scope } : undefined); + return _getSessionState(this).reporting?.sessionReporting.createScopedLogger( + scope ? { ...scope } : undefined + ); } public registerCloudBuildCacheProviderFactory( @@ -248,7 +416,8 @@ export function _createRushSessionForPlugin( getSource: () => IReporterEventSource ): RushSession { const state: IRushSessionState = _getSessionState(rushSession); - if (!state.options.reporter) { + const reporting: IRushSessionReportingState | undefined = state.reporting; + if (!state.options.reporter || !reporting) { return rushSession; } @@ -264,7 +433,68 @@ export function _createRushSessionForPlugin( cloudBuildCacheProviderFactories: state.cloudBuildCacheProviderFactories, cobuildLockProviderFactories: state.cobuildLockProviderFactories, hooks: state.hooks, - reporting: _createReporting(state.options.reporter, getSource()) + reporting: _createReporting(state.options.reporter, getSource(), reporting.observer) }); return pluginSession; } + +/** + * Creates a Rush-owned lifecycle emitter for internal command and operation paths. + * + * @internal + */ +export function _getRushSessionLifecycleEmitter( + rushSession: RushSession, + scope?: IReporterEventScope +): LifecycleEmitter | undefined { + return _createLifecycleEmitter(_getSessionState(rushSession).reporting, scope); +} + +/** + * Returns the current allowlisted reporter telemetry projection. + * + * @internal + */ +export function _getRushSessionTelemetryAggregate(rushSession: RushSession): ITelemetryAggregate | undefined { + return _getSessionState(rushSession).reporting?.observer.buildTelemetryAggregate(); +} + +/** + * Derives the shadow exit status without changing the authoritative process exit code. + * + * @internal + */ +export function _getRushSessionDerivedExitStatus(rushSession: RushSession): IRushExitStatus | undefined { + return _getSessionState(rushSession).reporting?.observer.resolveExitStatus(); +} + +/** + * Returns the Rush version bound to structured events for this session. + * + * @internal + */ +export function _getRushSessionReporterSourceVersion(rushSession: RushSession): string | undefined { + return _getSessionState(rushSession).reporting?.source.packageVersion; +} + +/** + * Correlates a legacy failure sentinel with an emitted structured diagnostic. + * + * @internal + */ +export function _correlateRushSessionError( + rushSession: RushSession, + error: unknown, + diagnosticId: string +): void { + _getSessionState(rushSession).reporting?.observer.correlateError(error, diagnosticId); +} + +/** + * Returns whether a failure is already represented by an emitted diagnostic or legacy sentinel. + * + * @internal + */ +export function _isRushSessionErrorRepresented(rushSession: RushSession, error: unknown): boolean { + return _getSessionState(rushSession).reporting?.observer.isErrorRepresented(error) ?? false; +} From 44bf28668ba3f38b46b21cccb5c96a78dcca4322 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 18:11:04 +0000 Subject: [PATCH 2/5] Isolate overlapping reporter watch cycles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- common/reviews/api/rush-lib.api.md | 2 +- .../logic/operations/OperationEventSink.ts | 7 +- .../src/logic/operations/OperationGraph.ts | 17 ++-- .../operations/ReporterOperationEventSink.ts | 79 +++++++++++-------- .../test/OperationGraphEventSink.test.ts | 50 +++++++++++- 5 files changed, 106 insertions(+), 49 deletions(-) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index bf214916f0b..01e0608c75f 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -685,7 +685,7 @@ export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; - onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationRegistered?(result: IOperationExecutionResult, silent: boolean): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; onOperationStreamClosed?(operationId: string): void; } diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index cda9311ebb4..74fe3be2ea7 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -40,16 +40,13 @@ export interface IOperationGraphEventSink { /** * Invoked when an operation is prepared for an iteration. */ - onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationRegistered?(result: IOperationExecutionResult, silent: boolean): void; /** * Invoked synchronously on every operation status transition. The result's * `status`, `error`, and `stopwatch` reflect the new state. */ - onOperationStatusChanged?( - result: IOperationExecutionResult, - previousStatus: OperationStatus - ): void; + onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; /** * Invoked when an operation's collated output is about to be displayed, diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 58e6c692aad..5f479b03565 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -678,7 +678,7 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent); + eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); } for (const [operation, record] of executionRecords) { @@ -1295,10 +1295,9 @@ function _handleOperationNoOp(record: OperationExecutionRecord, context: IStatef function _handleOperationSuccess(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { - record.eventSink?.onActivity?.( - `"${record.name}" completed successfully in ${stopwatch.toString()}.`, - { operationId: record.name } - ); + record.eventSink?.onActivity?.(`"${record.name}" completed successfully in ${stopwatch.toString()}.`, { + operationId: record.name + }); record.collatedWriter.terminal.writeStdoutLine( Colorize.green(`"${record.name}" completed successfully in ${stopwatch.toString()}.`) ); @@ -1315,10 +1314,10 @@ function _handleOperationSuccessWithWarning( ): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { - record.eventSink?.onActivity?.( - `"${record.name}" completed with warnings in ${stopwatch.toString()}.`, - { operationId: record.name, stderr: true } - ); + record.eventSink?.onActivity?.(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`, { + operationId: record.name, + stderr: true + }); record.collatedWriter.terminal.writeStderrLine( Colorize.yellow(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`) ); diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index 11a100e68bd..e9613aaa075 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -26,15 +26,21 @@ interface IReporterOperation { readonly operationId: string; readonly phaseName: string; readonly projectName: string; + registrationCycle: IReporterOperationCycle | undefined; +} + +interface IReporterOperationCycle { readonly registeredOperationIds: Set; readonly statuses: Map; + diagnosed: boolean; lastEmittedStatus: ReporterOperationStatus | undefined; silent: boolean; } class ReporterOperationEventSink implements IOperationGraphEventSink { private readonly _operationsByLegacyId: Map = new Map(); - private readonly _diagnosedOperations: Set = new Set(); + private readonly _cyclesByResult: WeakMap = + new WeakMap(); private readonly _rushSession: RushSession; public constructor(rushSession: RushSession, commandName: string, operations: Iterable) { @@ -62,15 +68,11 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { operationId, phaseName, projectName, - registeredOperationIds: new Set(), - statuses: new Map(), - lastEmittedStatus: undefined, - silent: true + registrationCycle: undefined }; operationsByReporterId.set(operationId, reporterOperation); } reporterOperation.legacyOperationIds.add(operation.name); - reporterOperation.silent &&= !operation.enabled || operation.runner?.silent === true; this._operationsByLegacyId.set(operation.name, reporterOperation); } } @@ -79,23 +81,29 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { return this._operationsByLegacyId.size > 0; } - public onOperationRegistered(operationId: string, silent: boolean): void { + public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { + const operationId: string = result.operation.name; const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); if (!operation) { return; } - if (operation.registeredOperationIds.size === operation.legacyOperationIds.size) { - operation.registeredOperationIds.clear(); - operation.statuses.clear(); - operation.lastEmittedStatus = undefined; - operation.silent = true; - this._diagnosedOperations.delete(operation.operationId); + let cycle: IReporterOperationCycle | undefined = operation.registrationCycle; + if (!cycle || cycle.registeredOperationIds.size === operation.legacyOperationIds.size) { + cycle = { + registeredOperationIds: new Set(), + statuses: new Map(), + diagnosed: false, + lastEmittedStatus: undefined, + silent: true + }; + operation.registrationCycle = cycle; } - operation.registeredOperationIds.add(operationId); - operation.silent &&= silent; - if (operation.registeredOperationIds.size !== operation.legacyOperationIds.size || operation.silent) { + this._cyclesByResult.set(result, cycle); + cycle.registeredOperationIds.add(operationId); + cycle.silent &&= silent; + if (cycle.registeredOperationIds.size !== operation.legacyOperationIds.size || cycle.silent) { return; } @@ -111,17 +119,21 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (!operation) { return; } + const cycle: IReporterOperationCycle | undefined = this._cyclesByResult.get(result); + if (!cycle) { + return; + } if ( result.status === OperationStatus.Ready && - operation.registeredOperationIds.size === operation.legacyOperationIds.size + cycle.registeredOperationIds.size === operation.legacyOperationIds.size ) { return; } - operation.statuses.set(result.operation.name, result.status); - if (result.status === OperationStatus.Failure && !this._diagnosedOperations.has(operation.operationId)) { - this._diagnosedOperations.add(operation.operationId); + cycle.statuses.set(result.operation.name, result.status); + if (result.status === OperationStatus.Failure && !cycle.diagnosed) { + cycle.diagnosed = true; const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_OPERATION_FAILED', { parameters: { projectName: { value: operation.projectName, privacy: 'public' } @@ -133,12 +145,12 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } } - const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation); - if (status === undefined || status === operation.lastEmittedStatus) { + const status: ReporterOperationStatus | undefined = _getAggregateStatus(operation, cycle); + if (status === undefined || status === cycle.lastEmittedStatus) { return; } - operation.lastEmittedStatus = status; - if (!operation.silent) { + cycle.lastEmittedStatus = status; + if (!cycle.silent) { const durationMs: number | undefined = operation.legacyOperationIds.size === 1 && result.stopwatch.startTime !== undefined ? result.stopwatch.duration * 1000 @@ -178,9 +190,9 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { : undefined; } - public onOperationRegistered(operationId: string, silent: boolean): void { - this._first.onOperationRegistered?.(operationId, silent); - this._second.onOperationRegistered?.(operationId, silent); + public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { + this._first.onOperationRegistered?.(result, silent); + this._second.onOperationRegistered?.(result, silent); } public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { @@ -252,21 +264,24 @@ function _toReporterStatus(status: OperationStatus): ReporterOperationStatus { } } -function _getAggregateStatus(operation: IReporterOperation): ReporterOperationStatus | undefined { - const statuses: readonly OperationStatus[] = [...operation.statuses.values()]; +function _getAggregateStatus( + operation: IReporterOperation, + cycle: IReporterOperationCycle +): ReporterOperationStatus | undefined { + const statuses: readonly OperationStatus[] = [...cycle.statuses.values()]; if ( statuses.some((status) => status === OperationStatus.Executing) || - operation.lastEmittedStatus === 'executing' + cycle.lastEmittedStatus === 'executing' ) { if ( - operation.statuses.size !== operation.legacyOperationIds.size || + cycle.statuses.size !== operation.legacyOperationIds.size || statuses.some((status) => !_isTerminalStatus(status)) ) { return 'executing'; } } if ( - operation.statuses.size === operation.legacyOperationIds.size && + cycle.statuses.size === operation.legacyOperationIds.size && statuses.every((status) => _isTerminalStatus(status)) ) { return _getAggregateTerminalStatus(statuses); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index fc6e58fe38a..8f89c77a1f2 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -86,8 +86,8 @@ class RecordingSink implements IOperationGraphEventSink { public readonly activities: string[] = []; public readonly chunks: Map = new Map(); - public onOperationRegistered(operationId: string, silent: boolean): void { - this.registered.push([operationId, silent]); + public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { + this.registered.push([result.operation.name, silent]); } public onOperationStatusChanged(result: IOperationExecutionResult): void { this.transitions.push([result.operation.name, result.status]); @@ -388,6 +388,52 @@ describe('OperationGraph event sink (dual-emit)', () => { expect(reporterSink.inputs.some(({ type }) => type === 'externalOutput')).toBe(false); }); + it('isolates diagnostics when the next watch iteration registers before abort completes', async () => { + const reporterSink: CapturingReporterSink = new CapturingReporterSink(); + const rushSession: RushSession = new RushSession({ + terminalProvider: new StringBufferTerminalProvider(), + getIsDebugMode: () => false, + reporter: { eventSink: reporterSink, sessionId: 'overlapping-operation-shadow' } + }); + let runCount: number = 0; + let resolveFirstRun: ((status: OperationStatus) => void) | undefined; + let markFirstRunStarted: (() => void) | undefined; + const firstRunStarted: Promise = new Promise((resolve: () => void) => { + markFirstRunStarted = resolve; + }); + const runner: MockOperationRunner = new MockOperationRunner('@scope/overlap (phase)', async () => { + runCount++; + if (runCount === 1) { + markFirstRunStarted!(); + return await new Promise((resolve: (status: OperationStatus) => void) => { + resolveFirstRun = resolve; + }); + } + return OperationStatus.Failure; + }); + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('overlap', runner, mockPhase, '@scope/overlap')]), + { ...createGraphOptions(mockWritable, false), pauseNextIteration: true } + ); + attachReporterOperationEventSink(graph, rushSession, 'build'); + + await graph.scheduleIterationAsync({}); + const firstExecution: Promise = graph.executeScheduledIterationAsync(); + await firstRunStarted; + await graph.scheduleIterationAsync({}); + const abortPromise: Promise = graph.abortCurrentIterationAsync(); + resolveFirstRun!(OperationStatus.Failure); + await Promise.all([firstExecution, abortPromise]); + await graph.executeScheduledIterationAsync(); + + expect( + reporterSink.inputs.filter( + ({ type, payload }) => + type === 'diagnosticEmitted' && (payload as { code?: string }).code === 'RUSH_OPERATION_FAILED' + ) + ).toHaveLength(2); + }); + it('recomputes grouped silence for each watch-style iteration', async () => { const reporterSink: CapturingReporterSink = new CapturingReporterSink(); const rushSession: RushSession = new RushSession({ From e588b242316cc16b993f0743b53f2e148be392c6 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 19:55:50 +0000 Subject: [PATCH 3/5] Keep operation sink callbacks compatible Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- common/reviews/api/rush-lib.api.md | 2 +- .../logic/operations/OperationEventSink.ts | 2 +- .../src/logic/operations/OperationGraph.ts | 2 +- .../operations/ReporterOperationEventSink.ts | 19 +++++++++++++------ .../test/OperationGraphEventSink.test.ts | 4 ++-- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 01e0608c75f..e9201654ba4 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -685,7 +685,7 @@ export interface _IOperationGraphEventSink { onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; - onOperationRegistered?(result: IOperationExecutionResult, silent: boolean): void; + onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; onOperationStreamClosed?(operationId: string): void; } diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index 74fe3be2ea7..b5d90e0ad8a 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -40,7 +40,7 @@ export interface IOperationGraphEventSink { /** * Invoked when an operation is prepared for an iteration. */ - onOperationRegistered?(result: IOperationExecutionResult, silent: boolean): void; + onOperationRegistered?(operationId: string, silent: boolean, result?: IOperationExecutionResult): void; /** * Invoked synchronously on every operation status transition. The result's diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 5f479b03565..b2c1362b6a2 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -678,7 +678,7 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); - eventSink?.onOperationRegistered?.(executionRecord, executionRecord.silent); + eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent, executionRecord); } for (const [operation, record] of executionRecords) { diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index e9613aaa075..d3287c44270 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -81,10 +81,13 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { return this._operationsByLegacyId.size > 0; } - public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { - const operationId: string = result.operation.name; + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); - if (!operation) { + if (!operation || !result) { return; } @@ -190,9 +193,13 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { : undefined; } - public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { - this._first.onOperationRegistered?.(result, silent); - this._second.onOperationRegistered?.(result, silent); + public onOperationRegistered( + operationId: string, + silent: boolean, + result?: IOperationExecutionResult + ): void { + this._first.onOperationRegistered?.(operationId, silent, result); + this._second.onOperationRegistered?.(operationId, silent, result); } public onOperationStatusChanged(result: IOperationExecutionResult, previousStatus: OperationStatus): void { diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 8f89c77a1f2..9a9883dfdb7 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -86,8 +86,8 @@ class RecordingSink implements IOperationGraphEventSink { public readonly activities: string[] = []; public readonly chunks: Map = new Map(); - public onOperationRegistered(result: IOperationExecutionResult, silent: boolean): void { - this.registered.push([result.operation.name, silent]); + public onOperationRegistered(operationId: string, silent: boolean): void { + this.registered.push([operationId, silent]); } public onOperationStatusChanged(result: IOperationExecutionResult): void { this.transitions.push([result.operation.name, result.status]); From 603362c4a4a37cca41846fed14c1ccbdd49e55ea Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Thu, 3 Sep 2026 20:53:13 +0000 Subject: [PATCH 4/5] Align reporter completion with failure exit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- libraries/rush-lib/src/cli/RushCommandLineParser.ts | 6 +++--- .../src/cli/test/RushCommandLineParserReporterClose.test.ts | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 7a149ca5744..772ee63935a 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -621,9 +621,6 @@ export class RushCommandLineParser extends CommandLineParser { console.error(`\n${error.stack}`); } - this._emitReporterCompletion(_getNumericProcessExitCode(1)); - this.flushTelemetry(); - const configuredExitCode: string | number | undefined = process.exitCode; const numericExitCode: number = Number(configuredExitCode); const exitCode: number = @@ -631,6 +628,9 @@ export class RushCommandLineParser extends CommandLineParser { ? numericExitCode : 1; process.exitCode = exitCode; + this._emitReporterCompletion(exitCode); + this.flushTelemetry(); + const handleExit = (): never => { // Ideally we want to eliminate all calls to process.exit() from our code, and replace them // with normal control flow that properly cleans up its data structures. diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 1b46d180f4c..7bebb337268 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -77,6 +77,8 @@ describe('RushCommandLineParser reporter close', () => { const parser: RushCommandLineParser = Object.create(RushCommandLineParser.prototype); Object.defineProperty(parser, '_debugParameter', { value: { value: false } }); Object.defineProperty(parser, '_rushOptions', { value: { reporterCloseAsync: closeAsync } }); + const emitReporterCompletion: jest.Mock = jest.fn(); + Object.defineProperty(parser, '_emitReporterCompletion', { value: emitReporterCompletion }); const exitSpy: jest.SpyInstance = jest .spyOn(process, 'exit') .mockImplementation(() => undefined as never); @@ -91,6 +93,7 @@ describe('RushCommandLineParser reporter close', () => { reportErrorAndSetExitCode(new Error('parser failed')); expect(closeAsync).toHaveBeenCalledTimes(1); + expect(emitReporterCompletion).toHaveBeenCalledWith(1); expect(exitSpy).not.toHaveBeenCalled(); process.exitCode = 0; From f439cc6e082f2ffee30b1d900c1c58e1ef125ee1 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 9 Sep 2026 13:03:41 +0000 Subject: [PATCH 5/5] Preserve reporter lifecycle across initialization and finalization failures Create the root reporting context before repository setup, share correlated failure emission, and publish final completion only after telemetry hooks settle without changing native error handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4747f826-8c83-495d-80df-3d1168662e10 --- ...orter-foundation-lifecycle_2026-09-09.json | 11 + .../rush-lib/src/cli/RushCommandLineParser.ts | 52 ++-- ...CommandLineParserReporterLifecycle.test.ts | 249 ++++++++++++++++++ specs/2026-07-12-rush-reporter-overhaul.md | 6 + 4 files changed, 300 insertions(+), 18 deletions(-) create mode 100644 common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json create mode 100644 libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts diff --git a/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json b/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json new file mode 100644 index 00000000000..0603c92996b --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-foundation-lifecycle_2026-09-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Report early initialization failures and defer successful reporter completion until telemetry finalization preserves the command's native outcome.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 772ee63935a..34341f9d5c9 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -147,6 +147,13 @@ export class RushCommandLineParser extends CommandLineParser { this._rushOptions = this._normalizeOptions(options || {}); const { cwd, alreadyReportedNodeTooNewError, builtInPluginConfigurations, reporter } = this._rushOptions; + this.rushSession = new RushSession({ + getIsDebugMode: () => this.isDebug, + terminalProvider, + reporter + }); + this._sessionLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession); + let rushJsonFilePath: string | undefined; try { rushJsonFilePath = RushConfiguration.tryFindRushJsonLocation({ @@ -171,11 +178,6 @@ export class RushCommandLineParser extends CommandLineParser { this.rushGlobalFolder = new RushGlobalFolder(); - this.rushSession = new RushSession({ - getIsDebugMode: () => this.isDebug, - terminalProvider, - reporter - }); this.pluginManager = new PluginManager({ rushSession: this.rushSession, rushConfiguration: this.rushConfiguration, @@ -277,13 +279,7 @@ export class RushCommandLineParser extends CommandLineParser { this._terminalProvider.verboseEnabled = this._terminalProvider.debugEnabled = rushArgv.includes('--debug') || rushArgv.includes('-d'); - this._sessionLifecycleEmitter = _getRushSessionLifecycleEmitter(this.rushSession); - if (this._sessionLifecycleEmitter) { - this._sessionStartTimeMs = performance.now(); - this._sessionLifecycleEmitter.emitSessionStarted({ - rushVersion: _getRushSessionReporterSourceVersion(this.rushSession)! - }); - } + this._startReporterSession(); try { await measureAsyncFn('rush:initializeUnassociatedPlugins', () => @@ -369,13 +365,17 @@ export class RushCommandLineParser extends CommandLineParser { // If we make it here, everything went fine, so reset the exit code back to 0 process.exitCode = 0; - this._emitReporterCompletion(0); } catch (error) { this._reportErrorAndSetExitCode(error as Error); } // This only gets hit if the wrapped execution completes successfully - await this.telemetry?.ensureFlushedAsync(); + try { + await this.telemetry?.ensureFlushedAsync(); + } catch (error) { + this._emitReporterFailureDiagnostic(error as Error); + throw error; + } } private _normalizeOptions(options: Partial): IRushCommandLineParserOptions { @@ -586,9 +586,21 @@ export class RushCommandLineParser extends CommandLineParser { ); } - private _reportErrorAndSetExitCode(error: Error): void { + private _startReporterSession(): void { + if (this._sessionLifecycleEmitter && this._sessionStartTimeMs === undefined) { + this._sessionStartTimeMs = performance.now(); + this._sessionLifecycleEmitter.emitSessionStarted({ + rushVersion: _getRushSessionReporterSourceVersion(this.rushSession)! + }); + } + } + + private _emitReporterFailureDiagnostic(error: Error): void { + this._startReporterSession(); + const emitter: LifecycleEmitter | undefined = + this._commandLifecycleEmitter ?? this._sessionLifecycleEmitter; const rushSession: RushSession | undefined = this.rushSession; - if (rushSession && !_isRushSessionErrorRepresented(rushSession, error)) { + if (emitter && rushSession && !_isRushSessionErrorRepresented(rushSession, error)) { const diagnostic: IRushDiagnostic = createRushDiagnostic('RUSH_COMMAND_FAILED', { parameters: { commandName: { @@ -597,9 +609,13 @@ export class RushCommandLineParser extends CommandLineParser { } } }); - this._commandLifecycleEmitter?.emitDiagnostic(diagnostic); + emitter.emitDiagnostic(diagnostic); _correlateRushSessionError(rushSession, error, diagnostic.diagnosticId); } + } + + private _reportErrorAndSetExitCode(error: Error): void { + this._emitReporterFailureDiagnostic(error); if (!(error instanceof AlreadyReportedError)) { const prefix: string = 'ERROR: '; @@ -680,7 +696,7 @@ export class RushCommandLineParser extends CommandLineParser { } private _emitReporterCompletion(exitCode: number): void { - if (this._reporterCompletionEmitted) { + if (!this._sessionLifecycleEmitter || this._reporterCompletionEmitted) { return; } this._reporterCompletionEmitted = true; diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts new file mode 100644 index 00000000000..774265bc22b --- /dev/null +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { JsonFile } from '@rushstack/node-core-library'; +import type { IReporterEmitEventInput, IReporterEventSink } from '@rushstack/rush-reporter'; + +import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; +import type { IRushConfigurationJson } from '../../api/RushConfiguration'; +import { + _getRushSessionDerivedExitStatus, + _isRushSessionErrorRepresented +} from '../../pluginFramework/RushSession'; +import { RushCommandLineParser } from '../RushCommandLineParser'; + +class CapturingReporterSink implements IReporterEventSink { + public readonly events: IReporterEmitEventInput[] = []; + + public emit(event: IReporterEmitEventInput): string { + this.events.push(event); + return `event-${this.events.length}`; + } +} + +function isCompletion(event: IReporterEmitEventInput): boolean { + return ( + event.type === 'commandResult' || event.type === 'commandCompleted' || event.type === 'sessionCompleted' + ); +} + +describe('RushCommandLineParser reporter lifecycle', () => { + const temporaryFolders: string[] = []; + let originalExitCode: string | number | undefined; + let originalArgv: string[]; + let stdoutSpy: jest.SpyInstance; + let stderrSpy: jest.SpyInstance; + + async function copyRepositoryAsync(): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-reporter-lifecycle-')); + temporaryFolders.push(directory); + const repoPath: string = path.join(directory, 'repo'); + await fs.promises.cp(path.join(__dirname, 'basicAndRunBuildActionRepo'), repoPath, { recursive: true }); + return repoPath; + } + + beforeEach(() => { + originalExitCode = process.exitCode; + originalArgv = process.argv; + process.exitCode = undefined; + process.argv = ['node', 'rush', 'custom-output']; + EnvironmentConfiguration.reset(); + stdoutSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + stderrSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(async () => { + await Promise.all( + temporaryFolders + .splice(0) + .map((directory) => fs.promises.rm(directory, { recursive: true, force: true })) + ); + process.exitCode = originalExitCode; + process.argv = originalArgv; + EnvironmentConfiguration.reset(); + jest.restoreAllMocks(); + }); + + it.each([ + { file: 'rush.json', withClose: false }, + { file: 'rush.json', withClose: true }, + { file: 'common/config/rush/command-line.json', withClose: false }, + { file: 'common/config/rush/command-line.json', withClose: true } + ])('reports invalid $file before fatal exit (close callback: $withClose)', async ({ file, withClose }) => { + const repoPath: string = await copyRepositoryAsync(); + await fs.promises.writeFile(path.join(repoPath, file), '{'); + const visibleOutput: unknown[] = []; + + for (const reporting of [false, true]) { + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + jest.clearAllMocks(); + const sink: CapturingReporterSink = new CapturingReporterSink(); + let eventsAtExit: readonly IReporterEmitEventInput[] = []; + let eventsAtClose: readonly IReporterEmitEventInput[] = []; + const exitSpy: jest.SpyInstance = jest.spyOn(process, 'exit').mockImplementation(() => { + eventsAtExit = [...sink.events]; + return undefined as never; + }); + const closeAsync: jest.Mock, []> = jest.fn(async () => { + eventsAtClose = [...sink.events]; + }); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'initialization-failure' } : undefined, + reporterCloseAsync: withClose ? closeAsync : undefined + }); + + if (!withClose) { + expect(exitSpy).toHaveBeenCalledWith(1); + } + await expect(parser.executeAsync(['custom-output'])).resolves.toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + + expect(process.exitCode).toBe(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(closeAsync).toHaveBeenCalledTimes(withClose ? 1 : 0); + expect(sink.events.map(({ type }) => type)).toEqual( + reporting ? ['sessionStarted', 'diagnosticEmitted', 'sessionCompleted'] : [] + ); + expect(eventsAtExit).toEqual(sink.events); + if (withClose) { + expect(eventsAtClose).toEqual(sink.events); + } + if (reporting) { + expect(sink.events[1].payload).toMatchObject({ + code: 'RUSH_COMMAND_FAILED', + diagnosticId: expect.any(String) + }); + expect(sink.events[2].payload).toMatchObject({ exitCode: 1 }); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual({ + exitCode: 1, + outcome: 'failed' + }); + } + visibleOutput.push({ + stdout: stdoutSpy.mock.calls.map((args) => [...args]), + stderr: stderrSpy.mock.calls.map((args) => [...args]) + }); + exitSpy.mockRestore(); + } + + expect(visibleOutput[1]).toEqual(visibleOutput[0]); + }); + + it('emits and correlates a session diagnostic when plugin initialization fails before action selection', async () => { + const repoPath: string = await copyRepositoryAsync(); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: { eventSink: sink, sessionId: 'plugin-initialization-failure' }, + reporterCloseAsync: closeAsync + }); + const error: Error = new Error('plugin initialization failed'); + jest.spyOn(parser.pluginManager, 'tryInitializeUnassociatedPluginsAsync').mockRejectedValue(error); + + await expect(parser.executeAsync(['custom-output'])).resolves.toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sink.events.map(({ type }) => type)).toEqual([ + 'sessionStarted', + 'diagnosticEmitted', + 'sessionCompleted' + ]); + expect(sink.events[1].scope?.commandName).toBeUndefined(); + expect(_isRushSessionErrorRepresented(parser.rushSession, error)).toBe(true); + expect(sink.events[2].payload).toMatchObject({ exitCode: 1 }); + expect(closeAsync).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it.each([false, true])('awaits a real delayed public telemetry hook (reject: %s)', async (reject) => { + const visibleErrors: unknown[] = []; + for (const reporting of [false, true]) { + process.exitCode = undefined; + EnvironmentConfiguration.reset(); + jest.clearAllMocks(); + const repoPath: string = await copyRepositoryAsync(); + const rushJsonPath: string = path.join(repoPath, 'rush.json'); + const rushJson: IRushConfigurationJson = JsonFile.load(rushJsonPath); + rushJson.telemetryEnabled = true; + JsonFile.save(rushJson, rushJsonPath); + const sink: CapturingReporterSink = new CapturingReporterSink(); + const closeAsync: jest.Mock, []> = jest.fn(async () => undefined); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: repoPath, + reporter: reporting ? { eventSink: sink, sessionId: 'telemetry-finalization' } : undefined, + reporterCloseAsync: reporting ? closeAsync : undefined + }); + let markHookStarted: (() => void) | undefined; + const hookStarted: Promise = new Promise((resolve) => { + markHookStarted = resolve; + }); + let releaseHook: (() => void) | undefined; + const hookReleased: Promise = new Promise((resolve) => { + releaseHook = resolve; + }); + const failure: Error = new Error('delayed telemetry flush failed'); + const flushTelemetry: jest.Mock, []> = jest.fn(async () => { + markHookStarted!(); + await hookReleased; + if (reject) { + throw failure; + } + }); + parser.rushSession.hooks.flushTelemetry.tapPromise('DelayedTelemetry', flushTelemetry); + + const execution: Promise = parser.executeAsync(['custom-output', '--reporter=junit']); + await hookStarted; + await new Promise((resolve) => setImmediate(resolve)); + const prematureCompletions: IReporterEmitEventInput[] = sink.events.filter(isCompletion); + releaseHook!(); + const succeeded: boolean = await execution; + + expect(JsonFile.load(path.join(repoPath, 'custom-output-args.json'))).toEqual(['--reporter', 'junit']); + expect(prematureCompletions).toEqual([]); + expect(succeeded).toBe(!reject); + expect(process.exitCode).toBe(reject ? 1 : 0); + expect(exitSpy).not.toHaveBeenCalled(); + expect(flushTelemetry).toHaveBeenCalledTimes(1); + expect(closeAsync).toHaveBeenCalledTimes(reporting ? 1 : 0); + if (reporting) { + const completions: IReporterEmitEventInput[] = sink.events.filter(isCompletion); + expect(completions.map(({ type }) => type)).toEqual([ + 'commandResult', + 'commandCompleted', + 'sessionCompleted' + ]); + for (const event of completions) { + expect(event.payload).toMatchObject({ exitCode: reject ? 1 : 0 }); + } + expect(completions[0].payload).toMatchObject({ succeeded: !reject }); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual({ + exitCode: reject ? 1 : 0, + outcome: reject ? 'failed' : 'succeeded' + }); + expect(_isRushSessionErrorRepresented(parser.rushSession, failure)).toBe(reject); + expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toHaveLength(reject ? 1 : 0); + } else { + expect(sink.events).toEqual([]); + } + visibleErrors.push(stderrSpy.mock.calls.map((args) => [...args])); + exitSpy.mockRestore(); + } + + expect(visibleErrors[1]).toEqual(visibleErrors[0]); + }); +}); diff --git a/specs/2026-07-12-rush-reporter-overhaul.md b/specs/2026-07-12-rush-reporter-overhaul.md index ed7bf36bcca..91ff006d264 100644 --- a/specs/2026-07-12-rush-reporter-overhaul.md +++ b/specs/2026-07-12-rush-reporter-overhaul.md @@ -335,6 +335,12 @@ required parent/wire reporter is fatal. Failure to create the full-detail file at both repository and OS-temp paths is nonfatal but emits an emergency warning and marks the artifact unavailable. +The engine's root reporting context is available before fallible repository +initialization. Failures before command selection emit a session-scoped +diagnostic and failure completion before reporter close. Successful command +completion is published only after command finalization, including the public +telemetry flush hooks, so reporter results retain the native exit outcome. + ### 5.5 Bootstrap and Wire Protocol `install-run-rush` performs a minimal prelude: