From a5712e4de822d724cfd8fcd24dc0255eb4f003ba Mon Sep 17 00:00:00 2001 From: Facundo Rodriguez Date: Thu, 12 Feb 2026 21:52:58 -0300 Subject: [PATCH 1/2] fix(analytics): include scan_load_time for failed scan events --- src/commands/scan/eol.ts | 11 +- test/commands/scan/eol.analytics.test.ts | 185 +++++++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 test/commands/scan/eol.analytics.test.ts diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 0d52b67f..b38c8b3b 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -142,7 +142,6 @@ export default class ScanEol extends Command { const scanStartTime = performance.now(); const scan = await this.scanSbom(sbom); - const scanEndTime = performance.now(); const componentCounts = countComponentsByStatus(scan); track('CLI EOL Scan Completed', (context) => ({ @@ -153,7 +152,7 @@ export default class ScanEol extends Command { nes_available_count: componentCounts.NES_AVAILABLE, number_of_packages: componentCounts.TOTAL, sbom_created: !flags.file, - scan_load_time: (scanEndTime - scanStartTime) / 1000, + scan_load_time: this.getScanLoadTime(scanStartTime), scanned_ecosystems: componentCounts.ECOSYSTEMS, web_report_link: !flags.hideReportUrl && scan.id ? `${config.eolReportUrl}/${scan.id}` : undefined, web_report_hidden: flags.hideReportUrl, @@ -195,6 +194,7 @@ export default class ScanEol extends Command { } private async scanSbom(sbom: CdxBom): Promise { + const scanStartTime = performance.now(); const { flags } = await this.parse(ScanEol); const spinner = ora().start('Trimming SBOM'); @@ -218,12 +218,14 @@ export default class ScanEol extends Command { return scan; } catch (error) { spinner.fail('Scanning failed'); + const scan_load_time = this.getScanLoadTime(scanStartTime); if (error instanceof ApiError) { track('CLI EOL Scan Failed', (context) => ({ command: context.command, command_flags: context.command_flags, scan_failure_reason: error.code, + scan_load_time, })); const errorMessages: Record = { @@ -240,11 +242,16 @@ export default class ScanEol extends Command { command: context.command, command_flags: context.command_flags, scan_failure_reason: errorMessage, + scan_load_time, })); this.error(`Failed to submit scan to NES. ${errorMessage}`); } } + private getScanLoadTime(scanStartTime: number): number { + return (performance.now() - scanStartTime) / 1000; + } + private saveReport(report: EolReport, dir: string, outputPath?: string): string { try { return saveArtifactToFile(dir, { kind: 'report', payload: report, outputPath }); diff --git a/test/commands/scan/eol.analytics.test.ts b/test/commands/scan/eol.analytics.test.ts new file mode 100644 index 00000000..6cebedd7 --- /dev/null +++ b/test/commands/scan/eol.analytics.test.ts @@ -0,0 +1,185 @@ +import type { CdxBom, EolReport } from '@herodevs/eol-shared'; +import { ApiError } from '../../../src/api/errors.ts'; +import ScanEol from '../../../src/commands/scan/eol.ts'; + +const { trackMock, requireAccessTokenForScanMock, submitScanMock, countComponentsByStatusMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + requireAccessTokenForScanMock: vi.fn(), + submitScanMock: vi.fn(), + countComponentsByStatusMock: vi.fn(), +})); + +vi.mock('@herodevs/eol-shared', () => ({ + trimCdxBom: vi.fn((sbom: unknown) => sbom), +})); + +vi.mock('../../../src/service/analytics.svc.ts', () => ({ + track: trackMock, +})); + +vi.mock('../../../src/service/auth.svc.ts', () => ({ + requireAccessTokenForScan: requireAccessTokenForScanMock, +})); + +vi.mock('../../../src/api/nes.client.ts', () => ({ + submitScan: submitScanMock, +})); + +vi.mock('../../../src/service/display.svc.ts', () => ({ + countComponentsByStatus: countComponentsByStatusMock, + formatDataPrivacyLink: vi.fn(() => []), + formatReportSaveHint: vi.fn(() => []), + formatScanResults: vi.fn(() => []), + formatWebReportUrl: vi.fn(() => []), +})); + +vi.mock('../../../src/service/file.svc.ts', () => ({ + readSbomFromFile: vi.fn(), + saveArtifactToFile: vi.fn(), + validateDirectory: vi.fn(), +})); + +vi.mock('../../../src/service/cdx.svc.ts', () => ({ + createSbom: vi.fn(), +})); + +vi.mock('ora', () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + })), +})); + +type ParseFlags = { + automated?: boolean; + saveTrimmedSbom?: boolean; + dir?: string; + file?: string; + save?: boolean; + output?: string; + saveSbom?: boolean; + sbomOutput?: string; + hideReportUrl?: boolean; +}; + +type ScanCommandInternals = { + parse: (...args: unknown[]) => Promise<{ flags: ParseFlags }>; + error: (message: string) => never; + scanSbom: (sbom: CdxBom) => Promise; + loadSbom: () => Promise; + displayResults: (report: EolReport, hideReportUrl: boolean, hasCustomOutput: boolean) => void; + jsonEnabled: () => boolean; + run: () => Promise; +}; + +function createCommand(): ScanCommandInternals { + return new ScanEol([], {} as Record) as unknown as ScanCommandInternals; +} + +function getTrackProperties(eventName: string): Record { + const call = trackMock.mock.calls.find(([event]) => event === eventName); + if (!call) { + throw new Error(`Expected analytics event ${eventName} to be tracked`); + } + + const getProperties = call[1] as (context: Record) => Record; + return getProperties({ command: 'scan:eol', command_flags: '--file sample.sbom.json' }); +} + +describe('scan:eol analytics timing', () => { + const sampleSbom = { + bomFormat: 'CycloneDX', + specVersion: '1.5', + metadata: {}, + components: [{ purl: 'pkg:npm/test@1.0.0' }], + } as unknown as CdxBom; + + const sampleReport = { + id: 'report-123', + metadata: {}, + createdOn: new Date().toISOString(), + components: [{ purl: 'pkg:npm/test@1.0.0', metadata: {} }], + } as unknown as EolReport; + + beforeEach(() => { + vi.clearAllMocks(); + requireAccessTokenForScanMock.mockResolvedValue(undefined); + countComponentsByStatusMock.mockReturnValue({ + EOL: 1, + EOL_UPCOMING: 0, + OK: 0, + UNKNOWN: 0, + NES_AVAILABLE: 0, + TOTAL: 1, + ECOSYSTEMS: ['npm'], + }); + }); + + it('tracks scan_load_time on timeout-like scan failures', async () => { + submitScanMock.mockRejectedValue(new Error('GraphQL request timed out after 60000ms')); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { automated: false, saveTrimmedSbom: false, dir: process.cwd() }, + }); + vi.spyOn(command, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.scanSbom(sampleSbom)).rejects.toThrow( + 'Failed to submit scan to NES. GraphQL request timed out after 60000ms', + ); + + const properties = getTrackProperties('CLI EOL Scan Failed'); + expect(properties.scan_failure_reason).toBe('GraphQL request timed out after 60000ms'); + expect(properties.scan_load_time).toEqual(expect.any(Number)); + expect(properties.scan_load_time as number).toBeGreaterThanOrEqual(0); + }); + + it('tracks scan_load_time on ApiError scan failures', async () => { + submitScanMock.mockRejectedValue(new ApiError('forbidden', 'FORBIDDEN')); + + const command = createCommand(); + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { automated: false, saveTrimmedSbom: false, dir: process.cwd() }, + }); + vi.spyOn(command, 'error').mockImplementation((message: string) => { + throw new Error(message); + }); + + await expect(command.scanSbom(sampleSbom)).rejects.toThrow('You do not have permission to perform this action.'); + + const properties = getTrackProperties('CLI EOL Scan Failed'); + expect(properties.scan_failure_reason).toBe('FORBIDDEN'); + expect(properties.scan_load_time).toEqual(expect.any(Number)); + expect(properties.scan_load_time as number).toBeGreaterThanOrEqual(0); + }); + + it('keeps scan_load_time on successful completion events', async () => { + const command = createCommand(); + + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: { + file: '/tmp/sample.sbom.json', + save: false, + output: undefined, + saveSbom: false, + sbomOutput: undefined, + saveTrimmedSbom: false, + hideReportUrl: false, + automated: false, + }, + }); + vi.spyOn(command, 'loadSbom').mockResolvedValue(sampleSbom); + vi.spyOn(command, 'scanSbom').mockResolvedValue(sampleReport); + vi.spyOn(command, 'displayResults').mockImplementation(() => {}); + vi.spyOn(command, 'jsonEnabled').mockReturnValue(true); + + await command.run(); + + const properties = getTrackProperties('CLI EOL Scan Completed'); + expect(properties.scan_load_time).toEqual(expect.any(Number)); + expect(properties.scan_load_time as number).toBeGreaterThanOrEqual(0); + }); +}); From 10dc5eb9c138d68ee0a7b9425eb29ec71b143590 Mon Sep 17 00:00:00 2001 From: Facundo Rodriguez Date: Fri, 13 Feb 2026 10:47:30 -0300 Subject: [PATCH 2/2] fix: include number_of_packages --- src/commands/scan/eol.ts | 9 ++++++--- test/commands/scan/eol.analytics.test.ts | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index b38c8b3b..91ec4f2c 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -195,6 +195,7 @@ export default class ScanEol extends Command { private async scanSbom(sbom: CdxBom): Promise { const scanStartTime = performance.now(); + const numberOfPackages = sbom.components?.length ?? 0; const { flags } = await this.parse(ScanEol); const spinner = ora().start('Trimming SBOM'); @@ -218,14 +219,15 @@ export default class ScanEol extends Command { return scan; } catch (error) { spinner.fail('Scanning failed'); - const scan_load_time = this.getScanLoadTime(scanStartTime); + const scanLoadTime = this.getScanLoadTime(scanStartTime); if (error instanceof ApiError) { track('CLI EOL Scan Failed', (context) => ({ command: context.command, command_flags: context.command_flags, scan_failure_reason: error.code, - scan_load_time, + scan_load_time: scanLoadTime, + number_of_packages: numberOfPackages, })); const errorMessages: Record = { @@ -242,7 +244,8 @@ export default class ScanEol extends Command { command: context.command, command_flags: context.command_flags, scan_failure_reason: errorMessage, - scan_load_time, + scan_load_time: scanLoadTime, + number_of_packages: numberOfPackages, })); this.error(`Failed to submit scan to NES. ${errorMessage}`); } diff --git a/test/commands/scan/eol.analytics.test.ts b/test/commands/scan/eol.analytics.test.ts index 6cebedd7..dd2bdb9a 100644 --- a/test/commands/scan/eol.analytics.test.ts +++ b/test/commands/scan/eol.analytics.test.ts @@ -135,6 +135,7 @@ describe('scan:eol analytics timing', () => { expect(properties.scan_failure_reason).toBe('GraphQL request timed out after 60000ms'); expect(properties.scan_load_time).toEqual(expect.any(Number)); expect(properties.scan_load_time as number).toBeGreaterThanOrEqual(0); + expect(properties.number_of_packages).toBe(1); }); it('tracks scan_load_time on ApiError scan failures', async () => { @@ -154,6 +155,7 @@ describe('scan:eol analytics timing', () => { expect(properties.scan_failure_reason).toBe('FORBIDDEN'); expect(properties.scan_load_time).toEqual(expect.any(Number)); expect(properties.scan_load_time as number).toBeGreaterThanOrEqual(0); + expect(properties.number_of_packages).toBe(1); }); it('keeps scan_load_time on successful completion events', async () => {