From 2f9ac7d90e365143c0b33da2e8dd9782fda99e35 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 04:15:43 +0000 Subject: [PATCH] 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 | 128 ++++++- .../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, 1191 insertions(+), 46 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 bf52161bba6..66da397e0c6 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'; @@ -1042,6 +1043,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 7f22adc16b6..ee5f79c8ee5 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`. @@ -90,6 +98,12 @@ export class RushCommandLineParser extends CommandLineParser { private readonly _terminalProvider: ConsoleTerminalProvider; private readonly _terminal: Terminal; private readonly _autocreateBuildCommand: boolean; + private _sessionLifecycleEmitter: LifecycleEmitter | undefined; + private _commandLifecycleEmitter: LifecycleEmitter | undefined; + private _sessionStartTimeMs: number | undefined; + private _commandStartTimeMs: number | undefined; + private _reporterCompletionEmitted: boolean = false; + private _reporterClosePromise: Promise | undefined; /** * The current working directory that was used to find the Rush configuration. @@ -249,12 +263,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(); } @@ -272,6 +304,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(); @@ -312,6 +355,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); } @@ -529,6 +573,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: '; @@ -549,6 +607,7 @@ export class RushCommandLineParser extends CommandLineParser { console.error(`\n${error.stack}`); } + this._emitReporterCompletion(_getNumericProcessExitCode(1)); this.flushTelemetry(); const handleExit = (): never => { @@ -584,12 +643,67 @@ export class RushCommandLineParser extends CommandLineParser { } } - private async _closeReporterAsync(): Promise { - try { - await this._rushOptions.reporterCloseAsync?.(); - } catch (error) { - process.exitCode = 1; - process.stderr.write(`[reporter] Unable to finalize reporters: ${(error as Error).message}\n`); + private _closeReporterAsync(): Promise { + if (!this._reporterClosePromise) { + this._reporterClosePromise = (async (): Promise => { + try { + await this._rushOptions.reporterCloseAsync?.(); + } catch (error) { + process.exitCode = 1; + process.stderr.write(`[reporter] Unable to finalize reporters: ${(error as Error).message}\n`); + } + })(); + } + 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 7b79e36e081..4b7e38eb872 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -61,6 +61,7 @@ import { NodeDiagnosticDirPlugin } from '../../logic/operations/NodeDiagnosticDi import { IgnoredParametersPlugin } from '../../logic/operations/IgnoredParametersPlugin'; 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'; @@ -668,6 +669,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 b8113aad056..9ae8ddf06f8 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -111,4 +111,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; +}