Skip to content

Commit fefabfc

Browse files
TheLarkInnCopilot
andcommitted
Fix reporter self-review findings
Preserve legacy telemetry hook output without mutating the allowlisted aggregate, keep root session telemetry authoritative, retain inherited lifecycle scope, and make shadow result summaries internally consistent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent afcd38d commit fefabfc

7 files changed

Lines changed: 155 additions & 31 deletions

File tree

common/reviews/api/rush-reporter.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export type BootstrapPrivacyClassification = 'public' | 'local-sensitive' | 'sec
3939
export function computeEnvelopePrivacyFloor(classifications: Iterable<ReporterPrivacyClassification>): ReporterPrivacyClassification;
4040

4141
// @beta
42-
export function createBeforeLogAdapter(hooks: readonly LegacyBeforeLogHook[]): (aggregate: ITelemetryAggregate) => void;
42+
export function createBeforeLogAdapter(hooks: readonly LegacyBeforeLogHook[]): (aggregate: ITelemetryAggregate) => Record<string, unknown>;
4343

4444
// @beta
4545
export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution;

libraries/reporter/src/lifecycle/LifecycleEmitter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ export class LifecycleEmitter {
9797
public emitOperationRegistered(payload: IOperationRegisteredPayload): string {
9898
return this._emit('operationRegistered', payload, 'public', {
9999
operationId: payload.operationId,
100-
projectName: payload.projectName,
101-
phaseName: payload.phaseName
100+
...(payload.projectName === undefined ? {} : { projectName: payload.projectName }),
101+
...(payload.phaseName === undefined ? {} : { phaseName: payload.phaseName })
102102
});
103103
}
104104

libraries/reporter/src/lifecycle/ShadowParity.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,27 @@ export interface IShadowResultSummary {
4646
* @beta
4747
*/
4848
export function deriveExitCodeFromEvents(events: readonly IReporterEventEnvelope<unknown>[]): number {
49+
let commandResult: ICommandResultPayload | undefined;
4950
for (const event of events) {
5051
if (event.type === 'commandResult') {
51-
const payload: ICommandResultPayload = event.payload as ICommandResultPayload;
52-
if (payload.succeeded) {
53-
return 0;
54-
}
55-
return payload.exitCode !== 0 ? payload.exitCode : 1;
52+
commandResult = event.payload as ICommandResultPayload;
53+
}
54+
}
55+
if (commandResult !== undefined) {
56+
if (commandResult.succeeded) {
57+
return 0;
5658
}
59+
return commandResult.exitCode !== 0 ? commandResult.exitCode : 1;
5760
}
5861

62+
let sessionExitCode: number | undefined;
5963
for (const event of events) {
6064
if (event.type === 'sessionCompleted') {
61-
return (event.payload as { exitCode: number }).exitCode;
65+
sessionExitCode = (event.payload as { exitCode: number }).exitCode;
6266
}
6367
}
6468

65-
return 0;
69+
return sessionExitCode ?? 0;
6670
}
6771

6872
/**
@@ -81,7 +85,7 @@ export function summarizeShadowResult(
8185
): IShadowResultSummary {
8286
const operationCounts: { [status: string]: number } = {};
8387
let commandName: string | undefined;
84-
let succeeded: boolean = true;
88+
let commandSucceeded: boolean | undefined;
8589

8690
for (const event of events) {
8791
if (event.type === 'operationStatusChanged') {
@@ -90,14 +94,15 @@ export function summarizeShadowResult(
9094
} else if (event.type === 'commandResult') {
9195
const payload: ICommandResultPayload = event.payload as ICommandResultPayload;
9296
commandName = payload.commandName;
93-
succeeded = payload.succeeded;
97+
commandSucceeded = payload.succeeded;
9498
}
9599
}
96100

101+
const exitCode: number = deriveExitCodeFromEvents(events);
97102
return {
98103
commandName,
99-
succeeded,
100-
exitCode: deriveExitCodeFromEvents(events),
104+
succeeded: commandSucceeded ?? exitCode === 0,
105+
exitCode,
101106
operationCounts
102107
};
103108
}

libraries/reporter/src/telemetry/BeforeLogAdapter.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,32 @@ export type LegacyBeforeLogHook = (telemetry: Record<string, unknown>) => void;
1919
*
2020
* @remarks
2121
* During migration the existing `beforeLog` hook is preserved: the adapter runs
22-
* each legacy hook with a plain-object copy of the new aggregate, so no hook
23-
* observes non-allowlisted data.
22+
* each legacy hook with a deep plain-object copy of the new aggregate, so no
23+
* hook observes non-allowlisted source data or mutates the allowlisted
24+
* aggregate. The returned record preserves hook augmentations for the legacy
25+
* telemetry writer.
2426
*
2527
* @param hooks - the legacy hooks to preserve
2628
*
2729
* @beta
2830
*/
2931
export function createBeforeLogAdapter(
3032
hooks: readonly LegacyBeforeLogHook[]
31-
): (aggregate: ITelemetryAggregate) => void {
32-
return (aggregate: ITelemetryAggregate): void => {
33-
const record: Record<string, unknown> = { ...aggregate };
33+
): (aggregate: ITelemetryAggregate) => Record<string, unknown> {
34+
return (aggregate: ITelemetryAggregate): Record<string, unknown> => {
35+
const record: Record<string, unknown> = {
36+
...aggregate,
37+
operationStatusCounts: { ...aggregate.operationStatusCounts },
38+
diagnosticCodes: [...aggregate.diagnosticCodes],
39+
diagnosticCategoryCounts: { ...aggregate.diagnosticCategoryCounts },
40+
producerVersions: [...aggregate.producerVersions],
41+
...(aggregate.protocolVersion === undefined
42+
? {}
43+
: { protocolVersion: { ...aggregate.protocolVersion } })
44+
};
3445
for (const hook of hooks) {
3546
hook(record);
3647
}
48+
return record;
3749
};
3850
}

libraries/reporter/src/telemetry/TelemetrySubscriber.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,17 @@ export class TelemetrySubscriber {
5252

5353
switch (event.type) {
5454
case 'commandStarted': {
55+
if (event.parentSessionId !== undefined) {
56+
break;
57+
}
5558
// Deliberately ignores argv.
5659
this._commandName = (event.payload as { commandName: string }).commandName;
5760
break;
5861
}
5962
case 'commandResult': {
63+
if (event.parentSessionId !== undefined) {
64+
break;
65+
}
6066
const payload: { commandName: string; succeeded: boolean; exitCode: number } = event.payload as {
6167
commandName: string;
6268
succeeded: boolean;
@@ -68,20 +74,25 @@ export class TelemetrySubscriber {
6874
break;
6975
}
7076
case 'commandCompleted': {
77+
if (event.parentSessionId !== undefined) {
78+
break;
79+
}
7180
const payload: { durationMs?: number } = event.payload as { durationMs?: number };
7281
if (payload.durationMs !== undefined) {
7382
this._durationMs = payload.durationMs;
7483
}
7584
break;
7685
}
7786
case 'sessionCompleted': {
87+
if (event.parentSessionId !== undefined) {
88+
break;
89+
}
7890
const payload: { exitCode: number; durationMs?: number } = event.payload as {
7991
exitCode: number;
8092
durationMs?: number;
8193
};
82-
if (this._exitCode === undefined) {
83-
this._exitCode = payload.exitCode;
84-
}
94+
this._exitCode = payload.exitCode;
95+
this._result = payload.exitCode === 0 ? 'succeeded' : 'failed';
8596
if (payload.durationMs !== undefined) {
8697
this._durationMs = payload.durationMs;
8798
}

libraries/reporter/src/test/Lifecycle.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,25 @@ describe('LifecycleEmitter', () => {
7474
});
7575
});
7676

77+
it('preserves inherited scope when operation fields are omitted', () => {
78+
const sink: CapturingSink = new CapturingSink();
79+
const emitter: LifecycleEmitter = new LifecycleEmitter({
80+
sink,
81+
sessionId: 'sess',
82+
source: SOURCE,
83+
scope: { commandName: 'build', projectName: 'p', phaseName: '_phase:build' }
84+
});
85+
86+
emitter.emitOperationRegistered({ operationId: 'op1' });
87+
88+
expect(sink.inputs[0].scope).toEqual({
89+
commandName: 'build',
90+
operationId: 'op1',
91+
projectName: 'p',
92+
phaseName: '_phase:build'
93+
});
94+
});
95+
7796
it('emits diagnostics on the diagnosticEmitted channel with the privacy floor', () => {
7897
const sink: CapturingSink = new CapturingSink();
7998
const emitter: LifecycleEmitter = new LifecycleEmitter({ sink, sessionId: 'sess', source: SOURCE });
@@ -149,6 +168,30 @@ describe('summarizeShadowResult', () => {
149168
expect(summary.exitCode).toBe(1);
150169
expect(summary.operationCounts).toEqual({ success: 2, fromCache: 1, failure: 1 });
151170
});
171+
172+
it('derives failure from the session exit code when no command result exists', () => {
173+
const summary: IShadowResultSummary = summarizeShadowResult([
174+
ev('operationStatusChanged', { operationId: 'a', status: 'failure' }),
175+
ev('sessionCompleted', { exitCode: 1 })
176+
]);
177+
178+
expect(summary.succeeded).toBe(false);
179+
expect(summary.exitCode).toBe(1);
180+
});
181+
182+
it('uses the final command result consistently', () => {
183+
const events: IReporterEventEnvelope<unknown>[] = [
184+
ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }),
185+
ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })
186+
];
187+
188+
expect(deriveExitCodeFromEvents(events)).toBe(0);
189+
expect(summarizeShadowResult(events)).toMatchObject({
190+
commandName: 'build',
191+
succeeded: true,
192+
exitCode: 0
193+
});
194+
});
152195
});
153196

154197
describe('shadow emission parity through the manager', () => {

libraries/reporter/src/test/Telemetry.test.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,63 @@ describe('TelemetrySubscriber', () => {
157157
expect(TELEMETRY_AGGREGATE_KEYS).toContain(key);
158158
}
159159
});
160+
161+
it('uses the root session completion as the final process result', async () => {
162+
const telemetry: TelemetrySubscriber = new TelemetrySubscriber();
163+
const manager: ReporterManager = new ReporterManager();
164+
manager.addReporter(createTelemetryReporter(telemetry));
165+
await manager.initializeAsync();
166+
167+
manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 }));
168+
manager.emit(rawInput('sessionCompleted', { exitCode: 1, durationMs: 2000 }));
169+
await manager.flushAsync();
170+
171+
expect(telemetry.buildAggregate()).toMatchObject({
172+
commandName: 'build',
173+
result: 'failed',
174+
exitCode: 1,
175+
durationMs: 2000
176+
});
177+
});
178+
179+
it('does not let child session lifecycle events overwrite root command state', async () => {
180+
const telemetry: TelemetrySubscriber = new TelemetrySubscriber();
181+
const manager: ReporterManager = new ReporterManager();
182+
manager.addReporter(createTelemetryReporter(telemetry));
183+
await manager.initializeAsync();
184+
185+
manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }));
186+
manager.emit(rawInput('sessionCompleted', { exitCode: 1, durationMs: 2000 }));
187+
manager.emit({
188+
...rawInput('commandResult', { commandName: 'child-command', succeeded: true, exitCode: 0 }),
189+
sessionId: 'child',
190+
parentSessionId: 'sess'
191+
});
192+
manager.emit({
193+
...rawInput('sessionCompleted', { exitCode: 0, durationMs: 25 }),
194+
sessionId: 'child',
195+
parentSessionId: 'sess'
196+
});
197+
await manager.flushAsync();
198+
199+
expect(telemetry.buildAggregate()).toMatchObject({
200+
commandName: 'build',
201+
result: 'failed',
202+
exitCode: 1,
203+
durationMs: 2000
204+
});
205+
});
160206
});
161207

162208
describe('createBeforeLogAdapter', () => {
163-
it('runs legacy hooks with a plain copy of the aggregate', () => {
164-
const observed: Record<string, unknown>[] = [];
209+
it('returns hook augmentations without mutating the allowlisted aggregate', () => {
165210
const hook: LegacyBeforeLogHook = (telemetry: Record<string, unknown>) => {
166-
observed.push(telemetry);
211+
telemetry.result = 'failed';
212+
telemetry.customField = 'custom-value';
213+
(telemetry.operationStatusCounts as Record<string, number>).injected = 99;
167214
};
168-
const adapter: (aggregate: ITelemetryAggregate) => void = createBeforeLogAdapter([hook]);
215+
const adapter: (aggregate: ITelemetryAggregate) => Record<string, unknown> =
216+
createBeforeLogAdapter([hook]);
169217

170218
const aggregate: ITelemetryAggregate = {
171219
commandName: 'build',
@@ -176,11 +224,16 @@ describe('createBeforeLogAdapter', () => {
176224
diagnosticCategoryCounts: {},
177225
producerVersions: ['@microsoft/rush-lib@5.177.2']
178226
};
179-
adapter(aggregate);
227+
const record: Record<string, unknown> = adapter(aggregate);
180228

181-
expect(observed).toHaveLength(1);
182-
expect(observed[0]).toEqual({ ...aggregate });
183-
// The hook receives a copy, not the aggregate itself.
184-
expect(observed[0]).not.toBe(aggregate);
229+
expect(record).toMatchObject({
230+
result: 'failed',
231+
customField: 'custom-value',
232+
operationStatusCounts: { success: 2, injected: 99 }
233+
});
234+
expect(aggregate.result).toBe('succeeded');
235+
expect(aggregate.operationStatusCounts).toEqual({ success: 2 });
236+
expect(record).not.toBe(aggregate);
237+
expect(record.operationStatusCounts).not.toBe(aggregate.operationStatusCounts);
185238
});
186239
});

0 commit comments

Comments
 (0)