From 743a56201cc357425931c9926a8fe452d618e2f8 Mon Sep 17 00:00:00 2001 From: CptSchnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Sat, 26 Oct 2024 14:35:04 +0300 Subject: [PATCH 1/3] feat: added metrics --- .gitignore | 2 ++ README.md | 10 ++++++ package-lock.json | 50 +++++++++++++++++++++++++++- package.json | 17 +++++++--- scripts/generateVersionFile.mjs | 10 ++++++ src/config.ts | 12 +++++-- src/errors.ts | 1 + src/metrics.ts | 59 +++++++++++++++++++++++++++++++++ src/types.ts | 6 ++++ tests/metrics.spec.ts | 40 ++++++++++++++++++++++ 10 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 scripts/generateVersionFile.mjs create mode 100644 src/metrics.ts create mode 100644 tests/metrics.spec.ts diff --git a/.gitignore b/.gitignore index 09fc68c..988961d 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,5 @@ dist # reports of jest tests reports + +src/version.ts diff --git a/README.md b/README.md index 701e2fc..042a7e6 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ This package allows you to configure various options for loading and managing co - **Default**: `./config` - **Description**: The path to the local configuration folder. +### `metricsRegistry` +- **Type**: `promClient.Registry` +- **Optional**: `true` +- **Description**: The prometheus registry to use for metrics. If not provided, metrics are not provided. + ## Environment Variable Configuration The following environment variables can be used to configure the options: @@ -243,6 +248,11 @@ try { ``` - **Description**: This error occurs when there is a version mismatch between the remote and local schemas. The payload includes the versions of both the remote and local schemas. +### `promClientNotInstalledError` +- **Code**: `8` +- **Payload**: `Error` +- **Description**: This error occurs when the `prom-client` package is not installed. The payload contains the error object. + # Debugging If for some reason you want to debug the package you can either use the `getConfigParts` or the `getResolvedOptions` functions described in the API or use the more powerful debug logger. diff --git a/package-lock.json b/package-lock.json index 5a11f5f..9bc53cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,11 +39,18 @@ "jest": "^29.7.0", "prettier": "^2.2.1", "pretty-quick": "^3.1.0", + "prom-client": "^15.0.0", "ts-jest": "^29.1.5", "typescript": "^5.4.5" }, "peerDependencies": { - "@map-colonies/schemas": "^1.0.0" + "@map-colonies/schemas": "^1.0.0", + "prom-client": "^15.0.0" + }, + "peerDependenciesMeta": { + "prom-client": { + "optional": true + } } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -3161,6 +3168,16 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.1.tgz", @@ -4256,6 +4273,13 @@ } ] }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "dev": true, + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -9547,6 +9571,20 @@ "node": ">=8" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -10550,6 +10588,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index 3f9daf7..a91778b 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,14 @@ "prelint": "npm run format", "lint": "eslint .", "lint:fix": "eslint --fix .", + "pretest": "npm run generate:version", "test": "cross-env SUPPRESS_NO_CONFIG_WARNING=true NODE_CONFIG_STRICT_MODE=false jest --config=./tests/configurations/jest.config.js", - "prebuild": "npm run clean", + "prebuild": "npm run clean && npm run generate:version", "build": "tsc --project tsconfig.build.json", "start": "npm run build && cd dist && node ./index.js", "clean": "rimraf dist", - "prepublish": "npm run build" + "prepublish": "npm run build", + "generate:version": "node scripts/generateVersionFile.mjs" }, "repository": { "type": "git", @@ -61,7 +63,13 @@ "undici": "^6.18.2" }, "peerDependencies": { - "@map-colonies/schemas": "^1.0.0" + "@map-colonies/schemas": "^1.0.0", + "prom-client": "^15.0.0" + }, + "peerDependenciesMeta": { + "prom-client": { + "optional": true + } }, "devDependencies": { "@commitlint/cli": "^11.0.0", @@ -80,6 +88,7 @@ "prettier": "^2.2.1", "pretty-quick": "^3.1.0", "ts-jest": "^29.1.5", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "prom-client": "^15.0.0" } } diff --git a/scripts/generateVersionFile.mjs b/scripts/generateVersionFile.mjs new file mode 100644 index 0000000..d73cefd --- /dev/null +++ b/scripts/generateVersionFile.mjs @@ -0,0 +1,10 @@ +import { writeFileSync } from 'fs'; +import { readPackageJsonSync } from '@map-colonies/read-pkg'; + +const packageJson = readPackageJsonSync(); +const version = packageJson.version; +const versionFile = 'src/version.ts'; + +const content = `/* prettier-ignore */\n/* eslint-disable*/\nexport const PACKAGE_VERSION = '${version}';\n`; + +writeFileSync(versionFile, content); diff --git a/src/config.ts b/src/config.ts index fea91c6..74dc1c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import configPkg from 'config'; import semver from 'semver'; import lodash, { type GetFieldType } from 'lodash'; import { getEnvValues } from './env'; -import { BaseOptions, ConfigOptions, ConfigInstance } from './types'; +import { BaseOptions, ConfigOptions, ConfigInstance, Config } from './types'; import { loadSchema } from './schemas'; import { getOptions, initializeOptions } from './options'; import { getRemoteConfig, getServerCapabilities } from './httpClient'; @@ -12,6 +12,7 @@ import { ajvConfigValidator, validate } from './validator'; import { createDebug } from './utils/debug'; import { LOCAL_SCHEMAS_PACKAGE_VERSION } from './constants'; import { createConfigError } from './errors'; +import { initializeMetrics } from './metrics'; const debug = createDebug('config'); @@ -30,11 +31,12 @@ export async function config( ): Promise> { // handle package options debug('config called with options: %j', { ...options, schema: options.schema.$id }); - const { schema: baseSchema, ...unvalidatedOptions } = options; + const { schema: baseSchema, metricsRegistry, ...unvalidatedOptions } = options; const { configName, offlineMode, version, ignoreServerIsOlderVersionError } = initializeOptions(unvalidatedOptions); let remoteConfig: object | T = {}; + let serverConfigResponse: Config | undefined = undefined; // handle remote config if (offlineMode !== true) { debug('handling fetching remote data'); @@ -58,7 +60,7 @@ export async function config( } // get the remote config - const serverConfigResponse = await getRemoteConfig(configName, version); + serverConfigResponse = await getRemoteConfig(configName, version); if (serverConfigResponse.schemaId !== baseSchema.$id) { debug('schema version mismatch. local: %s, remote: %s', baseSchema.$id, serverConfigResponse.schemaId); @@ -100,6 +102,10 @@ export async function config( // freeze the merged config so it can't be modified by the package user Object.freeze(validatedConfig); + if (metricsRegistry) { + initializeMetrics(metricsRegistry, baseSchema.$id, serverConfigResponse?.version); + } + function get(path: TPath): GetFieldType { debug('get called with path: %s', path); // eslint-disable-next-line @typescript-eslint/no-unsafe-return diff --git a/src/errors.ts b/src/errors.ts index 773c407..f725539 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -9,6 +9,7 @@ const configErrors = { schemaNotFoundError: { code: 5, payload: {} as { schemaPath: string } }, schemasPackageVersionMismatchError: { code: 6, payload: {} as { remotePackageVersion: string; localPackageVersion: string } }, schemaVersionMismatchError: { code: 7, payload: {} as { remoteSchemaVersion: string; localSchemaVersion: string } }, + promClientNotInstalledError: { code: 8, payload: {} as { message: string } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as const satisfies Record; diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..fb0c45a --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,59 @@ +import type { Registry } from 'prom-client'; +import { getOptions } from './options'; +import { PACKAGE_VERSION } from './version'; +import { LOCAL_SCHEMAS_PACKAGE_VERSION } from './constants'; +import { createDebug } from './utils/debug'; +import { createConfigError } from './errors'; + +const debug = createDebug('metrics'); +const MILLISECONDS_PER_SECOND = 1000; + +let promClient: typeof import('prom-client') | undefined; + +function loadPromClient(): void { + if (promClient === undefined) { + debug('loading prom-client'); + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + promClient = require('prom-client'); + } catch (error) { + console.log('error', error); + + throw createConfigError('promClientNotInstalledError', 'prom-client is not installed and metrics was initialized', error as Error); + } + } +} + +export function initializeMetrics(registry: Registry, schemaId: string, actualVersion: number | undefined): void { + debug('initializing metrics'); + loadPromClient(); + + if (promClient === undefined) { + return; + } + + const { offlineMode, configName, version } = getOptions(); + const gauge = new promClient.Gauge({ + name: 'config_time_of_last_fetch_unix_timestamp', + help: 'The time of the last fetch of the configuration in unix timestamp', + labelNames: ['name', 'request_version', 'actual_version', 'offline_mode', 'schemas_package_version', 'package_version', 'schema_id'], + }); + + /* eslint-disable @typescript-eslint/naming-convention */ + gauge.set( + { + name: configName, + request_version: version, + actual_version: actualVersion, + offline_mode: String(offlineMode ?? false), + schemas_package_version: LOCAL_SCHEMAS_PACKAGE_VERSION, + package_version: PACKAGE_VERSION, + schema_id: schemaId, + }, + + Date.now() / MILLISECONDS_PER_SECOND + ); + + /* eslint-enable @typescript-eslint/naming-convention */ + registry.registerMetric(gauge); +} diff --git a/src/types.ts b/src/types.ts index c1d28b9..dac9dfa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { typeSymbol } from '@map-colonies/schemas/build/schemas/symbol'; import { JSONSchemaType } from 'ajv'; +import type { Registry } from 'prom-client'; type Prettify = { [K in keyof T]: T[K]; @@ -77,6 +78,11 @@ export type ConfigOptions = Prettify< * The schema of the configuration object. */ schema: T; + /** + * The registry for the metrics. If not provided, the metrics will not be registered. + * Depends on the prom-client package being installed. + */ + metricsRegistry?: Registry; } >; diff --git a/tests/metrics.spec.ts b/tests/metrics.spec.ts new file mode 100644 index 0000000..5740dc8 --- /dev/null +++ b/tests/metrics.spec.ts @@ -0,0 +1,40 @@ +import { Registry } from 'prom-client'; +import { getOptions } from '../src/options'; +import { BaseOptions } from '../src/types'; +import { initializeMetrics } from '../src/metrics'; +import { PACKAGE_VERSION } from '../src/version'; +import { LOCAL_SCHEMAS_PACKAGE_VERSION } from '../src/constants'; + +jest.mock('../src/options'); + +const mockedGetOptions = getOptions as jest.MockedFunction; + +mockedGetOptions.mockReturnValue({ + offlineMode: false, + configName: 'avi', + version: 1, +} as BaseOptions); + +describe('httpClient', () => { + beforeEach(() => {}); + + describe('#initializeMetrics', () => { + it('should initialize the metrics in the registry with the correct labels', async () => { + const registry = new Registry(); + initializeMetrics(registry, 'schema', 1); + + const metric = (await registry.getMetricsAsJSON())[0]; + expect(metric).toHaveProperty('name', 'config_time_of_last_fetch_unix_timestamp'); + expect(metric).toHaveProperty('values[0].labels', { + /* eslint-disable @typescript-eslint/naming-convention */ + actual_version: 1, + name: 'avi', + offline_mode: 'false', + package_version: PACKAGE_VERSION, + request_version: 1, + schema_id: 'schema', + schemas_package_version: LOCAL_SCHEMAS_PACKAGE_VERSION, + }); + }); + }); +}); From 7e0db060368d021004ae71388feea125f4b50857 Mon Sep 17 00:00:00 2001 From: CptSchnitz <12687466+CptSchnitz@users.noreply.github.com> Date: Sat, 26 Oct 2024 14:55:10 +0300 Subject: [PATCH 2/3] feat: changed metrics to be initialized later --- README.md | 9 ++++----- src/config.ts | 13 +++++++------ src/metrics.ts | 24 +++++++++++++----------- src/types.ts | 7 +++++++ 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 042a7e6..4054241 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ The `ConfigInstance` interface represents the your way to interact with the conf - **Description**: Retrieves the resolved options from the configuration object. Useful for debugging. - **Returns**: The resolved options, which are an instance of `BaseOptions`. +##### `initializeMetrics(registry: promClient.Registry): void` +- **Description**: Initializes the metrics for the configuration. +- **Parameters**: + - `registry` (`promClient.Registry`): The prometheus registry to use for the metrics. # Configuration Options @@ -114,11 +118,6 @@ This package allows you to configure various options for loading and managing co - **Default**: `./config` - **Description**: The path to the local configuration folder. -### `metricsRegistry` -- **Type**: `promClient.Registry` -- **Optional**: `true` -- **Description**: The prometheus registry to use for metrics. If not provided, metrics are not provided. - ## Environment Variable Configuration The following environment variables can be used to configure the options: diff --git a/src/config.ts b/src/config.ts index 74dc1c0..9c53aba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import deepmerge from 'deepmerge'; import { typeSymbol } from '@map-colonies/schemas/build/schemas/symbol'; import configPkg from 'config'; import semver from 'semver'; +import type { Registry } from 'prom-client'; import lodash, { type GetFieldType } from 'lodash'; import { getEnvValues } from './env'; import { BaseOptions, ConfigOptions, ConfigInstance, Config } from './types'; @@ -12,7 +13,7 @@ import { ajvConfigValidator, validate } from './validator'; import { createDebug } from './utils/debug'; import { LOCAL_SCHEMAS_PACKAGE_VERSION } from './constants'; import { createConfigError } from './errors'; -import { initializeMetrics } from './metrics'; +import { initializeMetrics as initializeMetricsInternal } from './metrics'; const debug = createDebug('config'); @@ -102,10 +103,6 @@ export async function config( // freeze the merged config so it can't be modified by the package user Object.freeze(validatedConfig); - if (metricsRegistry) { - initializeMetrics(metricsRegistry, baseSchema.$id, serverConfigResponse?.version); - } - function get(path: TPath): GetFieldType { debug('get called with path: %s', path); // eslint-disable-next-line @typescript-eslint/no-unsafe-return @@ -131,5 +128,9 @@ export async function config( return getOptions(); } - return { get, getAll, getConfigParts, getResolvedOptions }; + function initializeMetrics(registry: Registry): void { + initializeMetricsInternal(registry, baseSchema.$id, serverConfigResponse?.version); + } + + return { get, getAll, getConfigParts, getResolvedOptions, initializeMetrics }; } diff --git a/src/metrics.ts b/src/metrics.ts index fb0c45a..c1a4d8c 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -11,16 +11,18 @@ const MILLISECONDS_PER_SECOND = 1000; let promClient: typeof import('prom-client') | undefined; function loadPromClient(): void { - if (promClient === undefined) { - debug('loading prom-client'); - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - promClient = require('prom-client'); - } catch (error) { - console.log('error', error); - - throw createConfigError('promClientNotInstalledError', 'prom-client is not installed and metrics was initialized', error as Error); - } + if (promClient !== undefined) { + return; + } + + debug('loading prom-client'); + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + promClient = require('prom-client'); + } catch (error) { + console.log('error', error); + + throw createConfigError('promClientNotInstalledError', 'prom-client is not installed and metrics was initialized', error as Error); } } @@ -51,7 +53,7 @@ export function initializeMetrics(registry: Registry, schemaId: string, actualVe schema_id: schemaId, }, - Date.now() / MILLISECONDS_PER_SECOND + Math.round(Date.now() / MILLISECONDS_PER_SECOND) ); /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/types.ts b/src/types.ts index dac9dfa..a0a12cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { typeSymbol } from '@map-colonies/schemas/build/schemas/symbol'; import { JSONSchemaType } from 'ajv'; +import { initial } from 'lodash'; import type { Registry } from 'prom-client'; type Prettify = { @@ -139,4 +140,10 @@ export interface ConfigInstance { * @returns The resolved options. */ getResolvedOptions: () => BaseOptions; + + /** + * Initializes the metrics for the configuration object. + * @param registry - The registry for the metrics. + */ + initializeMetrics: (registry: Registry) => void; } From 10029e40aac9b4ecf149396caa516a052ef30fca Mon Sep 17 00:00:00 2001 From: netanelC Date: Wed, 30 Oct 2024 13:51:49 +0200 Subject: [PATCH 3/3] fix: PR issues --- src/metrics.ts | 2 -- src/types.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/metrics.ts b/src/metrics.ts index c1a4d8c..6783669 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -20,8 +20,6 @@ function loadPromClient(): void { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment promClient = require('prom-client'); } catch (error) { - console.log('error', error); - throw createConfigError('promClientNotInstalledError', 'prom-client is not installed and metrics was initialized', error as Error); } } diff --git a/src/types.ts b/src/types.ts index a0a12cc..13422b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,5 @@ import { typeSymbol } from '@map-colonies/schemas/build/schemas/symbol'; import { JSONSchemaType } from 'ajv'; -import { initial } from 'lodash'; import type { Registry } from 'prom-client'; type Prettify = {