Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added metrics #15

Merged
merged 3 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,5 @@ dist

# reports of jest tests
reports

src/version.ts
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -243,6 +247,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.

Expand Down
50 changes: 49 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 13 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
}
}
10 changes: 10 additions & 0 deletions scripts/generateVersionFile.mjs
Original file line number Diff line number Diff line change
@@ -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`;
netanelC marked this conversation as resolved.
Show resolved Hide resolved

writeFileSync(versionFile, content);
15 changes: 11 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ 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 } from './types';
import { BaseOptions, ConfigOptions, ConfigInstance, Config } from './types';
import { loadSchema } from './schemas';
import { getOptions, initializeOptions } from './options';
import { getRemoteConfig, getServerCapabilities } from './httpClient';
import { ajvConfigValidator, validate } from './validator';
import { createDebug } from './utils/debug';
import { LOCAL_SCHEMAS_PACKAGE_VERSION } from './constants';
import { createConfigError } from './errors';
import { initializeMetrics as initializeMetricsInternal } from './metrics';

const debug = createDebug('config');

Expand All @@ -30,11 +32,12 @@ export async function config<T extends { [typeSymbol]: unknown; $id: string }>(
): Promise<ConfigInstance<T[typeof typeSymbol]>> {
// 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');
Expand All @@ -58,7 +61,7 @@ export async function config<T extends { [typeSymbol]: unknown; $id: string }>(
}

// 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);
Expand Down Expand Up @@ -125,5 +128,9 @@ export async function config<T extends { [typeSymbol]: unknown; $id: string }>(
return getOptions();
}

return { get, getAll, getConfigParts, getResolvedOptions };
function initializeMetrics(registry: Registry): void {
initializeMetricsInternal(registry, baseSchema.$id, serverConfigResponse?.version);
}

return { get, getAll, getConfigParts, getResolvedOptions, initializeMetrics };
}
1 change: 1 addition & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { code: number; payload: any }>;

Expand Down
59 changes: 59 additions & 0 deletions src/metrics.ts
Original file line number Diff line number Diff line change
@@ -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) {
return;
}

debug('loading prom-client');
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
promClient = require('prom-client');
} catch (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,
},

Math.round(Date.now() / MILLISECONDS_PER_SECOND)
);

/* eslint-enable @typescript-eslint/naming-convention */
registry.registerMetric(gauge);
}
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
[K in keyof T]: T[K];
Expand Down Expand Up @@ -77,6 +78,11 @@ export type ConfigOptions<T extends SchemaWithType> = 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;
}
>;

Expand Down Expand Up @@ -133,4 +139,10 @@ export interface ConfigInstance<T> {
* @returns The resolved options.
*/
getResolvedOptions: () => BaseOptions;

/**
* Initializes the metrics for the configuration object.
* @param registry - The registry for the metrics.
*/
initializeMetrics: (registry: Registry) => void;
}
40 changes: 40 additions & 0 deletions tests/metrics.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getOptions>;

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,
});
});
});
});
Loading