Skip to content

Commit

Permalink
feat(telemetry): add telemetry wrapper for cli (#2210)
Browse files Browse the repository at this point in the history
## Proposed change
Add telemetry wrapper for cli

## Related issues

- 🚀 Feature #2191
  • Loading branch information
matthieu-crouzet authored Oct 2, 2024
2 parents 620c7db + 3e14e0e commit 7dbeef8
Show file tree
Hide file tree
Showing 10 changed files with 287 additions and 21 deletions.
11 changes: 11 additions & 0 deletions packages/@o3r/telemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"build:builders": "tsc -b tsconfig.builders.json --pretty"
},
"dependencies": {
"minimist": "^1.2.6",
"tslib": "^2.6.2"
},
"peerDependencies": {
Expand All @@ -27,6 +28,15 @@
"peerDependenciesMeta": {
"@angular-devkit/architect": {
"optional": true
},
"@angular-devkit/core": {
"optional": true
},
"@angular-devkit/schematics": {
"optional": true
},
"rxjs": {
"optional": true
}
},
"devDependencies": {
Expand All @@ -40,6 +50,7 @@
"@o3r/eslint-plugin": "workspace:^",
"@stylistic/eslint-plugin-ts": "~2.4.0",
"@types/jest": "~29.5.2",
"@types/minimist": "^1.2.2",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.14.1",
Expand Down
113 changes: 113 additions & 0 deletions packages/@o3r/telemetry/src/cli/index.spec.ts
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();
});
});
});
99 changes: 99 additions & 0 deletions packages/@o3r/telemetry/src/cli/index.ts
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());
});
}
}
};
1 change: 1 addition & 0 deletions packages/@o3r/telemetry/src/public_api.ts
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';
5 changes: 3 additions & 2 deletions packages/@o3r/telemetry/src/schematics/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { JsonObject } from '@angular-devkit/core';
import { callRule, Rule } from '@angular-devkit/schematics';
import type { Rule } from '@angular-devkit/schematics';
import { performance } from 'node:perf_hooks';
import { lastValueFrom } from 'rxjs';
import { getEnvironmentInfo } from '../environment/index';
import { sendData as defaultSendData, SchematicMetricData, type SendDataFn } from '../sender';

Expand All @@ -27,6 +26,8 @@ export const createSchematicWithMetrics: SchematicWrapper =
let error: any;
try {
const rule = schematicFn(options);
const { callRule } = await import('@angular-devkit/schematics');
const { lastValueFrom } = await import('rxjs');
await lastValueFrom(callRule(rule, tree, context));
}
catch (e: any) {
Expand Down
13 changes: 12 additions & 1 deletion packages/@o3r/telemetry/src/sender/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface BaseMetricData {
/** Error message */
error?: string;
}

export interface BuilderMetricData extends BaseMetricData {
/** Builder information */
builder: {
Expand Down Expand Up @@ -40,10 +41,20 @@ export interface SchematicMetricData extends BaseMetricData {
};
}

export interface CliMetricData extends BaseMetricData {
/** CLI information */
cli: {
/** Name of the CLI */
name: string;
/** CLI options */
options?: any;
};
}

/**
* Different kinds of metrics
*/
export type MetricData = BuilderMetricData | SchematicMetricData;
export type MetricData = BuilderMetricData | SchematicMetricData | CliMetricData;

/**
* Function sending metrics to the server
Expand Down
3 changes: 2 additions & 1 deletion packages/@o3r/telemetry/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"outDir": "./dist",
"module": "CommonJS",
"rootDir": "src",
"tsBuildInfoFile": "build/.tsbuildinfo"
"tsBuildInfoFile": "build/.tsbuildinfo",
"esModuleInterop": true
},
"include": ["src/**/*.ts"],
"exclude": [
Expand Down
4 changes: 4 additions & 0 deletions packages/@o3r/workspace/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@
"@angular/common": "~18.2.0",
"@angular/compiler-cli": "~18.2.0",
"@angular/core": "~18.2.0",
"@o3r/telemetry": "workspace:^",
"@schematics/angular": "~18.2.0",
"typescript": "~5.5.4"
},
"peerDependenciesMeta": {
"@angular/cli": {
"optional": true
},
"@o3r/telemetry": {
"optional": true
}
},
"dependencies": {
Expand Down
48 changes: 31 additions & 17 deletions packages/@o3r/workspace/src/cli/set-version.cts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node

import type { CliWrapper } from '@o3r/telemetry';
import { program } from 'commander';
import { sync as globbySync } from 'globby';
import * as fs from 'node:fs';
Expand Down Expand Up @@ -46,20 +47,33 @@ program
const options: any = program.opts();
logger.level = options.verbose ? 'debug' : 'info';

globbySync(options.include, {cwd: process.cwd()})
.map((file: string) => path.join(process.cwd(), file))
.map((filePath: string) => ({
path: filePath,
content: fs.readFileSync(filePath).toString()
}))
.forEach((pathWithContent: {path: string; content: string}) => {
const newContent = pathWithContent.content
.replace(new RegExp('"([~^]?)' + (options.placeholder as string).replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\\*\./g, '\\.') + '"', 'g'), `"$1${replaceVersion}"`)
.replace(/"workspace:([~^]?)[^"]*"(,?)$/gm, `"$1${replaceVersion}"$2`);
if (newContent !== pathWithContent.content) {
logger.info(`update version in ${pathWithContent.path}`);
fs.writeFileSync(pathWithContent.path, newContent);
} else {
logger.debug(`No change in ${pathWithContent.path}`);
}
});
const cliFn = () => {
globbySync(options.include, {cwd: process.cwd()})
.map((file: string) => path.join(process.cwd(), file))
.map((filePath: string) => ({
path: filePath,
content: fs.readFileSync(filePath).toString()
}))
.forEach((pathWithContent: {path: string; content: string}) => {
const newContent = pathWithContent.content
.replace(new RegExp('"([~^]?)' + (options.placeholder as string).replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\\*\./g, '\\.') + '"', 'g'), `"$1${replaceVersion}"`)
.replace(/"workspace:([~^]?)[^"]*"(,?)$/gm, `"$1${replaceVersion}"$2`);
if (newContent !== pathWithContent.content) {
logger.info(`update version in ${pathWithContent.path}`);
fs.writeFileSync(pathWithContent.path, newContent);
} else {
logger.debug(`No change in ${pathWithContent.path}`);
}
});
};

void (async () => {
let wrapper: CliWrapper = (fn: any) => fn;
try {
const { createCliWithMetrics } = await import('@o3r/telemetry');
wrapper = createCliWithMetrics;
} catch {
// Do not throw if `@o3r/telemetry` is not installedx
}
return wrapper(cliFn, '@o3r/workspace:set-version', { logger, preParsedOptions: options })();
})();
Loading

0 comments on commit 7dbeef8

Please sign in to comment.