Skip to content

Commit 05fe08d

Browse files
authored
feat: analytics (#301)
1 parent e711405 commit 05fe08d

File tree

12 files changed

+606
-34
lines changed

12 files changed

+606
-34
lines changed

.envrc.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
export GRAPHQL_HOST='https://api.nes.herodevs.com';
44
export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.com/reports';
5+
export ANALYTICS_URL='https://eol-api.herodevs.com/track';

package-lock.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"prepack": "oclif manifest && oclif readme",
2727
"pretest": "npm run lint && npm run typecheck",
2828
"readme": "npm run ci:fix && npm run build && npm exec oclif readme",
29-
"test": "globstar -- node --import tsx --test \"test/**/*.test.ts\"",
29+
"test": "globstar -- node --import tsx --test --experimental-test-module-mocks \"test/**/*.test.ts\"",
3030
"test:e2e": "globstar -- node --import tsx --test \"e2e/**/*.test.ts\"",
3131
"typecheck": "tsc --noEmit"
3232
},
@@ -36,13 +36,15 @@
3636
"herodevs cli"
3737
],
3838
"dependencies": {
39+
"@amplitude/analytics-node": "^1.5.0",
3940
"@apollo/client": "^3.13.8",
4041
"@cyclonedx/cdxgen": "^11.4.3",
4142
"@herodevs/eol-shared": "github:herodevs/eol-shared#v0.1.4",
4243
"@oclif/core": "^4.4.0",
4344
"@oclif/plugin-help": "^6.2.29",
4445
"@oclif/plugin-update": "^4.6.45",
4546
"graphql": "^16.11.0",
47+
"node-machine-id": "^1.1.12",
4648
"ora": "^8.2.0",
4749
"packageurl-js": "^2.0.1",
4850
"terminal-link": "^4.0.0",
@@ -84,7 +86,8 @@
8486
],
8587
"hooks": {
8688
"init": "./dist/hooks/npm-update-notifier.js",
87-
"prerun": "./dist/hooks/prerun.js"
89+
"prerun": "./dist/hooks/prerun.js",
90+
"finally": "./dist/hooks/finally.js"
8891
},
8992
"topicSeparator": " ",
9093
"macos": {

src/commands/scan/eol.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { Command, Flags } from '@oclif/core';
44
import ora from 'ora';
55
import { submitScan } from '../../api/nes.client.ts';
66
import { config, filenamePrefix } from '../../config/constants.ts';
7+
import { track } from '../../service/analytics.svc.ts';
78
import { createSbom } from '../../service/cdx.svc.ts';
8-
import { formatScanResults, formatWebReportUrl } from '../../service/display.svc.ts';
9+
import { countComponentsByStatus, formatScanResults, formatWebReportUrl } from '../../service/display.svc.ts';
910
import { readSbomFromFile, saveReportToFile, saveSbomToFile, validateDirectory } from '../../service/file.svc.ts';
1011
import { getErrorMessage } from '../../service/log.svc.ts';
1112

@@ -56,23 +57,72 @@ export default class ScanEol extends Command {
5657
public async run(): Promise<EolReport | undefined> {
5758
const { flags } = await this.parse(ScanEol);
5859

60+
track('CLI EOL Scan Started', (context) => ({
61+
command: context.command,
62+
command_flags: context.command_flags,
63+
scan_location: flags.dir,
64+
}));
65+
66+
const sbomStartTime = performance.now();
5967
const sbom = await this.loadSbom();
68+
const sbomEndTime = performance.now();
69+
70+
if (!flags.file) {
71+
track('CLI SBOM Generated', (context) => ({
72+
command: context.command,
73+
command_flags: context.command_flags,
74+
scan_location: flags.dir,
75+
sbom_generation_time: (sbomEndTime - sbomStartTime) / 1000,
76+
}));
77+
}
6078

6179
if (!sbom.components?.length) {
80+
track('CLI EOL Scan Ended, No Components Found', (context) => ({
81+
command: context.command,
82+
command_flags: context.command_flags,
83+
scan_location: flags.dir,
84+
}));
6285
this.log('No components found in scan. Report not generated.');
6386
return;
6487
}
6588

89+
const scanStartTime = performance.now();
6690
const scan = await this.scanSbom(sbom);
91+
const scanEndTime = performance.now();
92+
93+
const componentCounts = countComponentsByStatus(scan);
94+
track('CLI EOL Scan Completed', (context) => ({
95+
command: context.command,
96+
command_flags: context.command_flags,
97+
eol_true_count: componentCounts.EOL,
98+
eol_unknown_count: componentCounts.UNKNOWN,
99+
nes_available_count: componentCounts.NES_AVAILABLE,
100+
number_of_packages: componentCounts.TOTAL,
101+
sbom_created: !flags.file,
102+
scan_location: flags.dir,
103+
scan_load_time: (scanEndTime - scanStartTime) / 1000,
104+
scanned_ecosystems: componentCounts.ECOSYSTEMS,
105+
web_report_link: scan.id ? `${config.eolReportUrl}/${scan.id}` : undefined,
106+
}));
67107

68108
if (flags.save) {
69109
const reportPath = this.saveReport(scan, flags.dir);
70110
this.log(`Report saved to ${reportPath}`);
111+
track('CLI JSON Scan Output Saved', (context) => ({
112+
command: context.command,
113+
command_flags: context.command_flags,
114+
report_output_path: reportPath,
115+
}));
71116
}
72117

73118
if (flags.saveSbom && !flags.file) {
74119
const sbomPath = this.saveSbom(flags.dir, sbom);
75120
this.log(`SBOM saved to ${sbomPath}`);
121+
track('CLI SBOM Output Saved', (context) => ({
122+
command: context.command,
123+
command_flags: context.command_flags,
124+
sbom_output_path: sbomPath,
125+
}));
76126
}
77127

78128
if (!this.jsonEnabled()) {
@@ -107,23 +157,34 @@ export default class ScanEol extends Command {
107157
return scan;
108158
} catch (error) {
109159
spinner.fail('Scanning failed');
110-
this.error(`Failed to submit scan to NES. ${getErrorMessage(error)}`);
160+
const errorMessage = getErrorMessage(error);
161+
track('CLI EOL Scan Failed', (context) => ({
162+
command: context.command,
163+
command_flags: context.command_flags,
164+
scan_location: context.scan_location,
165+
scan_failure_reason: errorMessage,
166+
}));
167+
this.error(`Failed to submit scan to NES. ${errorMessage}`);
111168
}
112169
}
113170

114171
private saveReport(report: EolReport, dir: string): string {
115172
try {
116173
return saveReportToFile(dir, report);
117174
} catch (error) {
118-
this.error(getErrorMessage(error));
175+
const errorMessage = getErrorMessage(error);
176+
track('CLI Error Encountered', () => ({ error: errorMessage }));
177+
this.error(errorMessage);
119178
}
120179
}
121180

122181
private saveSbom(dir: string, sbom: CdxBom): string {
123182
try {
124183
return saveSbomToFile(dir, sbom);
125184
} catch (error) {
126-
this.error(getErrorMessage(error));
185+
const errorMessage = getErrorMessage(error);
186+
track('CLI Error Encountered', () => ({ error: errorMessage }));
187+
this.error(errorMessage);
127188
}
128189
}
129190

@@ -154,15 +215,19 @@ export default class ScanEol extends Command {
154215
}
155216
return sbom;
156217
} catch (error) {
157-
this.error(`Failed to scan directory: ${getErrorMessage(error)}`);
218+
const errorMessage = getErrorMessage(error);
219+
track('CLI Error Encountered', () => ({ error: errorMessage }));
220+
this.error(`Failed to scan directory: ${errorMessage}`);
158221
}
159222
}
160223

161224
private getSbomFromFile(filePath: string): CdxBom {
162225
try {
163226
return readSbomFromFile(filePath);
164227
} catch (error) {
165-
this.error(getErrorMessage(error));
228+
const errorMessage = getErrorMessage(error);
229+
track('CLI Error Encountered', () => ({ error: errorMessage }));
230+
this.error(errorMessage);
166231
}
167232
}
168233
}

src/config/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
export const EOL_REPORT_URL = 'https://eol-report-card.apps.herodevs.com/reports';
22
export const GRAPHQL_HOST = 'https://api.nes.herodevs.com';
33
export const GRAPHQL_PATH = '/graphql';
4+
export const ANALYTICS_URL = 'https://eol-api.herodevs.com/track';
45

56
export const config = {
67
eolReportUrl: process.env.EOL_REPORT_URL || EOL_REPORT_URL,
78
graphqlHost: process.env.GRAPHQL_HOST || GRAPHQL_HOST,
89
graphqlPath: process.env.GRAPHQL_PATH || GRAPHQL_PATH,
10+
analyticsUrl: process.env.ANALYTICS_URL || ANALYTICS_URL,
911
showVulnCount: true,
1012
};
1113

src/hooks/finally.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Hook } from '@oclif/core';
2+
import ora from 'ora';
3+
import { track } from '../service/analytics.svc.ts';
4+
5+
const hook: Hook<'finally'> = async (opts) => {
6+
const spinner = ora().start('Cleaning up');
7+
const event = track('CLI Session Ended', (context) => ({
8+
cli_version: context.cli_version,
9+
ended_at: new Date(),
10+
})).promise;
11+
12+
if (!opts.argv.includes('--help')) {
13+
await event;
14+
}
15+
16+
spinner.stop();
17+
};
18+
19+
export default hook;

src/hooks/prerun.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
import { parseArgs } from 'node:util';
12
import type { Hook } from '@oclif/core';
23
import debug from 'debug';
4+
import { initializeAnalytics, track } from '../service/analytics.svc.ts';
35

46
const hook: Hook<'prerun'> = async (opts) => {
7+
const args = parseArgs({ allowPositionals: true, strict: false });
8+
initializeAnalytics();
9+
track('CLI Command Submitted', (context) => ({
10+
command: args.positionals.join(' ').trim(),
11+
command_flags: Object.entries(args.values).flat().join(' '),
12+
app_used: context.app_used,
13+
ci_provider: context.ci_provider,
14+
cli_version: context.cli_version,
15+
started_at: context.started_at,
16+
}));
17+
518
// If JSON flag is enabled, silence debug logging
619
if (opts.Command.prototype.jsonEnabled()) {
720
debug.disable();

0 commit comments

Comments
 (0)