diff --git a/package-lock.json b/package-lock.json index 2bf470508..5eb9ef8ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@salesforce/plugin-code-analyzer", - "version": "5.2.2", + "version": "5.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@salesforce/plugin-code-analyzer", - "version": "5.2.2", + "version": "5.3.0", "license": "BSD-3-Clause", "dependencies": { "@oclif/core": "3.27.0", "@salesforce/code-analyzer-core": "0.31.0", "@salesforce/code-analyzer-engine-api": "0.26.0", - "@salesforce/code-analyzer-eslint-engine": "0.27.0", + "@salesforce/code-analyzer-eslint-engine": "0.28.0", "@salesforce/code-analyzer-flow-engine": "0.24.0", "@salesforce/code-analyzer-pmd-engine": "0.28.0", "@salesforce/code-analyzer-regex-engine": "0.24.0", @@ -31,7 +31,7 @@ }, "devDependencies": { "@eslint/compat": "^1.3.1", - "@eslint/eslintrc": "^3.2.0", + "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.31.0", "@oclif/plugin-help": "^6.2.31", "@salesforce/cli-plugins-testkit": "^5.3.39", @@ -39,12 +39,12 @@ "@types/tmp": "^0.2.6", "eslint": "^9.31.0", "eslint-plugin-sf-plugin": "^1.20.27", - "jest": "^30.0.0", + "jest": "^30.0.4", "jest-junit": "^16.0.0", - "oclif": "^4.18.0", + "oclif": "^4.20.8", "tmp": "^0.2.3", "ts-jest": "^29.4.0", - "typescript": "^5.7.3", + "typescript": "^5.8.3", "typescript-eslint": "^8.37.0" }, "engines": { @@ -4594,9 +4594,9 @@ } }, "node_modules/@salesforce/code-analyzer-eslint-engine": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@salesforce/code-analyzer-eslint-engine/-/code-analyzer-eslint-engine-0.27.0.tgz", - "integrity": "sha512-o7gXDwBgvkyTqXgBdORshSX4nUGL82VIfOlOh6VSdrcx0DMJJPCzR4satGLpSVUh3IAFCeF/OcHQ64NXIpUNTQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@salesforce/code-analyzer-eslint-engine/-/code-analyzer-eslint-engine-0.28.0.tgz", + "integrity": "sha512-zuyz+UptPYD4qSqOCvE5bwUS2WWfXJn6hQT0U8JOJZiJh5xtKmYauvfsEuONd/1LOlteevarhR/6ZuVXwm1NUQ==", "license": "BSD-3-Clause", "dependencies": { "@eslint/js": "^9.31.0", diff --git a/package.json b/package.json index 570fd0ff7..b3a0b4e87 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "@salesforce/plugin-code-analyzer", "description": "Salesforce Code Analyzer is a unified tool to help Salesforce developers analyze their source code for security vulnerabilities, performance issues, best practices, and more.", - "version": "5.2.2", + "version": "5.3.0", "author": "Salesforce Code Analyzer Team", "bugs": "https://github.com/forcedotcom/code-analyzer/issues", "dependencies": { "@oclif/core": "3.27.0", "@salesforce/code-analyzer-core": "0.31.0", "@salesforce/code-analyzer-engine-api": "0.26.0", - "@salesforce/code-analyzer-eslint-engine": "0.27.0", + "@salesforce/code-analyzer-eslint-engine": "0.28.0", "@salesforce/code-analyzer-flow-engine": "0.24.0", "@salesforce/code-analyzer-pmd-engine": "0.28.0", "@salesforce/code-analyzer-regex-engine": "0.24.0", diff --git a/src/commands/code-analyzer/rules.ts b/src/commands/code-analyzer/rules.ts index 06308fcaa..8687abcad 100644 --- a/src/commands/code-analyzer/rules.ts +++ b/src/commands/code-analyzer/rules.ts @@ -10,6 +10,7 @@ import {Displayable, UxDisplay} from '../../lib/Display'; import {LogEventDisplayer} from '../../lib/listeners/LogEventListener'; import {RuleSelectionProgressSpinner} from '../../lib/listeners/ProgressEventListener'; import {CompositeRulesWriter} from '../../lib/writers/RulesWriter'; +import { SfCliTelemetryEmitter } from '../../lib/Telemetry'; export default class RulesCommand extends SfCommand implements Displayable { // We don't need the `--json` output for this command. @@ -86,6 +87,7 @@ export default class RulesCommand extends SfCommand implements Displayable pluginsFactory: new EnginePluginsFactoryImpl(), logEventListeners: [new LogEventDisplayer(uxDisplay)], progressListeners: [new RuleSelectionProgressSpinner(uxDisplay)], + telemetryEmitter: new SfCliTelemetryEmitter(), actionSummaryViewer: new RulesActionSummaryViewer(uxDisplay), viewer: this.createRulesViewer(view, outputFiles, uxDisplay), writer: CompositeRulesWriter.fromFiles(outputFiles) diff --git a/src/lib/actions/RulesAction.ts b/src/lib/actions/RulesAction.ts index 07cc38436..d72f9375d 100644 --- a/src/lib/actions/RulesAction.ts +++ b/src/lib/actions/RulesAction.ts @@ -8,12 +8,16 @@ import { RulesActionSummaryViewer } from '../viewers/ActionSummaryViewer'; import { RuleViewer } from '../viewers/RuleViewer'; import { LogFileWriter } from '../writers/LogWriter'; import { RulesWriter } from '../writers/RulesWriter'; +import { TelemetryEmitter } from '../Telemetry'; +import { TelemetryEventListener } from '../listeners/TelemetryEventListener'; +import * as Constants from '../../Constants'; export type RulesDependencies = { configFactory: CodeAnalyzerConfigFactory; pluginsFactory: EnginePluginsFactory; logEventListeners: LogEventListener[]; progressListeners: ProgressEventListener[]; + telemetryEmitter: TelemetryEmitter; actionSummaryViewer: RulesActionSummaryViewer, viewer: RuleViewer; writer: RulesWriter; @@ -45,6 +49,8 @@ export class RulesAction { // LogEventListeners should start listening as soon as the Core is instantiated, since Core can start emitting // events they listen for basically immediately. this.dependencies.logEventListeners.forEach(listener => listener.listen(core)); + const telemetryListener: TelemetryEventListener = new TelemetryEventListener(this.dependencies.telemetryEmitter); + telemetryListener.listen(core); const enginePlugins = this.dependencies.pluginsFactory.create(); const enginePluginModules = config.getCustomEnginePluginModules(); const addEnginePromises: Promise[] = [ @@ -60,10 +66,12 @@ export class RulesAction { // that's when progress events can start being emitted. this.dependencies.progressListeners.forEach(listener => listener.listen(core)); const ruleSelection: RuleSelection = await core.selectRules(input["rule-selector"], selectOptions); + this.emitEngineTelemetry(ruleSelection, enginePlugins.flatMap(p => p.getAvailableEngineNames())); // After Core is done running, the listeners need to be told to stop, since some of them have persistent UI elements // or file handlers that must be gracefully ended. this.dependencies.progressListeners.forEach(listener => listener.stopListening()); this.dependencies.logEventListeners.forEach(listener => listener.stopListening()); + telemetryListener.stopListening(); const rules: Rule[] = core.getEngineNames().flatMap(name => ruleSelection.getRulesFor(name)); this.dependencies.writer.write(ruleSelection) @@ -79,4 +87,18 @@ export class RulesAction { public static createAction(dependencies: RulesDependencies): RulesAction { return new RulesAction(dependencies); } + + private emitEngineTelemetry(ruleSelection: RuleSelection, coreEngineNames: string[]): void { + const selectedEngineNames: Set = new Set(ruleSelection.getEngineNames()); + for (const coreEngineName of coreEngineNames) { + if (!selectedEngineNames.has(coreEngineName)) { + continue; + } + this.dependencies.telemetryEmitter.emitTelemetry(Constants.TelemetrySource, Constants.TelemetryEventName, { + sfcaEvent: Constants.CliTelemetryEvents.ENGINE_SELECTION, + engine: coreEngineName, + ruleCount: ruleSelection.getRulesFor(coreEngineName).length + }); + } + } } diff --git a/test/lib/actions/RulesAction.test.ts b/test/lib/actions/RulesAction.test.ts index c0f8730b0..28fca4669 100644 --- a/test/lib/actions/RulesAction.test.ts +++ b/test/lib/actions/RulesAction.test.ts @@ -8,6 +8,7 @@ import { SpyRuleViewer } from '../../stubs/SpyRuleViewer'; import { SpyRuleWriter } from '../../stubs/SpyRuleWriter'; import { StubDefaultConfigFactory } from '../../stubs/StubCodeAnalyzerConfigFactories'; import * as StubEnginePluginFactories from '../../stubs/StubEnginePluginsFactories'; +import { CapturedTelemetryEmission, SpyTelemetryEmitter } from '../../stubs/SpyTelemetryEmitter'; const PATH_TO_GOLDFILES = path.join(__dirname, '..', '..', 'fixtures', 'comparison-files', 'lib', 'actions', 'RulesAction.test.ts'); @@ -15,6 +16,7 @@ describe('RulesAction tests', () => { let viewer: SpyRuleViewer; let writer: SpyRuleWriter; let spyDisplay: SpyDisplay; + let spyTelemetryEmitter: SpyTelemetryEmitter; let actionSummaryViewer: RulesActionSummaryViewer; let defaultDependencies: RulesDependencies; @@ -23,11 +25,13 @@ describe('RulesAction tests', () => { writer = new SpyRuleWriter(); spyDisplay = new SpyDisplay(); actionSummaryViewer = new RulesActionSummaryViewer(spyDisplay); + spyTelemetryEmitter = new SpyTelemetryEmitter(); defaultDependencies = { configFactory: new StubDefaultConfigFactory(), pluginsFactory: new StubEnginePluginFactories.StubEnginePluginsFactory_withFunctionalStubEngine(), logEventListeners: [], progressListeners: [], + telemetryEmitter: spyTelemetryEmitter, actionSummaryViewer, viewer, writer @@ -123,14 +127,8 @@ describe('RulesAction tests', () => { } ])('When $case, only the relevant rules are returned', async ({workspace, target}) => { const dependencies: RulesDependencies = { - configFactory: new StubDefaultConfigFactory(), - // The engine we're using here will synthesize one rule per target. + ...defaultDependencies, pluginsFactory: new StubEnginePluginFactories.StubEnginePluginsFactory_withTargetDependentStubEngine(), - logEventListeners: [], - progressListeners: [], - actionSummaryViewer, - viewer, - writer }; const action = RulesAction.createAction(dependencies); const input: RulesInput = { @@ -161,13 +159,8 @@ describe('RulesAction tests', () => { */ it('When no engines are registered, empty results are displayed', async () => { const dependencies: RulesDependencies = { - configFactory: new StubDefaultConfigFactory(), + ...defaultDependencies, pluginsFactory: new StubEnginePluginFactories.StubEnginePluginsFactory_withNoPlugins(), - logEventListeners: [], - progressListeners: [], - actionSummaryViewer, - viewer, - writer }; const action = RulesAction.createAction(dependencies); const input: RulesInput = { @@ -183,13 +176,8 @@ describe('RulesAction tests', () => { it('Throws an error when an engine throws an error', async () => { const dependencies: RulesDependencies = { - configFactory: new StubDefaultConfigFactory(), + ...defaultDependencies, pluginsFactory: new StubEnginePluginFactories.StubEnginePluginsFactory_withThrowingStubPlugin(), - logEventListeners: [], - progressListeners: [], - actionSummaryViewer, - viewer, - writer }; const action = RulesAction.createAction(dependencies); const input: RulesInput = { @@ -214,13 +202,9 @@ describe('RulesAction tests', () => { ])('When $quantifier rules are returned, $expectation', async ({selector, goldfile}) => { const goldfilePath: string = path.join(PATH_TO_GOLDFILES, 'action-summaries', goldfile); const dependencies: RulesDependencies = { - configFactory: new StubDefaultConfigFactory(), + ...defaultDependencies, pluginsFactory: new StubEnginePluginFactories.StubEnginePluginsFactory_withFunctionalStubEngine(), - logEventListeners: [], - progressListeners: [], - actionSummaryViewer, - viewer, - writer + viewer }; const action = RulesAction.createAction(dependencies); const input: RulesInput = { @@ -266,6 +250,62 @@ describe('RulesAction tests', () => { expect(displayedLogEvents).toContain(goldfileContents); }); }); + + describe('Telemetry Emission', () => { + it('When a telemetry emitter is provided, it is used', async () => { + + defaultDependencies.pluginsFactory = new StubEnginePluginFactories.StubEnginePluginsFactory_withFunctionalStubEngine(); + const action = RulesAction.createAction(defaultDependencies); + // Create the input. + const input: RulesInput = { + 'rule-selector': ['all'] + }; + // ==== TESTED BEHAVIOR ==== + await action.execute(input); + + // ==== ASSERTIONS ==== + const allTelemEvents: CapturedTelemetryEmission[] = spyTelemetryEmitter.getCapturedTelemetry(); + const ruleSelectionTelemEvents: CapturedTelemetryEmission[] = allTelemEvents.filter( + e => e.data.sfcaEvent === 'engine_selection'); + + expect(ruleSelectionTelemEvents).toHaveLength(2); + expect(ruleSelectionTelemEvents[0]).toEqual({ + "data": { + "engine": "stubEngine1", + "ruleCount": 5, + "sfcaEvent": "engine_selection" + }, + "eventName": "plugin-code-analyzer", + "source": "CLI" // NOTE: We might move these events to Core in the future instead of the CLI + }); + expect(ruleSelectionTelemEvents[1]).toEqual({ + "data": { + "engine": "stubEngine2", + "ruleCount": 3, + "sfcaEvent": "engine_selection" + }, + "eventName": "plugin-code-analyzer", + "source": "CLI" // NOTE: We might move these events to Core in the future instead of the CLI + }); + + const engineSpecificTelemEvents: CapturedTelemetryEmission[] = allTelemEvents.filter( + e => e.data.sfcaEvent === 'engine1DescribeTelemetry'); + expect(engineSpecificTelemEvents).toHaveLength(1); + const customEvent: CapturedTelemetryEmission = engineSpecificTelemEvents[0]; + customEvent.data["timestamp"] = 0; // fix for deterministic testing + customEvent.data["uuid"] = "someUUID"; // fix for deterministic testing + expect(customEvent).toEqual({ + "data": { + "someArg": true, // argument set by engine + "sfcaEvent": "engine1DescribeTelemetry", + "timestamp": 0, + "uuid": "someUUID" + }, + "eventName": "plugin-code-analyzer", + "source": "stubEngine1" + }); + }); + }) }); // TODO: Whenever we decide to document the custom_engine_plugin_modules flag in our configuration file, then we'll want