Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-reporter",
"comment": "Prevent non-public reporter events from contributing unvalidated or unbounded values to telemetry aggregates, and protect parent-owned producer and protocol metadata.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-reporter",
"email": "TheLarkInn@users.noreply.github.com"
}
4 changes: 4 additions & 0 deletions common/reviews/api/rush-reporter.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,10 @@ export interface IReporterPerformanceBudgets {
readonly maxAiDetailedDiagnostics: number;
readonly maxAiOutputBytes: number;
readonly maxInteractiveRefreshHz: number;
readonly maxTelemetryDiagnosticCategories: number;
readonly maxTelemetryDiagnosticCodes: number;
readonly maxTelemetryProducerVersionLength: number;
readonly maxTelemetryProducerVersions: number;
readonly maxWallTimeRegressionPercent: number;
}

Expand Down
30 changes: 29 additions & 1 deletion libraries/reporter/src/perf/PerformanceBudgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ export interface IReporterPerformanceBudgets {
* before summarizing the remainder. Defaults to `20`.
*/
readonly maxAiDetailedDiagnostics: number;

/**
* The maximum number of distinct diagnostic codes retained in a telemetry
* aggregate. Defaults to `20`.
*/
readonly maxTelemetryDiagnosticCodes: number;

/**
* The maximum number of diagnostic category buckets retained in a telemetry
* aggregate. Defaults to `20`.
*/
readonly maxTelemetryDiagnosticCategories: number;

/**
* The maximum number of distinct producer versions retained in a telemetry
* aggregate. Defaults to `20`.
*/
readonly maxTelemetryProducerVersions: number;

/**
* The maximum character length of one `packageName@packageVersion` telemetry
* entry. Longer entries are omitted. Defaults to `256`.
*/
readonly maxTelemetryProducerVersionLength: number;
}

/**
Expand All @@ -68,7 +92,11 @@ export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets = {
maxAdditionalPeakMemoryBytes: 32 * BYTES_PER_MIB,
maxInteractiveRefreshHz: 10,
maxAiOutputBytes: 64 * BYTES_PER_KIB,
maxAiDetailedDiagnostics: 20
maxAiDetailedDiagnostics: 20,
maxTelemetryDiagnosticCodes: 20,
maxTelemetryDiagnosticCategories: 20,
maxTelemetryProducerVersions: 20,
maxTelemetryProducerVersionLength: 256
};

/**
Expand Down
9 changes: 8 additions & 1 deletion libraries/reporter/src/telemetry/TelemetryAggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ export interface ITelemetryAggregate {
readonly protocolVersion?: IReporterProtocolVersion;

/**
* The distinct `packageName@packageVersion` producers observed, sorted.
* The distinct `packageName@packageVersion` producers observed on effectively
* public envelopes, sorted.
*
* @remarks
* The list is bounded by the reporter telemetry budgets. Parent-session
* producers are retained before child-session producers, remaining entries
* are selected lexicographically, and entries over the per-entry length
* budget are omitted. Package namespace text does not confer priority.
*/
readonly producerVersions: readonly string[];
}
Expand Down
213 changes: 186 additions & 27 deletions libraries/reporter/src/telemetry/TelemetrySubscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,107 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion
import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope';
import type { IReporter } from '../manager/IReporter';
import type { IOperationStatusChangedPayload } from '../lifecycle/LifecycleEvents';
import {
isValidRushDiagnosticCode,
RUSH_DIAGNOSTIC_CODE_DEFINITIONS,
type IRushDiagnosticCodeDefinition
} from '../diagnostics/RushDiagnosticCodeRegistry';
import { REPORTER_PERFORMANCE_BUDGETS } from '../perf/PerformanceBudgets';
import type { ITelemetryAggregate, TelemetryResult } from './TelemetryAggregate';

const OTHER_DIAGNOSTIC_CATEGORY: 'other' = 'other';
const REGISTERED_DIAGNOSTIC_CODE_DEFINITIONS: ReadonlyMap<string, IRushDiagnosticCodeDefinition> = new Map(
RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map(
(definition: IRushDiagnosticCodeDefinition): readonly [string, IRushDiagnosticCodeDefinition] => [
definition.code,
definition
]
)
);
const KNOWN_DIAGNOSTIC_CATEGORIES: ReadonlySet<string> = new Set(
RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map(
(definition: IRushDiagnosticCodeDefinition): string => definition.category
)
);

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isDiagnosticPayloadEffectivelyPublic(payload: unknown): boolean {
if (!isRecord(payload)) {
return false;
}
const parameters: unknown = payload.parameters;
if (parameters === undefined) {
return true;
}
if (!isRecord(parameters)) {
return false;
}
for (const parameter of Object.values(parameters)) {
if (!isRecord(parameter) || parameter.privacy !== 'public') {
return false;
}
}
return true;
}

function comparePrioritizedCandidates(
left: readonly [value: string, preferred: boolean],
right: readonly [value: string, preferred: boolean]
): number {
if (left[1] !== right[1]) {
return left[1] ? -1 : 1;
}
return left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0;
}

function recordBoundedPrioritizedValue(
values: Map<string, boolean>,
value: string,
preferred: boolean,
maximumCount: number
): void {
const existingPriority: boolean | undefined = values.get(value);
if (existingPriority !== undefined) {
if (preferred && !existingPriority) {
values.set(value, true);
}
return;
}

if (values.size < maximumCount) {
values.set(value, preferred);
return;
}

let worstCandidate: readonly [value: string, preferred: boolean] | undefined;
for (const candidate of values) {
if (worstCandidate === undefined || comparePrioritizedCandidates(candidate, worstCandidate) > 0) {
worstCandidate = candidate;
}
}

const newCandidate: readonly [value: string, preferred: boolean] = [value, preferred];
if (worstCandidate !== undefined && comparePrioritizedCandidates(newCandidate, worstCandidate) < 0) {
values.delete(worstCandidate[0]);
values.set(value, preferred);
}
}

/**
* Consumes canonical events and produces the allowlisted telemetry aggregate.
*
* @remarks
* The subscriber runs before reporter filtering, so it observes every event. It
* extracts only allowlisted values: from a diagnostic it keeps the code and
* category but never the parameters, remediation, or templates; it ignores
* messages, raw external output, and command arguments entirely.
* projects envelope metadata and lifecycle values only from effectively public
* events. A diagnostic containing any non-public parameter is treated as
* non-public even when its envelope floor is `public`. From a non-public
* diagnostic it keeps only a registered code and that code's registry category,
* never parameters, remediation, or templates. It ignores all other values from
* non-public events, messages, raw external output, and command arguments
* entirely.
*
* @beta
*/
Expand All @@ -27,14 +118,14 @@ export class TelemetrySubscriber {
private _protocolVersion: IReporterProtocolVersion | undefined;
private readonly _operationStatuses: Map<string, IOperationStatusChangedPayload['status']>;
private readonly _diagnosticCategoryCounts: { [category: string]: number };
private readonly _diagnosticCodes: Set<string>;
private readonly _producerVersions: Set<string>;
private readonly _diagnosticCodes: Map<string, boolean>;
private readonly _producerVersions: Map<string, boolean>;

public constructor() {
this._operationStatuses = new Map();
this._diagnosticCategoryCounts = {};
this._diagnosticCodes = new Set();
this._producerVersions = new Set();
this._diagnosticCodes = new Map();
this._producerVersions = new Map();
}

/**
Expand All @@ -48,8 +139,47 @@ export class TelemetrySubscriber {
* Ingests one event, extracting only allowlisted values.
*/
public ingest(event: IReporterEventEnvelope<unknown>): void {
this._protocolVersion = event.protocolVersion;
this._producerVersions.add(`${event.source.packageName}@${event.source.packageVersion}`);
const isEffectivelyPublicEnvelope: boolean =
event.privacy === 'public' &&
(event.type !== 'diagnosticEmitted' || isDiagnosticPayloadEffectivelyPublic(event.payload));
if (isEffectivelyPublicEnvelope) {
if (event.parentSessionId === undefined) {
this._protocolVersion = event.protocolVersion;
}
this._recordProducerVersion(
event.source.packageName,
event.source.packageVersion,
event.parentSessionId === undefined
);
}

if (event.type === 'diagnosticEmitted') {
// Code and category are public schema fields even when classified
// parameters make the diagnostic envelope non-public.
const payload: { code?: unknown; category?: unknown } = isRecord(event.payload) ? event.payload : {};
const registeredDefinition: IRushDiagnosticCodeDefinition | undefined =
typeof payload.code === 'string'
? REGISTERED_DIAGNOSTIC_CODE_DEFINITIONS.get(payload.code)
: undefined;
if (isEffectivelyPublicEnvelope) {
if (typeof payload.code === 'string' && isValidRushDiagnosticCode(payload.code)) {
this._recordDiagnosticCode(payload.code, registeredDefinition !== undefined);
}
if (typeof payload.category === 'string') {
this._recordDiagnosticCategory(
KNOWN_DIAGNOSTIC_CATEGORIES.has(payload.category) ? payload.category : OTHER_DIAGNOSTIC_CATEGORY
);
}
} else if (registeredDefinition !== undefined) {
this._recordDiagnosticCode(registeredDefinition.code, true);
this._recordDiagnosticCategory(registeredDefinition.category);
}
return;
}

if (!isEffectivelyPublicEnvelope) {
return;
}

switch (event.type) {
case 'commandStarted': {
Expand Down Expand Up @@ -114,21 +244,6 @@ export class TelemetrySubscriber {
this._operationStatuses.set(payload.operationId, payload.status);
break;
}
case 'diagnosticEmitted': {
// Keeps only the code and category, never parameters, remediation, or templates.
const payload: { code?: string; category?: string } = event.payload as {
code?: string;
category?: string;
};
if (payload.code !== undefined) {
this._diagnosticCodes.add(payload.code);
}
if (payload.category !== undefined) {
this._diagnosticCategoryCounts[payload.category] =
(this._diagnosticCategoryCounts[payload.category] ?? 0) + 1;
}
break;
}
default: {
// Messages, raw external output, artifacts, and extension events are not
// telemetry.
Expand All @@ -145,6 +260,10 @@ export class TelemetrySubscriber {
for (const status of this._operationStatuses.values()) {
operationStatusCounts[status] = (operationStatusCounts[status] ?? 0) + 1;
}
const diagnosticCategoryCounts: { [category: string]: number } = {};
for (const category of Object.keys(this._diagnosticCategoryCounts).sort()) {
diagnosticCategoryCounts[category] = this._diagnosticCategoryCounts[category];
}

const aggregate: {
commandName?: string;
Expand All @@ -159,9 +278,9 @@ export class TelemetrySubscriber {
producerVersions: string[];
} = {
operationStatusCounts,
diagnosticCodes: [...this._diagnosticCodes].sort(),
diagnosticCategoryCounts: { ...this._diagnosticCategoryCounts },
producerVersions: [...this._producerVersions].sort()
diagnosticCodes: [...this._diagnosticCodes.keys()].sort(),
diagnosticCategoryCounts,
producerVersions: [...this._producerVersions.keys()].sort()
};

if (this._commandName !== undefined) {
Expand All @@ -185,6 +304,46 @@ export class TelemetrySubscriber {

return aggregate;
}

private _recordDiagnosticCode(code: string, registered: boolean): void {
recordBoundedPrioritizedValue(
this._diagnosticCodes,
code,
registered,
REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes
);
}

private _recordDiagnosticCategory(category: string): void {
const existingCount: number | undefined = this._diagnosticCategoryCounts[category];
if (existingCount !== undefined) {
this._diagnosticCategoryCounts[category] = existingCount + 1;
return;
}
if (
Object.keys(this._diagnosticCategoryCounts).length <
REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCategories
) {
this._diagnosticCategoryCounts[category] = 1;
}
}

private _recordProducerVersion(
packageName: string,
packageVersion: string,
isParentSessionProducer: boolean
): void {
const producerVersion: string = `${packageName}@${packageVersion}`;
if (producerVersion.length > REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength) {
return;
}
recordBoundedPrioritizedValue(
this._producerVersions,
producerVersion,
isParentSessionProducer,
REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions
);
}
}

/**
Expand Down
4 changes: 4 additions & 0 deletions libraries/reporter/src/test/Performance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ describe('reporter performance budgets', () => {
expect(REPORTER_PERFORMANCE_BUDGETS.maxInteractiveRefreshHz).toBe(10);
expect(REPORTER_PERFORMANCE_BUDGETS.maxAiOutputBytes).toBe(64 * 1024);
expect(REPORTER_PERFORMANCE_BUDGETS.maxAiDetailedDiagnostics).toBe(20);
expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes).toBe(20);
expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCategories).toBe(20);
expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions).toBe(20);
expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength).toBe(256);
});

it('evaluates wall-time regression against the 3 percent budget', () => {
Expand Down
Loading