-
Notifications
You must be signed in to change notification settings - Fork 39
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(telemetry): add telemetry wrapper for cli (#2210)
## Proposed change Add telemetry wrapper for cli ## Related issues - 🚀 Feature #2191
- Loading branch information
Showing
10 changed files
with
287 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
jest.mock('../environment/index', () => { | ||
const original = jest.requireActual('../environment/index'); | ||
return { | ||
...original, | ||
getEnvironmentInfo: jest.fn(() => ({ env: 'env' })) | ||
}; | ||
}); | ||
|
||
jest.mock('node:perf_hooks', () => { | ||
const original = jest.requireActual('node:perf_hooks'); | ||
return { | ||
...original, | ||
performance: { | ||
...original.performance, | ||
now: jest.fn().mockReturnValue(0) | ||
} | ||
}; | ||
}); | ||
|
||
import { createCliWithMetrics } from './index'; | ||
|
||
const expectedOutput = { success: true }; | ||
const options = { example: 'test' }; | ||
|
||
describe('CLI with metrics', () => { | ||
afterEach(() => { | ||
jest.restoreAllMocks(); | ||
}); | ||
|
||
it('should run the original builder with the same options', async () => { | ||
const originalCliFn = jest.fn(() => expectedOutput); | ||
const cliFn = createCliWithMetrics(originalCliFn, 'cli-test'); | ||
const output = await cliFn(options); | ||
expect(output).toEqual(expect.objectContaining(expectedOutput)); | ||
expect(originalCliFn).toHaveBeenCalled(); | ||
expect(originalCliFn).toHaveBeenCalledWith(options); | ||
}); | ||
|
||
it('should throw the same error as the original one', async () => { | ||
const error = new Error('error example'); | ||
const originalCliFn = jest.fn(() => { throw error; }); | ||
const cliFn = createCliWithMetrics(originalCliFn, 'cli-test'); | ||
await expect(() => cliFn(options)).rejects.toThrow(error); | ||
expect(originalCliFn).toHaveBeenCalled(); | ||
expect(originalCliFn).toHaveBeenCalledWith(options); | ||
}); | ||
|
||
it('should throw if the builder function is a rejected Promise', async () => { | ||
const originalCliFn = jest.fn(() => Promise.reject('rejected')); | ||
const cliFn = createCliWithMetrics(originalCliFn, 'cli-test'); | ||
await expect(() => cliFn(options)).rejects.toThrow(); | ||
}); | ||
|
||
describe('sendData', () => { | ||
let cliFn: ReturnType<typeof createCliWithMetrics>; | ||
let originalCliFn: jest.Mock; | ||
let sendDataMock: jest.Mock; | ||
|
||
beforeEach(() => { | ||
originalCliFn = jest.fn(() => expectedOutput); | ||
sendDataMock = jest.fn(() => Promise.resolve()); | ||
cliFn = createCliWithMetrics(originalCliFn, 'cli-test', { sendData: sendDataMock }); | ||
// eslint-disable-next-line @typescript-eslint/naming-convention | ||
jest.replaceProperty(process, 'env', { ...process.env, O3R_METRICS: 'true' }); | ||
}); | ||
|
||
it('should call sendData with the options given by argument', async () => { | ||
const preParsedOptions = { | ||
preParsedParam: 'value' | ||
}; | ||
cliFn = createCliWithMetrics(originalCliFn, 'cli-test', { sendData: sendDataMock, preParsedOptions }); | ||
jest.replaceProperty(process, 'argv', ['', '', 'param1', '--param2', 'value2', '--param3']); | ||
await cliFn(options); | ||
|
||
expect(sendDataMock).toHaveBeenCalled(); | ||
expect(sendDataMock).toHaveBeenCalledWith(expect.objectContaining({ | ||
cli: { | ||
name: 'cli-test', | ||
options: preParsedOptions | ||
} | ||
}), expect.anything()); | ||
}); | ||
|
||
it('should call sendData with the data parsed by minimist', async () => { | ||
jest.replaceProperty(process, 'argv', ['', '', 'param1', '--param2', 'value2', '--param3']); | ||
await cliFn(options); | ||
|
||
expect(sendDataMock).toHaveBeenCalled(); | ||
expect(sendDataMock).toHaveBeenCalledWith(expect.objectContaining({ | ||
cli: { | ||
name: 'cli-test', | ||
options: expect.objectContaining({ | ||
_: ['param1'], | ||
param2: 'value2', | ||
param3: true | ||
}) | ||
} | ||
}), expect.anything()); | ||
}); | ||
|
||
it('should not call sendData because called with --no-o3r-metrics', async () => { | ||
jest.replaceProperty(process, 'argv', ['', '', '--param1', 'value1', '--param2', '--no-o3r-metrics']); | ||
await cliFn(options); | ||
expect(sendDataMock).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('should not call sendData because called with --no-o3rMetrics', async () => { | ||
jest.replaceProperty(process, 'argv', ['', '', '--param1', 'value1', '--param2', '--no-o3rMetrics']); | ||
await cliFn(options); | ||
expect(sendDataMock).not.toHaveBeenCalled(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import { existsSync, readFileSync } from 'node:fs'; | ||
import path from 'node:path'; | ||
import minimist from 'minimist'; | ||
import type { Opts as MinimistOptions } from 'minimist'; | ||
import { getEnvironmentInfo } from '../environment'; | ||
import { type CliMetricData, sendData as defaultSendData, type SendDataFn } from '../sender'; | ||
|
||
/** Simple Logger interface */ | ||
interface Logger { | ||
/** Error message to display */ | ||
error: (message: string) => void; | ||
/** Error message to display */ | ||
warn: (message: string) => void; | ||
/** Information message to display */ | ||
info: (message: string) => void; | ||
/** Debug message message to display */ | ||
debug: (message: string) => void; | ||
} | ||
|
||
/** Custom options for the CLI wrapper */ | ||
interface CliWrapperOptions { | ||
/** Logger */ | ||
logger?: Logger; | ||
/** Function to send the data to the server */ | ||
sendData?: SendDataFn; | ||
/** Options to parse the CLI arguments with `minimist` */ | ||
minimistOptions?: MinimistOptions; | ||
/** CLI arguments pre-parsed to override the ones found by `minimist` */ | ||
preParsedOptions?: any; | ||
} | ||
|
||
/** | ||
* Type of a function that wraps a CLI | ||
*/ | ||
export type CliWrapper = <T extends (...args: any) => any>( | ||
cliFn: (...args: Parameters<T>) => ReturnType<T>, cliName: string, options?: CliWrapperOptions | ||
) => (...args: Parameters<T>) => Promise<ReturnType<T>>; | ||
|
||
export const createCliWithMetrics: CliWrapper = (cliFn, cliName, options) => async (...cliFnArgs) => { | ||
const logger: Logger = options?.logger || console; | ||
const sendData = options?.sendData || defaultSendData; | ||
const startTime = Math.floor(performance.now()); | ||
let error: any; | ||
try { | ||
// eslint-disable-next-line @typescript-eslint/await-thenable | ||
const result = await cliFn(...cliFnArgs); | ||
return result; | ||
} | ||
catch (e: any) { | ||
const err = e instanceof Error ? e : new Error(e.toString()); | ||
error = err.stack || err.toString(); | ||
throw err; | ||
} | ||
finally { | ||
const endTime = Math.floor(performance.now()); | ||
const duration = endTime - startTime; | ||
logger.info(`${cliName} run in ${duration}ms`); | ||
const environment = await getEnvironmentInfo(); | ||
const argv = minimist(process.argv.slice(2), { ...options?.minimistOptions, alias: { o3rMetrics: ['o3r-metrics']} }); | ||
const data: CliMetricData = { | ||
environment, | ||
duration, | ||
cli: { | ||
name: cliName, | ||
options: options?.preParsedOptions ?? argv | ||
}, | ||
error | ||
}; | ||
logger.debug(JSON.stringify(data, null, 2)); | ||
const packageJsonPath = path.join(process.cwd(), 'package.json'); | ||
const packageJson = existsSync(packageJsonPath) ? JSON.parse(readFileSync(packageJsonPath, 'utf-8')) : {}; | ||
const shouldSendData = !!( | ||
argv.o3rMetrics | ||
?? ((process.env.O3R_METRICS || '').length > 0 ? process.env.O3R_METRICS !== 'false' : undefined) | ||
?? packageJson.config?.o3r?.telemetry | ||
?? packageJson.config?.o3rMetrics // deprecated will be removed in v13 | ||
); | ||
if (typeof packageJson.config?.o3rMetrics !== 'undefined') { | ||
logger.warn([ | ||
'`config.o3rMetrics` is deprecated and will be removed in v13, please use `config.o3r.telemetry` instead.', | ||
'You can run `ng update @o3r/telemetry` to have the automatic update.' | ||
].join('\n')); | ||
} | ||
if (shouldSendData) { | ||
if (typeof (argv.o3rMetrics ?? process.env.O3R_METRICS) === 'undefined') { | ||
logger.info( | ||
'Telemetry is globally activated for the project (`config.o3r.telemetry` in package.json). ' | ||
+ 'If you personally don\'t want to send telemetry, you can deactivate it by setting `O3R_METRICS` to false in your environment variables, ' | ||
+ 'or by calling the cli with `--no-o3r-metrics`.' | ||
); | ||
} | ||
void sendData(data, logger).catch((e) => { | ||
// Do not throw error if we don't manage to collect data | ||
const err = (e instanceof Error ? e : new Error(error)); | ||
logger.error(err.stack || err.toString()); | ||
}); | ||
} | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
export * from './builders/index'; | ||
export * from './cli/index'; | ||
export * from './environment/index'; | ||
export * from './schematics/index'; | ||
export * from './sender/index'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.