From f244e61d206088e732b9c0a91812197b36e4ad10 Mon Sep 17 00:00:00 2001 From: Thomas Cardonne Date: Sat, 2 Aug 2025 01:08:02 +0200 Subject: [PATCH] feat(backend-defaults): add backend.logger configuration options Signed-off-by: Thomas Cardonne --- .changeset/silly-horses-share.md | 9 ++ .../core-services/root-logger.md | 45 +++++++++ packages/backend-defaults/config.d.ts | 62 ++++++++++++- .../backend-defaults/report-rootLogger.api.md | 19 ++++ .../rootLogger/WinstonLogger.test.ts | 52 +++++++++++ .../entrypoints/rootLogger/WinstonLogger.ts | 60 +++++++++++- .../src/entrypoints/rootLogger/config.test.ts | 75 +++++++++++++++ .../src/entrypoints/rootLogger/config.ts | 52 +++++++++++ .../src/entrypoints/rootLogger/index.ts | 4 + .../rootLoggerServiceFactory.test.ts | 72 +++++++++++++++ .../rootLogger/rootLoggerServiceFactory.ts | 17 +++- .../src/entrypoints/rootLogger/types.ts | 40 ++++++++ .../src/entrypoints/rootLogger/utils.test.ts | 92 +++++++++++++++++++ .../src/entrypoints/rootLogger/utils.ts | 78 ++++++++++++++++ 14 files changed, 670 insertions(+), 7 deletions(-) create mode 100644 .changeset/silly-horses-share.md create mode 100644 packages/backend-defaults/src/entrypoints/rootLogger/config.test.ts create mode 100644 packages/backend-defaults/src/entrypoints/rootLogger/config.ts create mode 100644 packages/backend-defaults/src/entrypoints/rootLogger/rootLoggerServiceFactory.test.ts create mode 100644 packages/backend-defaults/src/entrypoints/rootLogger/types.ts create mode 100644 packages/backend-defaults/src/entrypoints/rootLogger/utils.test.ts create mode 100644 packages/backend-defaults/src/entrypoints/rootLogger/utils.ts diff --git a/.changeset/silly-horses-share.md b/.changeset/silly-horses-share.md new file mode 100644 index 0000000000..969b337d6b --- /dev/null +++ b/.changeset/silly-horses-share.md @@ -0,0 +1,9 @@ +--- +'@backstage/backend-defaults': patch +--- + +Add `backend.logger` config options to configure the `RootLoggerService`. + +Read more about the new configuration options in the +[Root Logger Service](https://backstage.io/docs/backend-system/core-services/root-logger/) +documentation. diff --git a/docs/backend-system/core-services/root-logger.md b/docs/backend-system/core-services/root-logger.md index 36bb276920..9ab4473244 100644 --- a/docs/backend-system/core-services/root-logger.md +++ b/docs/backend-system/core-services/root-logger.md @@ -13,6 +13,51 @@ If you want to override the implementation for logging across all of the backend ## Configuring the service +The Root Logger Service can be configured with the `backend.logger` section of your `app-config.yaml`. + +The following parameters are available: + +- `level` (string, optional): Sets the global log level. Possible values are 'debug', 'info', 'warn', or 'error'. Only messages at or above this level will be logged. This can also be set via the `LOG_LEVEL` environment variable, which takes precedence. Defaults to 'info'. + +- `meta` (object, optional): Additional metadata to include with every log entry. + +- `overrides` (array, optional): Allows to specify logger overrides for specific plugins or messages. Each override can match on plugin names, message patterns, or any field contained in the log, and set a custom log level for those matches. + +Log level overrides are useful for controlling the volume of logs generated in Backstage. +They allow you to apply a global log level, `info` for example, while setting a stricter level, such as `warn`, for specific verbose plugins. + +The reverse is also possible: you can set a global log level of `warn` while enabling a more detailed level, such as `debug`, for certain logs. + +Example: + +```yaml +backend: + logger: + meta: + env: prod # Every log message will have `env="prod"` + + level: info # Set the global log level to info (the default) + + overrides: + # Set the log level to 'debug' for the catalog plugin logs + - matchers: + plugin: catalog + level: debug + + # Ignore 'info' incoming HTTP requests logs from the rootHttpRouter service + - matchers: + service: rootHttpRouter + type: incomingRequest + level: warn + + # Ignore logs starting with "Task worker starting", unless they're warnings or errors + - matchers: + message: ['/^Task worker starting/'] + level: warn +``` + +## Overriding the service + The following example is how you can override the root logger service to add additional metadata to all log lines. ```ts diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 167a8935f9..76975662e3 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { HumanDuration } from '@backstage/types'; +import { HumanDuration, JsonObject } from '@backstage/types'; export interface Config { app: { @@ -790,6 +790,66 @@ export interface Config { headers?: { [name: string]: string }; }; + /** + * Options to configure the default RootLoggerService. + */ + logger?: { + /** + * Configures the global log level for messages. + * + * This can also be configured using the LOG_LEVEL environment variable, which + * takes precedence over this configuration. + * + * Defaults to 'info'. + */ + level?: 'debug' | 'info' | 'warn' | 'error'; + + /** + * Additional metadata to include with every log entry. + */ + meta?: JsonObject; + + /** + * List of logger overrides. + * + * Can be used to configure a different level for logs matching certain criterias. + * For example, it can be used to ignore 'info' logs of given plugins. + * + * @example + * + * ```yaml + * logger: + * level: info + * overrides: + * # For catalog and auth plugins, messages less important than 'warn' will be ignored. + * - matchers: + * plugin: [catalog, auth] + * level: warn + * # Ignore all messages that starts with 'Forget' + * - matchers: + * message: '/^Forget/' + * level: warn + * ``` + */ + overrides?: Array<{ + /** + * Conditions that must be met to override the log level. + * + * A matcher can be: + * + * - A string (exact match or regex pattern delimited by slashes, e.g. `/pattern/`) + * - A non-string value (compared by strict equality) + * - An array of matchers (returns true if any matcher matches) + */ + matchers: JsonObject; + + /** + * Log level to use for matched entries. + */ + level: 'debug' | 'info' | 'warn' | 'error'; + }>; + }; + /** * Rate limiting options. Defining this as `true` will enable rate limiting with default values. */ diff --git a/packages/backend-defaults/report-rootLogger.api.md b/packages/backend-defaults/report-rootLogger.api.md index b353039c03..aa27a366e3 100644 --- a/packages/backend-defaults/report-rootLogger.api.md +++ b/packages/backend-defaults/report-rootLogger.api.md @@ -3,8 +3,10 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { config } from 'winston'; import { Format } from 'logform'; import { JsonObject } from '@backstage/types'; +import { JsonValue } from '@backstage/types'; import { LoggerService } from '@backstage/backend-plugin-api'; import { RootLoggerService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; @@ -31,14 +33,31 @@ export class WinstonLogger implements RootLoggerService { error(message: string, meta?: JsonObject): void; // (undocumented) info(message: string, meta?: JsonObject): void; + static logLevelFilter(defaultLogLevel: keyof config.NpmConfigSetLevels): { + format: Format; + setOverrides: (overrides: WinstonLoggerLevelOverride[]) => void; + }; static redacter(): { format: Format; add: (redactions: Iterable) => void; }; // (undocumented) + setLevelOverrides(overrides: WinstonLoggerLevelOverride[]): void; + // (undocumented) warn(message: string, meta?: JsonObject): void; } +// @public (undocumented) +export type WinstonLoggerLevelOverride = { + matchers: WinstonLoggerLevelOverrideMatchers; + level: string; +}; + +// @public (undocumented) +export type WinstonLoggerLevelOverrideMatchers = { + [key: string]: JsonValue | undefined; +}; + // @public (undocumented) export interface WinstonLoggerOptions { // (undocumented) diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts index 7208c22d39..8fc35a6e6f 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts @@ -118,4 +118,56 @@ describe('WinstonLogger', () => { add([null as any, undefined as any, 'valid-secret']); }).not.toThrow(); }); + + it('should filter logs below the default log level', () => { + const mockTransport = new Transport({ + log: jest.fn(), + logv: jest.fn(), + }); + + const logger = WinstonLogger.create({ + level: 'warn', + format: format.json(), + transports: [mockTransport], + }); + + logger.debug('debug log'); + + expect(mockTransport.log).not.toHaveBeenCalled(); + }); + + it('should not filter logs below the default log level with an override', () => { + const mockTransport = new Transport({ + log: jest.fn(), + logv: jest.fn(), + }); + + const logger = WinstonLogger.create({ + level: 'warn', + format: format.json(), + transports: [mockTransport], + }); + + logger.setLevelOverrides([ + { + matchers: { + plugin: 'catalog', + }, + level: 'debug', + }, + ]); + + logger.debug('debug log', { plugin: 'catalog' }); + + expect(mockTransport.log).toHaveBeenCalledWith( + expect.objectContaining({ + [MESSAGE]: JSON.stringify({ + level: 'debug', + message: 'debug log', + plugin: 'catalog', + }), + }), + expect.any(Function), + ); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts index 1b6b9db900..90f3042e33 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts @@ -26,9 +26,12 @@ import { createLogger, transports, transport as Transport, + config as winstonConfig, } from 'winston'; import { MESSAGE } from 'triple-beam'; import { escapeRegExp } from '../../lib/escapeRegExp'; +import { winstonLevels, WinstonLoggerLevelOverride } from './types'; +import { isLogMatching } from './utils'; /** * @public @@ -48,20 +51,27 @@ export interface WinstonLoggerOptions { export class WinstonLogger implements RootLoggerService { #winston: Logger; #addRedactions?: (redactions: Iterable) => void; + #setLevelOverrides?: (overrides: WinstonLoggerLevelOverride[]) => void; /** * Creates a {@link WinstonLogger} instance. */ static create(options: WinstonLoggerOptions): WinstonLogger { + const defaultLogLevel = process.env.LOG_LEVEL || options.level || 'info'; + const redacter = WinstonLogger.redacter(); + const logLevelFilter = WinstonLogger.logLevelFilter(defaultLogLevel); + const defaultFormatter = process.env.NODE_ENV === 'production' ? format.json() : WinstonLogger.colorFormat(); let logger = createLogger({ - level: process.env.LOG_LEVEL || options.level || 'info', + // Lowest level possible as we let the logLevelFilter do the filtering + level: 'silly', format: format.combine( + logLevelFilter.format, options.format ?? defaultFormatter, redacter.format, ), @@ -72,7 +82,7 @@ export class WinstonLogger implements RootLoggerService { logger = logger.child(options.meta); } - return new WinstonLogger(logger, redacter.add); + return new WinstonLogger(logger, redacter.add, logLevelFilter.setOverrides); } /** @@ -169,12 +179,54 @@ export class WinstonLogger implements RootLoggerService { ); } + /** + * Formatter that filters log levels using overrides, falling back to the default level when no criteria match. + */ + static logLevelFilter( + defaultLogLevel: keyof winstonConfig.NpmConfigSetLevels, + ): { + format: Format; + setOverrides: (overrides: WinstonLoggerLevelOverride[]) => void; + } { + const overrides: WinstonLoggerLevelOverride[] = []; + + return { + format: format(log => { + for (const override of overrides) { + if (isLogMatching(log, override.matchers)) { + // Discard the log if the log level is below the override + // eg, if the override level is 'warn' (1) and the log is 'debug' (5) + if (winstonLevels[log.level] > winstonLevels[override.level]) { + return false; + } + + return log; + } + } + + // Ignore logs that are below the global level + // eg, if the global level is 'warn' (1) and the log level is 'debug' (5) + if (winstonLevels[log.level] > winstonLevels[defaultLogLevel]) { + return false; + } + + return log; + })(), + setOverrides: newOverrides => { + // Replace the content while preserving the reference + overrides.splice(0, overrides.length, ...newOverrides); + }, + }; + } + private constructor( winston: Logger, addRedactions?: (redactions: Iterable) => void, + setLevelOverrides?: (overrides: WinstonLoggerLevelOverride[]) => void, ) { this.#winston = winston; this.#addRedactions = addRedactions; + this.#setLevelOverrides = setLevelOverrides; } error(message: string, meta?: JsonObject): void { @@ -200,4 +252,8 @@ export class WinstonLogger implements RootLoggerService { addRedactions(redactions: Iterable) { this.#addRedactions?.(redactions); } + + setLevelOverrides(overrides: WinstonLoggerLevelOverride[]) { + this.#setLevelOverrides?.(overrides); + } } diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/config.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/config.test.ts new file mode 100644 index 0000000000..6fde1d3ec2 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLogger/config.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; +import { getRootLoggerConfig } from './config'; + +describe('getRootLoggerConfig', () => { + it('should load the configuration without throwing', () => { + const config = { + backend: { + logger: { + level: 'info', + meta: { + env: 'prod', + }, + overrides: [ + { + matchers: { + plugin: 'catalog', + }, + level: 'warn', + }, + ], + }, + }, + }; + + expect(() => + getRootLoggerConfig( + mockServices.rootConfig({ + data: config, + }), + ), + ).not.toThrow(); + }); + + it('should throw if an override is using an invalid level', () => { + const config = { + backend: { + logger: { + level: 'info', + meta: { + env: 'prod', + }, + overrides: [ + { + matchers: { + plugin: 'catalog', + }, + level: 'invalid', + }, + ], + }, + }, + }; + + expect(() => + getRootLoggerConfig(mockServices.rootConfig({ data: config })), + ).toThrow( + "Invalid config at backend.logger.overrides[0].level, 'invalid' is not a valid Winston npm logging level", + ); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/config.ts b/packages/backend-defaults/src/entrypoints/rootLogger/config.ts new file mode 100644 index 0000000000..4a1f9e1446 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLogger/config.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RootConfigService } from '@backstage/backend-plugin-api'; +import { JsonObject } from '@backstage/types'; +import { RootLoggerConfig, winstonLevels } from './types'; + +export const getRootLoggerConfig = ( + config: RootConfigService, +): RootLoggerConfig => { + const level = config.getOptionalString('backend.logger.level'); + const meta = config + .getOptionalConfig('backend.logger.meta') + ?.get(); + + const overridesConfig = config.getOptionalConfigArray( + 'backend.logger.overrides', + ); + const overrides = overridesConfig?.map((override, i) => { + const overrideLevel = override.getString('level'); + if (winstonLevels[overrideLevel] === undefined) { + throw new Error( + `Invalid config at backend.logger.overrides[${i}].level, '${overrideLevel}' is not a valid Winston npm logging level`, + ); + } + + const matchers = override.getConfig('matchers').get(); + + return { + matchers, + level: overrideLevel, + }; + }); + + return { + meta, + level, + overrides, + }; +}; diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/index.ts b/packages/backend-defaults/src/entrypoints/rootLogger/index.ts index 96ed402520..676c1f5398 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/index.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/index.ts @@ -16,3 +16,7 @@ export { rootLoggerServiceFactory } from './rootLoggerServiceFactory'; export { WinstonLogger, type WinstonLoggerOptions } from './WinstonLogger'; +export { + type WinstonLoggerLevelOverride, + type WinstonLoggerLevelOverrideMatchers, +} from './types'; diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/rootLoggerServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/rootLoggerServiceFactory.test.ts new file mode 100644 index 0000000000..4b807aafc8 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLogger/rootLoggerServiceFactory.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + mockServices, + ServiceFactoryTester, +} from '@backstage/backend-test-utils'; +import { rootLoggerServiceFactory } from './rootLoggerServiceFactory'; + +import { WinstonLogger } from './WinstonLogger'; + +describe('rootLoggerServiceFactory', () => { + beforeEach(() => { + jest.spyOn(WinstonLogger, 'create'); + }); + + it('should create WinstonLogger with defaults', async () => { + await ServiceFactoryTester.from(rootLoggerServiceFactory, { + dependencies: [mockServices.rootConfig.factory()], + }).getSubject(); + + expect(WinstonLogger.create).toHaveBeenCalledWith({ + level: 'info', + meta: { + service: 'backstage', + }, + format: expect.anything(), + transports: expect.anything(), + }); + }); + + it('should create WinstonLogger from config', async () => { + await ServiceFactoryTester.from(rootLoggerServiceFactory, { + dependencies: [ + mockServices.rootConfig.factory({ + data: { + backend: { + logger: { + meta: { + env: 'test', + }, + level: 'warn', + }, + }, + }, + }), + ], + }).getSubject(); + + expect(WinstonLogger.create).toHaveBeenCalledWith({ + level: 'warn', + meta: { + service: 'backstage', + env: 'test', + }, + format: expect.anything(), + transports: expect.anything(), + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/rootLoggerServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootLogger/rootLoggerServiceFactory.ts index 5a4427cf5f..abd81e575b 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/rootLoggerServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/rootLoggerServiceFactory.ts @@ -15,12 +15,13 @@ */ import { - createServiceFactory, coreServices, + createServiceFactory, } from '@backstage/backend-plugin-api'; -import { transports, format } from 'winston'; -import { WinstonLogger } from '../rootLogger/WinstonLogger'; +import { format, transports } from 'winston'; import { createConfigSecretEnumerator } from '../rootConfig/createConfigSecretEnumerator'; +import { WinstonLogger } from '../rootLogger/WinstonLogger'; +import { getRootLoggerConfig } from './config'; /** * Root-level logging. @@ -37,11 +38,14 @@ export const rootLoggerServiceFactory = createServiceFactory({ config: coreServices.rootConfig, }, async factory({ config }) { + const rootLoggerConfig = getRootLoggerConfig(config); + const logger = WinstonLogger.create({ meta: { service: 'backstage', + ...rootLoggerConfig.meta, }, - level: process.env.LOG_LEVEL || 'info', + level: process.env.LOG_LEVEL || rootLoggerConfig.level || 'info', format: process.env.NODE_ENV === 'production' ? format.json() @@ -53,6 +57,11 @@ export const rootLoggerServiceFactory = createServiceFactory({ logger.addRedactions(secretEnumerator(config)); config.subscribe?.(() => logger.addRedactions(secretEnumerator(config))); + logger.setLevelOverrides(rootLoggerConfig.overrides ?? []); + config.subscribe?.(() => + logger.setLevelOverrides(getRootLoggerConfig(config).overrides ?? []), + ); + return logger; }, }); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/types.ts b/packages/backend-defaults/src/entrypoints/rootLogger/types.ts new file mode 100644 index 0000000000..b1f6fcc5b3 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLogger/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { JsonObject, JsonValue } from '@backstage/types'; +import { config as winstonConfig } from 'winston'; + +/** + * @public + */ +export type WinstonLoggerLevelOverrideMatchers = { + [key: string]: JsonValue | undefined; +}; + +/** + * @public + */ +export type WinstonLoggerLevelOverride = { + matchers: WinstonLoggerLevelOverrideMatchers; + level: string; +}; + +export type RootLoggerConfig = { + level?: string; + meta?: JsonObject; + overrides?: WinstonLoggerLevelOverride[]; +}; + +export const winstonLevels = winstonConfig.npm.levels; diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/utils.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/utils.test.ts new file mode 100644 index 0000000000..c60414cfc3 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLogger/utils.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { isLogMatching } from './utils'; + +describe('isLogMatching', () => { + const log = { + level: 'info', + message: 'This is a simple log from the catalog plugin', + plugin: 'catalog', + status: 200, + action: 'read', + }; + + it('should match with a simple matcher', () => { + expect( + isLogMatching(log, { + plugin: 'catalog', + }), + ).toEqual(true); + }); + + it('should not match with a simple matcher', () => { + expect( + isLogMatching(log, { + plugin: 'search', + }), + ).toEqual(false); + }); + + it('should match with an AND matcher', () => { + expect( + isLogMatching(log, { + plugin: 'catalog', + action: 'read', + }), + ).toEqual(true); + }); + + it('should not match log with an AND matcher', () => { + expect( + isLogMatching(log, { + plugin: 'catalog', + action: 'write', + }), + ).toEqual(false); + }); + + it('should match with an OR matcher', () => { + expect( + isLogMatching(log, { + plugin: ['auth', 'catalog'], + }), + ).toEqual(true); + }); + + it('should not match log with an OR matcher', () => { + expect( + isLogMatching(log, { + plugin: ['auth', 'search'], + }), + ).toEqual(false); + }); + + it('should match log with a regex matcher', () => { + expect( + isLogMatching(log, { + message: '/This is a simple log/', + }), + ).toEqual(true); + }); + + it('should not match log with a regex matcher', () => { + expect( + isLogMatching(log, { + message: '/^simple log/', + }), + ).toEqual(false); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/utils.ts b/packages/backend-defaults/src/entrypoints/rootLogger/utils.ts new file mode 100644 index 0000000000..205a1b1520 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootLogger/utils.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TransformableInfo } from 'logform'; +import { WinstonLoggerLevelOverrideMatchers } from './types'; + +/** + * Determines if a given log field matches a specified matcher. + * + * The matcher can be: + * - A string (exact match or regex pattern delimited by slashes, e.g. `/pattern/`) + * - A non-string value (compared by strict equality) + * - An array of matchers (returns true if any matcher matches) + * + * @param logField - The log field value to test for a match. + * @param matcher - The matcher or array of matchers to compare against the log field. + * @returns `true` if the log field matches the matcher, otherwise `false`. + */ +const isLogFieldMatching = ( + logField: unknown, + matcher: WinstonLoggerLevelOverrideMatchers[0], +): boolean => { + if (Array.isArray(matcher)) { + return matcher.some(m => isLogFieldMatching(logField, m)); + } + + if (typeof matcher !== 'string') { + return logField === matcher; + } + + if ( + matcher.startsWith('/') && + matcher.endsWith('/') && + typeof logField === 'string' + ) { + const regex = new RegExp(matcher.slice(1, -1)); + return regex.test(logField); + } + + return logField === matcher; +}; + +/** + * Determines whether a log entry matches all specified override matchers. + * + * Iterates over each key-matcher pair in the provided `matchers` object, + * retrieves the corresponding field from the `log` object, and checks if + * the field matches the matcher using `isLogFieldMatching`. Returns `true` + * only if all matchers are satisfied. + * + * @param log - The log entry to be checked, typically containing various log fields. + * @param matchers - An object where each key corresponds to a log field and each value is a matcher to test against that field. + * @returns `true` if the log entry matches all provided matchers, otherwise `false`. + */ +export const isLogMatching = ( + log: TransformableInfo, + matchers: WinstonLoggerLevelOverrideMatchers, +): boolean => { + const matched = Object.entries(matchers).every(([key, matcher]) => { + const logField = log[key]; + return isLogFieldMatching(logField, matcher); + }); + + return matched; +};