diff --git a/docs/docs/logging.md b/docs/docs/logging.md index adf14e0b487e..bacd944af9eb 100644 --- a/docs/docs/logging.md +++ b/docs/docs/logging.md @@ -30,9 +30,52 @@ It is also useful for debugging problems with entity discovery, as you will see [discovery] - entity discovery finished after 13 ms ``` -## Custom Logger +## Disabling colored output + +To disable colored output, you can use multiple environment variables: -We can also provide our own logger function via `logger` option. +- `NO_COLOR` +- `MIKRO_ORM_NO_COLOR` +- `FORCE_COLOR` + +## Logger Namespaces + +There are multiple Logger Namespaces that you can specifically request, while omitting the rest. Just specify array of them via the `debug` option: + +```ts +return MikroORM.init({ + debug: ['query'], // now only queries will be logged +}); +``` + +Currently, there are 5 namespaces – `query`, `query-params`, `schema`, `discovery` and `info`. + +If you provide `query-params` then you must also provide `query` in order for it to take effect. + +## Highlighters + +Previously Highlight.js was used to highlight various things in the CLI, like SQL and mongo queries, or migrations or entities generated via CLI. While the library worked fine, it was causing performance issues mainly for those bundling via webpack and using lambdas, as the library was huge. + +Since v4, highlighting is disabled by default, and there are 2 highlighters you can optionally use (you need to install them first). + +```ts +import { SqlHighlighter } from '@mikro-orm/sql-highlighter'; + +MikroORM.init({ + highlighter: new SqlHighlighter(), + // ... +}); +``` + +For MongoDB you can use `MongoHighlighter` from `@mikro-orm/mongo-highlighter` package. + +## Logger Customization + +Several customization options exist to allow for style changes or custom logic. + +### Using a custom logger + +You can provide your own logger function via the `logger` option: ```ts return MikroORM.init({ @@ -41,7 +84,41 @@ return MikroORM.init({ }); ``` -If we want to have more control over logging, we can use `loggerFactory` and use our own implementation of the `Logger` interface: +### Using a custom `LoggerFactory` + +If you want more control over what is logged and how, use the `loggerFactory` option in your config and extend the `SimpleLogger` class, extend the `DefaultLogger` class, or make your `Logger` from scratch: + +#### Extending `DefaultLogger` or `SimpleLogger` + +You can extend the `DefaultLogger` or `SimpleLogger` instead of implementing everything from scratch. `DefaultLogger` and `SimpleLogger` are both exported from the `@mikro-orm/core` package with `SimpleLogger` being colorless. + +```ts +class CustomLogger extends DefaultLogger { + log(namespace: LoggerNamespace, message: string, context?: LogContext) { + // Create your own implementation for output: + console.log(`[${namespace}] (${context.label}) ${message}`); + + // OR Utilize DefaultLogger's implementation: + super.log(namespace, message, context) + } +} + +return MikroORM.init({ + debug: true, + loggerFactory: (options) => new CustomLogger(options), +}); +``` + +To use `SimpleLogger` instead, simply replace `DefaultLogger` in the example above: +```ts +class CustomLogger extends SimpleLogger { + // ... +} +``` + +#### Creating a custom logger from scratch + +You can use `loggerFactory` and use your own implementation of the `Logger` interface: ```ts import { Logger, LoggerOptions, MikroORM, Configuration } from '@mikro-orm/core'; @@ -52,12 +129,10 @@ class MyLogger implements Logger { const orm = await MikroORM.init({ debug: true, - loggerFactory: (options: LoggerOptions) => new MyLogger(config), + loggerFactory: (options) => new MyLogger(options), }); ``` -We can also extend the `DefaultLogger` instead of implementing everything from scratch. It is also exported from the `@mikro-orm/core` package. - The `Logger` interface is defined as follows: ```ts @@ -70,10 +145,11 @@ interface Logger { isEnabled(namespace: LoggerNamespace): boolean; } -type LoggerNamespace = 'query' | 'query-params' | 'discovery' | 'info'; +type LoggerNamespace = 'query' | 'query-params' | 'schema' | 'discovery' | 'info'; -interface LogContext { +interface LogContext extends Dictionary { query?: string; + label?: string; params?: unknown[]; took?: number; level?: 'info' | 'warning' | 'error'; @@ -84,41 +160,29 @@ interface LogContext { } ``` -## Disabling colored output - -To disable colored output, we can use multiple environment variables: - -- `NO_COLOR` -- `MIKRO_ORM_NO_COLOR` -- `FORCE_COLOR` +### Query Labels -## Logger Namespaces +It may often be beneficial to log the origin of a query when using [`EntityManager.find`](entity-manager.md#fetching-entities-with-entitymanager) or [`EntityManager.findOne`](entity-manager.md#fetching-entities-with-entitymanager) for debugging and redundancy elimination purposes. -There are multiple Logger Namespaces that you can specifically request, while omitting the rest. Just specify array of them via the `debug` option: +An optional `loggerContext` option can be included within the `FindOptions` parameter of either call which will add a label to the output when debug mode is enabled. ```ts -return MikroORM.init({ - debug: ['query'], // now only queries will be logged -}); +const author = await em.findOne(Author, { id: 1 }, { loggerContext: { label: 'Author Retrieval - /authors/me' } }); +// [query] (Author Retrieval - /authors/me) select "a0".* from "Author" as "a0" where "a0"."id" = 1 limit 1 [took 21 ms] ``` -Currently, there are 4 namespaces – `query`, `query-params`, `discovery` and `info`. - -If you provide `query-params` then you must also provide `query` in order for it to take effect. - -## Highlighters - -Previously Highlight.js was used to highlight various things in the CLI, like SQL and mongo queries, or migrations or entities generated via CLI. While the library worked fine, it was causing performance issues mainly for those bundling via webpack and using lambdas, as the library was huge. - -Since v4, highlighting is disabled by default, and there are 2 highlighters you can optionally use (you need to install them first). +### Providing additional context to a custom logger +If you have implemented your own `LoggerFactory` and need to access additional contextual values inside your customer logger implementation, utilize the `LoggerContext` property of `FindOptions`. Adding additional key/value pairs to that object will make them available inside your custom logger: ```ts -import { SqlHighlighter } from '@mikro-orm/sql-highlighter'; +const author = await em.findOne(Author, { id: 1 }, { loggerContext: { meaningOfLife: 42 } }); -MikroORM.init({ - highlighter: new SqlHighlighter(), - // ... -}); -``` +// ... -For MongoDB you can use `MongoHighlighter` from `@mikro-orm/mongo-highlighter` package. +class CustomLogger extends DefaultLogger { + log(namespace: LoggerNamespace, message: string, context?: LogContext) { + console.log(context?.meaningOfLife); + // 42 + } +} +``` \ No newline at end of file diff --git a/packages/core/src/drivers/IDatabaseDriver.ts b/packages/core/src/drivers/IDatabaseDriver.ts index 8e456541c682..2be2d8905aa0 100644 --- a/packages/core/src/drivers/IDatabaseDriver.ts +++ b/packages/core/src/drivers/IDatabaseDriver.ts @@ -10,6 +10,7 @@ import type { Collection } from '../entity/Collection'; import type { EntityManager } from '../EntityManager'; import type { DriverException } from '../exceptions'; import type { Configuration } from '../utils/Configuration'; +import type { LoggerContext } from '../logging'; export const EntityManagerType = Symbol('EntityManagerType'); @@ -128,6 +129,7 @@ export interface FindOptions extends Omit, 'limit' | 'offset'> { diff --git a/packages/core/src/logging/DefaultLogger.ts b/packages/core/src/logging/DefaultLogger.ts index ae484cb8c23f..fab35f32fb11 100644 --- a/packages/core/src/logging/DefaultLogger.ts +++ b/packages/core/src/logging/DefaultLogger.ts @@ -32,7 +32,11 @@ export class DefaultLogger implements Logger { message = colors.yellow(message); } - this.writer(colors.grey(`[${namespace}] `) + message); + const label = context?.label + ? colors.cyan(`(${context.label}) `) + : ''; + + this.writer(colors.grey(`[${namespace}] `) + label + message); } /** diff --git a/packages/core/src/logging/Logger.ts b/packages/core/src/logging/Logger.ts index cbefcdf718fd..59099cec60ae 100644 --- a/packages/core/src/logging/Logger.ts +++ b/packages/core/src/logging/Logger.ts @@ -1,4 +1,4 @@ -import type { Highlighter } from '../typings'; +import type { Dictionary, Highlighter } from '../typings'; export interface Logger { @@ -33,8 +33,9 @@ export interface Logger { export type LoggerNamespace = 'query' | 'query-params' | 'schema' | 'discovery' | 'info'; -export interface LogContext { +export interface LogContext extends Dictionary { query?: string; + label?: string; params?: unknown[]; took?: number; level?: 'info' | 'warning' | 'error'; @@ -50,3 +51,12 @@ export interface LoggerOptions { highlighter?: Highlighter; usesReplicas?: boolean; } + +/** + * Context for a logger to utilize to format output, including a label and additional properties that can be accessed by custom loggers + * + * @example + * await em.findOne(User, 1, { loggerContext: { label: 'user middleware' } }; + * // [query] (user middleware) select * from user where id = 1; + */ +export type LoggerContext = Pick & Dictionary; diff --git a/packages/core/src/logging/SimpleLogger.ts b/packages/core/src/logging/SimpleLogger.ts index 2eb261de635e..f8022e59d880 100644 --- a/packages/core/src/logging/SimpleLogger.ts +++ b/packages/core/src/logging/SimpleLogger.ts @@ -1,6 +1,9 @@ import type { LogContext, LoggerNamespace } from './Logger'; import { DefaultLogger } from './DefaultLogger'; +/** + * A basic logger that provides fully formatted output without color + */ export class SimpleLogger extends DefaultLogger { /** @@ -13,8 +16,9 @@ export class SimpleLogger extends DefaultLogger { // clean up the whitespace message = message.replace(/\n/g, '').replace(/ +/g, ' ').trim(); + const label = context?.label ? `(${context.label}) ` : ''; - this.writer(`[${namespace}] ${message}`); + this.writer(`[${namespace}] ${label}${message}`); } /** diff --git a/packages/knex/src/AbstractSqlConnection.ts b/packages/knex/src/AbstractSqlConnection.ts index 436966bc0b4d..623963567f40 100644 --- a/packages/knex/src/AbstractSqlConnection.ts +++ b/packages/knex/src/AbstractSqlConnection.ts @@ -2,7 +2,7 @@ import type { Knex } from 'knex'; import { knex } from 'knex'; import { readFile } from 'fs-extra'; import type { - AnyEntity, Configuration, ConnectionOptions, EntityData, IsolationLevel, QueryResult, + AnyEntity, Configuration, ConnectionOptions, EntityData, IsolationLevel, LoggerContext, QueryResult, Transaction, TransactionEventBroadcaster } from '@mikro-orm/core'; import { Connection, EventType, Utils, } from '@mikro-orm/core'; @@ -114,7 +114,7 @@ export abstract class AbstractSqlConnection extends Connection { } } - async execute | EntityData[] = EntityData[]>(queryOrKnex: string | Knex.QueryBuilder | Knex.Raw, params: unknown[] = [], method: 'all' | 'get' | 'run' = 'all', ctx?: Transaction): Promise { + async execute | EntityData[] = EntityData[]>(queryOrKnex: string | Knex.QueryBuilder | Knex.Raw, params: unknown[] = [], method: 'all' | 'get' | 'run' = 'all', ctx?: Transaction, loggerContext?: LoggerContext): Promise { await this.ensureConnection(); if (Utils.isObject(queryOrKnex)) { @@ -134,7 +134,7 @@ export abstract class AbstractSqlConnection extends Connection { } return query; - }, { query: queryOrKnex, params }); + }, { query: queryOrKnex, params, ...loggerContext }); return this.transformRawResult(res, method); } diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index 067a26ef269c..a12413bec367 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -34,6 +34,7 @@ import type { EntityKey, EntityValue, OrderDefinition, + LoggerContext, } from '@mikro-orm/core'; import { DatabaseDriver, @@ -85,7 +86,7 @@ export abstract class AbstractSqlDriver[], options.fields); const joinedProps = this.joinedProps(meta, populate); - const qb = this.createQueryBuilder(entityName, options.ctx, options.connectionType, false); + const qb = this.createQueryBuilder(entityName, options.ctx, options.connectionType, false, options.loggerContext); const fields = this.buildFields(meta, populate, joinedProps, qb, options.fields as unknown as Field[]); const joinedPropsOrderBy = this.buildJoinedPropsOrderBy(entityName, qb, meta, joinedProps); const orderBy = [...Utils.asArray(options.orderBy), ...joinedPropsOrderBy]; @@ -843,9 +844,18 @@ export abstract class AbstractSqlDriver(entityName: EntityName | QueryBuilder, ctx?: Transaction, preferredConnectionType?: ConnectionType, convertCustomTypes?: boolean): QueryBuilder { + createQueryBuilder(entityName: EntityName | QueryBuilder, ctx?: Transaction, preferredConnectionType?: ConnectionType, convertCustomTypes?: boolean, loggerContext?: LoggerContext): QueryBuilder { const connectionType = this.resolveConnectionType({ ctx, connectionType: preferredConnectionType }); - const qb = new QueryBuilder(entityName, this.metadata, this, ctx, undefined, connectionType); + const qb = new QueryBuilder( + entityName, + this.metadata, + this, + ctx, + undefined, + connectionType, + undefined, + loggerContext, + ); if (!convertCustomTypes) { qb.unsetFlag(QueryFlag.CONVERT_CUSTOM_TYPES); diff --git a/packages/knex/src/query/QueryBuilder.ts b/packages/knex/src/query/QueryBuilder.ts index 4f3d9d0fcc5b..a8cb3998e8be 100644 --- a/packages/knex/src/query/QueryBuilder.ts +++ b/packages/knex/src/query/QueryBuilder.ts @@ -13,6 +13,7 @@ import type { FlatQueryOrderMap, FlushMode, GroupOperator, + LoggerContext, MetadataStorage, ObjectQuery, PopulateOptions, @@ -127,7 +128,8 @@ export class QueryBuilder { private readonly context?: Knex.Transaction, alias?: string, private connectionType?: ConnectionType, - private readonly em?: SqlEntityManager) { + private readonly em?: SqlEntityManager, + private readonly loggerContext?: LoggerContext) { if (alias) { this.aliasCounter++; this._explicitAlias = true; @@ -608,7 +610,7 @@ export class QueryBuilder { } const type = this.connectionType || (method === 'run' ? 'write' : 'read'); - const res = await this.driver.getConnection(type).execute(query.sql, query.bindings as any[], method, this.context); + const res = await this.driver.getConnection(type).execute(query.sql, query.bindings as any[], method, this.context, this.loggerContext); const meta = this.mainAlias.metadata; if (!mapResults || !meta) { diff --git a/packages/mongodb/src/MongoConnection.ts b/packages/mongodb/src/MongoConnection.ts index 45247f9c6544..8a3ed0e0edd4 100644 --- a/packages/mongodb/src/MongoConnection.ts +++ b/packages/mongodb/src/MongoConnection.ts @@ -27,6 +27,7 @@ import type { EntityName, FilterQuery, IsolationLevel, + LoggerContext, QueryOrderMap, QueryResult, Transaction, @@ -143,7 +144,7 @@ export class MongoConnection extends Connection { throw new Error(`${this.constructor.name} does not support generic execute method`); } - async find(collection: string, where: FilterQuery, orderBy?: QueryOrderMap | QueryOrderMap[], limit?: number, offset?: number, fields?: string[], ctx?: Transaction): Promise[]> { + async find(collection: string, where: FilterQuery, orderBy?: QueryOrderMap | QueryOrderMap[], limit?: number, offset?: number, fields?: string[], ctx?: Transaction, loggerContext?: LoggerContext): Promise[]> { await this.ensureConnection(); collection = this.getCollectionName(collection); const options: Dictionary = ctx ? { session: ctx } : {}; @@ -183,7 +184,7 @@ export class MongoConnection extends Connection { const now = Date.now(); const res = await resultSet.toArray(); - this.logQuery(`${query}.toArray();`, { took: Date.now() - now }); + this.logQuery(`${query}.toArray();`, { took: Date.now() - now, ...loggerContext }); return res as EntityData[]; } @@ -208,7 +209,7 @@ export class MongoConnection extends Connection { return this.runQuery('deleteMany', collection, undefined, where, ctx); } - async aggregate(collection: string, pipeline: any[], ctx?: Transaction): Promise { + async aggregate(collection: string, pipeline: any[], ctx?: Transaction, loggerContext?: LoggerContext): Promise { await this.ensureConnection(); collection = this.getCollectionName(collection); /* istanbul ignore next */ @@ -216,7 +217,7 @@ export class MongoConnection extends Connection { const query = `db.getCollection('${collection}').aggregate(${this.logObject(pipeline)}, ${this.logObject(options)}).toArray();`; const now = Date.now(); const res = this.getCollection(collection).aggregate(pipeline, options).toArray(); - this.logQuery(query, { took: Date.now() - now }); + this.logQuery(query, { took: Date.now() - now, ...loggerContext }); return res; } @@ -273,7 +274,7 @@ export class MongoConnection extends Connection { await eventBroadcaster?.dispatchEvent(EventType.afterTransactionRollback, ctx); } - private async runQuery | number = QueryResult>(method: 'insertOne' | 'insertMany' | 'updateMany' | 'bulkUpdateMany' | 'deleteMany' | 'countDocuments', collection: string, data?: Partial | Partial[], where?: FilterQuery | FilterQuery[], ctx?: Transaction, upsert?: boolean): Promise { + private async runQuery | number = QueryResult>(method: 'insertOne' | 'insertMany' | 'updateMany' | 'bulkUpdateMany' | 'deleteMany' | 'countDocuments', collection: string, data?: Partial | Partial[], where?: FilterQuery | FilterQuery[], ctx?: Transaction, upsert?: boolean, loggerContext?: LoggerContext): Promise { await this.ensureConnection(); collection = this.getCollectionName(collection); const logger = this.config.getLogger(); @@ -336,7 +337,7 @@ export class MongoConnection extends Connection { break; } - this.logQuery(query!, { took: Date.now() - now }); + this.logQuery(query!, { took: Date.now() - now, ...loggerContext }); if (method === 'countDocuments') { return res! as unknown as U; diff --git a/tests/Logger.test.ts b/tests/Logger.test.ts index 473307decc52..ed3f7c21f9bf 100644 --- a/tests/Logger.test.ts +++ b/tests/Logger.test.ts @@ -1,43 +1,111 @@ -import { Configuration, DefaultLogger } from '@mikro-orm/core'; +import { DefaultLogger, SimpleLogger, colors } from '@mikro-orm/core'; -describe('Logger', () => { +// Allow for testing colored output and prevent colors from causing match failures (invis. chars) +const redColorFormatterSpy = jest.spyOn(colors, 'red').mockImplementation(text => text); +const greyColorFormatterSpy = jest.spyOn(colors, 'grey').mockImplementation(text => text); +const cyanColorFormatterSpy = jest.spyOn(colors, 'cyan').mockImplementation(text => text); +const yellowColorFormatterSpy = jest.spyOn(colors, 'yellow').mockImplementation(text => text); + +const mockWriter = jest.fn(); - test('should have debug mode disabled by default', async () => { - const mock = jest.fn(); - const logger = new DefaultLogger({ writer: mock }); - expect(logger.debugMode).toBe(false); - logger.log('discovery', 'test debug msg'); - expect(mock.mock.calls.length).toBe(0); - logger.log('info', 'test info msg'); - expect(mock.mock.calls.length).toBe(0); +describe('Logger', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - test('should print debug messages when debug mode enabled', async () => { - const mock = jest.fn(); - const logger = new DefaultLogger({ writer: mock, debugMode: true }); - expect(logger.debugMode).toBe(true); - logger.log('discovery', 'test debug msg'); - expect(mock.mock.calls.length).toBe(1); - logger.log('info', 'test info msg'); - expect(mock.mock.calls.length).toBe(2); - logger.log('query', 'test query msg'); - expect(mock.mock.calls.length).toBe(3); + describe('DefaultLogger', () => { + + test('should have debug mode disabled by default', async () => { + const logger = new DefaultLogger({ writer: mockWriter }); + expect(logger.debugMode).toBe(false); + logger.log('discovery', 'test debug msg'); + logger.log('info', 'test info msg'); + expect(mockWriter).toBeCalledTimes(0); + }); + + test('should print debug messages when debug mode enabled', async () => { + const logger = new DefaultLogger({ writer: mockWriter, debugMode: true }); + expect(logger.debugMode).toBe(true); + logger.log('discovery', 'test debug msg'); + expect(mockWriter).toBeCalledTimes(1); + logger.log('info', 'test info msg'); + expect(mockWriter).toBeCalledTimes(2); + logger.log('query', 'test query msg'); + expect(mockWriter).toBeCalledTimes(3); + }); + + test('should not print debug messages when given namespace not enabled', async () => { + const logger = new DefaultLogger({ writer: mockWriter, debugMode: ['query'] }); + expect(logger.debugMode).toEqual(['query']); + logger.log('discovery', 'test debug msg'); + expect(mockWriter).toBeCalledTimes(0); + logger.log('info', 'test info msg'); + expect(mockWriter).toBeCalledTimes(0); + logger.log('query', 'test query msg'); + expect(mockWriter).toBeCalledTimes(1); + logger.error('query', 'test error msg'); + expect(mockWriter).toBeCalledTimes(2); + logger.warn('query', 'test warning msg'); + expect(mockWriter).toBeCalledTimes(3); + }); + + test('should print labels correctly', () => { + const logger = new DefaultLogger({ writer: mockWriter, debugMode: ['query'] }); + const namespace = 'query'; + const message = 'test label msg'; + const label = 'hello world handler'; + logger.log(namespace, message, { label }); + expect(mockWriter).toBeCalledWith(`[${namespace}] (${label}) ${message}`); + }); + + test('should print values with the appropriate colors', () => { + const logger = new DefaultLogger({ writer: mockWriter, debugMode: ['query'] }); + const namespace = 'query'; + const label = 'handler'; + const message = 'test label msg'; + + logger.log(namespace, message, { level: 'error', label }); + expect(greyColorFormatterSpy).toBeCalledWith(`[${namespace}] `); + expect(redColorFormatterSpy).toBeCalledWith(message); + expect(cyanColorFormatterSpy).toBeCalledWith(`(${label}) `); + expect(yellowColorFormatterSpy).not.toBeCalled(); + + jest.clearAllMocks(); + + logger.log(namespace, message, { level: 'warning', label }); + expect(greyColorFormatterSpy).toBeCalledWith(`[${namespace}] `); + expect(yellowColorFormatterSpy).toBeCalledWith(message); + expect(cyanColorFormatterSpy).toBeCalledWith(`(${label}) `); + expect(redColorFormatterSpy).not.toBeCalled(); + + jest.clearAllMocks(); + + logger.log(namespace, message, { level: 'info', label }); + expect(greyColorFormatterSpy).toBeCalledWith(`[${namespace}] `); + expect(cyanColorFormatterSpy).toBeCalledWith(`(${label}) `); + expect(yellowColorFormatterSpy).not.toBeCalled(); + expect(redColorFormatterSpy).not.toBeCalled(); + }); }); - test('should not print debug messages when given namespace not enabled', async () => { - const mock = jest.fn(); - const logger = new DefaultLogger({ writer: mock, debugMode: ['query'] }); - expect(logger.debugMode).toEqual(['query']); - logger.log('discovery', 'test debug msg'); - expect(mock.mock.calls.length).toBe(0); - logger.log('info', 'test info msg'); - expect(mock.mock.calls.length).toBe(0); - logger.log('query', 'test query msg'); - expect(mock.mock.calls.length).toBe(1); - logger.error('query', 'test error msg'); - expect(mock.mock.calls.length).toBe(2); - logger.warn('query', 'test warning msg'); - expect(mock.mock.calls.length).toBe(3); + describe('SimpleLogger', () => { + + test('should print correctly without a label', () => { + const logger = new SimpleLogger({ writer: mockWriter, debugMode: ['query'] }); + const namespace = 'query'; + const message = 'test label msg'; + logger.log(namespace, message); + expect(mockWriter).toBeCalledWith(`[${namespace}] ${message}`); + }); + + test('should print labels correctly', () => { + const logger = new SimpleLogger({ writer: mockWriter, debugMode: ['query'] }); + const namespace = 'query'; + const message = 'test label msg'; + const label = 'hello world handler'; + logger.log(namespace, message, { label }); + expect(mockWriter).toBeCalledWith(`[${namespace}] (${label}) ${message}`); + }); }); });