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: logs pass #57

Merged
merged 2 commits into from
Oct 28, 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
243 changes: 180 additions & 63 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"@apidevtools/json-schema-ref-parser": "^11.5.4",
"@godaddy/terminus": "^4.12.1",
"@map-colonies/error-express-handler": "^2.1.0",
"@map-colonies/express-access-log-middleware": "^2.0.1",
"@map-colonies/express-access-log-middleware": "^2.1.0",
"@map-colonies/js-logger": "^1.0.1",
"@map-colonies/openapi-express-viewer": "^3.0.0",
"@map-colonies/read-pkg": "0.0.1",
Expand All @@ -69,6 +69,7 @@
"express-openapi-validator": "^5.0.4",
"http-status-codes": "^2.2.0",
"json-pointer": "0.6.2",
"lodash": "^4.17.21",
"pg": "^8.11.5",
"reflect-metadata": "^0.1.13",
"tsyringe": "^4.8.0"
Expand All @@ -87,6 +88,7 @@
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/json-pointer": "^1.0.34",
"@types/lodash": "^4.17.12",
"@types/multer": "^1.4.7",
"@types/pg": "^8.11.5",
"@types/supertest": "^2.0.12",
Expand Down
60 changes: 60 additions & 0 deletions src/common/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { get } from 'lodash';
import jsLogger, { Logger, LoggerOptions } from '@map-colonies/js-logger';
import { getOtelMixin } from '@map-colonies/telemetry';
import { NextFunction, Request, Response } from 'express';
import { DependencyContainer } from 'tsyringe';
import { SERVICES } from './constants';
import { IConfig } from './interfaces';

const logContext = new AsyncLocalStorage<object>();

export function addOperationIdToLog(req: IncomingMessage, res: ServerResponse, loggableObject: Record<string, unknown>): unknown {
const operationId = get(req, 'openapi.schema.operationId') as string | undefined;
if (operationId !== undefined) {
loggableObject['operationId'] = operationId;
}
return loggableObject;
}

export function enrichLogContext(values: object): void {
const store = logContext.getStore();
if (store) {
Object.assign(store, values);
}
}

export function loggerFactory(container: DependencyContainer): Logger {
const config = container.resolve<IConfig>(SERVICES.CONFIG);
const loggerConfig = config.get<LoggerOptions>('telemetry.logger');

const logger = jsLogger({
...loggerConfig,
mixin: (mergeObj, level) => {
const otelMixin = getOtelMixin();
const store = logContext.getStore();
return { ...otelMixin(mergeObj, level), ...store };
},
});

return logger;
}

export function logContextInjectionMiddleware(req: Request, res: Response, next: NextFunction): void {
logContext.run({}, () => {
next();
});
}

export function logEnrichmentParamMiddlewareFactory(
logEntry: string
): (req: Request, res: Response, next: NextFunction, paramValue: unknown) => void {
return function (req: Request, res: Response, next: NextFunction, paramValue: unknown): void {
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (paramValue) {
enrichLogContext({ [logEntry]: paramValue });
}
next();
};
}
3 changes: 3 additions & 0 deletions src/configs/controllers/configController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
SortQueryRepeatError,
} from '../models/errors';
import { SchemaNotFoundError } from '../../schemas/models/errors';
import { enrichLogContext } from '../../common/logger';

function configMapper(config: Config): components['schemas']['config'] {
return {
Expand Down Expand Up @@ -111,6 +112,8 @@ export class ConfigController {

public postConfig: TypedRequestHandler<'/config', 'post'> = async (req, res, next) => {
try {
enrichLogContext({ configName: req.body.configName, version: req.body.version, schemaId: req.body.schemaId });

await this.manager.createConfig(req.body);
return res.status(httpStatus.CREATED).json();
} catch (error) {
Expand Down
23 changes: 23 additions & 0 deletions src/configs/models/configManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { parseISO } from 'date-fns';
import type { Prettify } from '../../common/interfaces';
import { ConfigRepository, ConfigSearchParams, SqlPaginationParams } from '../repositories/configRepository';
import { SERVICES } from '../../common/constants';
import { enrichLogContext } from '../../common/logger';
import { paths, components } from '../../openapiTypes';
import { Config, SortOption } from './config';
import { Validator } from './configValidator';
Expand All @@ -24,29 +25,39 @@ export class ConfigManager {

public async getConfig(name: string, version?: number, shouldDereferenceConfig?: boolean): Promise<Config> {
if (shouldDereferenceConfig !== true) {
this.logger.debug('Retrieving config from the database with unresolved refs');
const config = await this.configRepository.getConfig(name, version);

if (!config) {
throw new ConfigNotFoundError('Config not found');
}

enrichLogContext({ resolvedConfigVersion: config.version });
return config;
}

this.logger.debug('Retrieving config from the database with resolved refs');

const res = await this.configRepository.getConfigRecursive(name, version);
if (!res) {
throw new ConfigNotFoundError('Config not found');
}

enrichLogContext({ resolvedConfigVersion: res[0].version });

const [config, refs] = res;

if (refs.length > 0) {
this.logger.debug('Resolving refs for config', { refCount: refs.length });
this.replaceRefs(config.config, refs);
}

return config;
}

public async getConfigs(options?: GetConfigOptions): Promise<{ configs: Config[]; totalCount: number }> {
this.logger.debug('Preparing search params and retrieving configs from the database');

const searchParams: ConfigSearchParams = {};
let paginationParams: SqlPaginationParams = {};
let sortParams: SortOption[] = [];
Expand Down Expand Up @@ -75,6 +86,9 @@ export class ConfigManager {
}

public async createConfig(config: Omit<components['schemas']['config'], 'createdAt' | 'createdBy'>): Promise<void> {
this.logger.debug('Creating a new config');

this.logger.debug('fetching latest config with same name for validations');
const latestConfig = await this.configRepository.getConfig(config.configName);

if (!latestConfig && config.version !== 1) {
Expand Down Expand Up @@ -106,6 +120,7 @@ export class ConfigManager {
}

if (latestConfig !== undefined) {
this.logger.debug('a config with the same name already exists, incrementing version');
config.version++;
}

Expand All @@ -119,6 +134,7 @@ export class ConfigManager {
* @throws {ConfigValidationError} If the config reference is not valid.
*/
private listConfigRefs(config: components['schemas']['config']['config']): ConfigReference[] {
this.logger.debug('Listing all the config references in the config object');
const refs: ConfigReference[] = [];

pointer.walk(config, (val, key) => {
Expand All @@ -143,19 +159,24 @@ export class ConfigManager {
* @throws {ConfigValidationError} If the configuration is not valid.
*/
private replaceRefs(obj: JsonObject, refs: Awaited<ReturnType<typeof this.configRepository.getAllConfigRefs>>): void {
this.logger.debug('Replacing all the references in the object with the corresponding values');

// the input is not an object or an array so we don't need to do anything
if (!Array.isArray(obj) && typeof obj !== 'object') {
this.logger.debug('The object is not an object or an array, skipping');
return;
}

const paths = new Map<string, ConfigReference>();

this.logger.debug('Finding all the references in the object');
// find all the references in the object
pointer.walk(obj, (val, key) => {
if (key.endsWith('$ref/configName')) {
const refPath = key.slice(0, key.lastIndexOf('/'));
const ref = pointer.get(obj, refPath) as unknown;
if (!this.configValidator.validateRef(ref)) {
this.logger.debug('The reference in the following path is not valid', { refPath });
throw new ConfigValidationError(`The reference in the following path ${refPath} is not valid`);
}

Expand All @@ -164,6 +185,7 @@ export class ConfigManager {
});

for (const [path, ref] of paths) {
this.logger.debug('Replacing the reference in the object', { refPath: path, referenceObject: ref });
const config = refs.find((r) => r.configName === ref.configName && (ref.version === 'latest' || r.version === ref.version));
if (!config) {
throw new Error(`could not find ref in db: ${JSON.stringify(ref)}`);
Expand All @@ -182,6 +204,7 @@ export class ConfigManager {
}

if (path === '') {
this.logger.debug('The reference is in the root of the object, replacing the object with the reference');
Object.assign(obj, replacementValue);
return;
}
Expand Down
10 changes: 8 additions & 2 deletions src/configs/models/configValidator.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { readFileSync } from 'node:fs';
import Ajv, { AnySchemaObject, ErrorObject, ValidateFunction } from 'ajv/dist/2019';
import { injectable } from 'tsyringe';
import { inject, injectable } from 'tsyringe';
import addFormats from 'ajv-formats';
import { Logger } from '@map-colonies/js-logger';
import betterAjvErrors, { type IOutputError } from '@sidvind/better-ajv-errors';
import { SchemaManager } from '../../schemas/models/schemaManager';
import { SERVICES } from '../../common/constants';
import { ConfigReference, configReferenceSchema } from './configReference';

@injectable()
export class Validator {
private readonly ajv: Ajv;
private readonly ajvRefValidator: ValidateFunction;

public constructor(private readonly schemaManager: SchemaManager) {
public constructor(
private readonly schemaManager: SchemaManager,
@inject(SERVICES.LOGGER) private readonly logger: Logger
) {
const draft7MetaSchema = JSON.parse(
readFileSync(require.resolve('ajv/dist/refs/json-schema-draft-07.json'), { encoding: 'utf-8' })
) as AnySchemaObject;
Expand All @@ -31,6 +36,7 @@ export class Validator {
}

public async isValid(schemaId: string, data: unknown): Promise<[boolean, IOutputError[]?]> {
this.logger.info('Validating config data', { schemaId });
const validate = await this.ajv.compileAsync(await this.schemaManager.getSchema(schemaId));
const valid = validate(data);

Expand Down
13 changes: 12 additions & 1 deletion src/configs/repositories/configRepository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Logger, SQL, SQLWrapper, and, asc, desc, eq, gt, isNull, lt, or, sql } from 'drizzle-orm';
import { SQL, SQLWrapper, and, asc, desc, eq, gt, isNull, lt, or, sql } from 'drizzle-orm';
import { inject, scoped, Lifecycle } from 'tsyringe';
import { toDate } from 'date-fns-tz';
import { Logger } from '@map-colonies/js-logger';
import { SERVICES } from '../../common/constants';
import type { Drizzle } from '../../db/createConnection';
import { type Config, type NewConfig, type NewConfigRef, configs, configsRefs, SortOption } from '../models/config';
Expand Down Expand Up @@ -70,6 +71,7 @@ export class ConfigRepository {
) {}

public async getAllConfigRefs(refs: ConfigReference[]): Promise<ConfigRefResponse[]> {
this.logger.debug('Retrieving all config references', { refCount: refs.length });
const refsForSql = refs.map((ref) => ({ configName: ref.configName, version: ref.version === 'latest' ? null : ref.version }));
// query to transform the input into a postgresql recordset so it can be joined with the data
const inputCTE = sql`
Expand Down Expand Up @@ -153,17 +155,20 @@ export class ConfigRepository {
}));

await this.drizzle.transaction(async (tx) => {
this.logger.debug('Inserting the config into the database');
await tx
.insert(configs)
.values({ ...configData, isLatest: true })
.execute();

if (dbRefs.length > 0) {
this.logger.debug('Inserting the config references into the database');
await tx.insert(configsRefs).values(dbRefs).execute();
}

// set the previous version of the config to not be the latest if a previous version exists
if (config.version !== 1) {
this.logger.debug('Setting the previous version of the config to not be the latest');
await tx
.update(configs)
.set({ isLatest: false })
Expand All @@ -181,6 +186,7 @@ export class ConfigRepository {
* @returns A Promise that resolves to the retrieved configuration, or undefined if not found.
*/
public async getConfig(name: string, version?: number): Promise<Config | undefined> {
this.logger.debug('Retrieving the config from the database without resolving references');
const comparators = [eq(configs.configName, name)];

if (version !== undefined) {
Expand Down Expand Up @@ -208,6 +214,7 @@ export class ConfigRepository {
* @returns A promise that resolves to an array containing the configuration and its references, or undefined if not found.
*/
public async getConfigRecursive(name: string, version?: number): Promise<[Config, ConfigRefResponse[]] | undefined> {
this.logger.debug('Retrieving config and its references from the database');
// const maxVersion = maxVersionQueryBuilder(this.drizzle, name);

const versionOperator = version !== undefined ? eq(configs.version, version) : eq(configs.isLatest, true);
Expand Down Expand Up @@ -246,6 +253,7 @@ export class ConfigRepository {

const configResult = res.rows.shift();
if (!configResult) {
this.logger.debug('No config found with the specified name and version');
return undefined;
}

Expand Down Expand Up @@ -274,6 +282,7 @@ export class ConfigRepository {
paginationParams: SqlPaginationParams = { limit: 1, offset: 0 },
sortingParams: SortOption[] = []
): Promise<{ configs: Config[]; totalCount: number }> {
this.logger.debug('Retrieving configs with filters from the database');
const filterParams: SQLWrapper[] = this.getFilterParams(searchParams);

const orderByParams = sortingParams.map((sort) => (sort.order === 'asc' ? asc(configs[sort.field]) : desc(configs[sort.field])));
Expand All @@ -298,6 +307,7 @@ export class ConfigRepository {
const configsResult = await configsQuery.execute();

if (configsResult.length === 0) {
this.logger.debug('No configs found with the specified filters');
return { configs: [], totalCount: 0 };
}

Expand All @@ -317,6 +327,7 @@ export class ConfigRepository {
}

private getFilterParams(searchParams: ConfigSearchParams): SQLWrapper[] {
this.logger.debug('Building SQL filter params for the config search');
const filterParams: SQLWrapper[] = [];

if (searchParams.q !== undefined && searchParams.q !== '') {
Expand Down
4 changes: 4 additions & 0 deletions src/configs/routes/configRouter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Router } from 'express';
import { FactoryFunction } from 'tsyringe';
import { ConfigController } from '../controllers/configController';
import { logEnrichmentParamMiddlewareFactory } from '../../common/logger';

export const configRouterFactory: FactoryFunction<Router> = (dependencyContainer) => {
const router = Router();
const controller = dependencyContainer.resolve(ConfigController);

router.param('name', logEnrichmentParamMiddlewareFactory('configName'));
router.param('version', logEnrichmentParamMiddlewareFactory('version'));

router.get('/', controller.getConfigs);
router.post('/', controller.postConfig);
router.get('/:name', controller.getConfigByName);
Expand Down
16 changes: 9 additions & 7 deletions src/containerConfig.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import config from 'config';
import { getOtelMixin } from '@map-colonies/telemetry';
import { trace, metrics as OtelMetrics } from '@opentelemetry/api';
import { DependencyContainer } from 'tsyringe/dist/typings/types';
import jsLogger, { LoggerOptions } from '@map-colonies/js-logger';
import { instancePerContainerCachingFactory } from 'tsyringe';
import type { Pool } from 'pg';
import { Metrics } from '@map-colonies/telemetry';
import { SERVICES, SERVICE_NAME } from './common/constants';
import { tracing } from './common/tracing';
Expand All @@ -12,27 +11,30 @@ import { InjectionObject, registerDependencies } from './common/dependencyRegist
import { CAPABILITIES_ROUTER_SYMBOL, capabilitiesRouterFactory } from './capabilities/routes/capabilitiesRouter';
import { CONFIG_ROUTER_SYMBOL, configRouterFactory } from './configs/routes/configRouter';
import { initConnection, createDrizzle, createConnectionOptions, DbConfig } from './db/createConnection';
import { loggerFactory } from './common/logger';

export interface RegisterOptions {
override?: InjectionObject<unknown>[];
useChild?: boolean;
}

export async function registerExternalValues(options?: RegisterOptions): Promise<DependencyContainer> {
const loggerConfig = config.get<LoggerOptions>('telemetry.logger');
const logger = jsLogger({ ...loggerConfig, prettyPrint: loggerConfig.prettyPrint, mixin: getOtelMixin() });

const metrics = new Metrics();
metrics.start();

tracing.start();
const tracer = trace.getTracer(SERVICE_NAME);

const pool = await initConnection(createConnectionOptions(config.get<DbConfig>('db')));
let pool: Pool;
try {
pool = await initConnection(createConnectionOptions(config.get<DbConfig>('db')));
} catch (error) {
throw new Error(`Failed to connect to the database`, { cause: error });
}

const dependencies: InjectionObject<unknown>[] = [
{ token: SERVICES.CONFIG, provider: { useValue: config } },
{ token: SERVICES.LOGGER, provider: { useValue: logger } },
{ token: SERVICES.LOGGER, provider: { useFactory: instancePerContainerCachingFactory(loggerFactory) } },
{ token: SERVICES.TRACER, provider: { useValue: tracer } },
{ token: SERVICES.METER, provider: { useValue: OtelMetrics.getMeterProvider().getMeter(SERVICE_NAME) } },
{ token: SCHEMA_ROUTER_SYMBOL, provider: { useFactory: schemaRouterFactory } },
Expand Down
Loading
Loading