From f244e61d206088e732b9c0a91812197b36e4ad10 Mon Sep 17 00:00:00 2001 From: Thomas Cardonne Date: Sat, 2 Aug 2025 01:08:02 +0200 Subject: [PATCH 1/5] 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; +}; From 6985276cf646f2fa8ebec3cd0e66c19a7b0a5ef1 Mon Sep 17 00:00:00 2001 From: Thomas Cardonne Date: Mon, 1 Sep 2025 13:30:02 +0200 Subject: [PATCH 2/5] chore: change invalid log level message Signed-off-by: Thomas Cardonne --- .../backend-defaults/src/entrypoints/rootLogger/config.test.ts | 2 +- packages/backend-defaults/src/entrypoints/rootLogger/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/config.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/config.test.ts index 6fde1d3ec2..e32cc13247 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/config.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/config.test.ts @@ -69,7 +69,7 @@ describe('getRootLoggerConfig', () => { expect(() => getRootLoggerConfig(mockServices.rootConfig({ data: config })), ).toThrow( - "Invalid config at backend.logger.overrides[0].level, 'invalid' is not a valid Winston npm logging level", + "Invalid config at backend.logger.overrides[0].level, 'invalid' is not a valid logging level, must be one of 'error', 'warn', 'info' or 'debug'.", ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/config.ts b/packages/backend-defaults/src/entrypoints/rootLogger/config.ts index 4a1f9e1446..24660f82c8 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/config.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/config.ts @@ -32,7 +32,7 @@ export const getRootLoggerConfig = ( 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`, + `Invalid config at backend.logger.overrides[${i}].level, '${overrideLevel}' is not a valid logging level, must be one of 'error', 'warn', 'info' or 'debug'.`, ); } From b6ab2d405dcf57f9e228bdea0af052366386bc25 Mon Sep 17 00:00:00 2001 From: Thomas Cardonne Date: Mon, 1 Sep 2025 13:44:36 +0200 Subject: [PATCH 3/5] tests: add test case Signed-off-by: Thomas Cardonne --- .../rootLogger/WinstonLogger.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts index 8fc35a6e6f..7dba10cccf 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts @@ -170,4 +170,30 @@ describe('WinstonLogger', () => { expect.any(Function), ); }); + + it('should filter logs above the default log level with an override', () => { + const mockTransport = new Transport({ + log: jest.fn(), + logv: jest.fn(), + }); + + const logger = WinstonLogger.create({ + level: 'debug', + format: format.json(), + transports: [mockTransport], + }); + + logger.setLevelOverrides([ + { + matchers: { + plugin: 'catalog', + }, + level: 'error', + }, + ]); + + logger.info('info log', { plugin: 'catalog' }); + + expect(mockTransport.log).not.toHaveBeenCalled(); + }); }); From f4045adea049d62776f049c0aad3b77401ca0bb1 Mon Sep 17 00:00:00 2001 From: Thomas Cardonne Date: Mon, 1 Sep 2025 13:45:50 +0200 Subject: [PATCH 4/5] refactor: use JsonPrimitive | JsonPrimitive[] instead of JsonValue Signed-off-by: Thomas Cardonne --- packages/backend-defaults/report-rootLogger.api.md | 4 ++-- .../src/entrypoints/rootLogger/config.ts | 10 ++++++++-- .../src/entrypoints/rootLogger/types.ts | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/backend-defaults/report-rootLogger.api.md b/packages/backend-defaults/report-rootLogger.api.md index aa27a366e3..99dc263358 100644 --- a/packages/backend-defaults/report-rootLogger.api.md +++ b/packages/backend-defaults/report-rootLogger.api.md @@ -6,7 +6,7 @@ import { config } from 'winston'; import { Format } from 'logform'; import { JsonObject } from '@backstage/types'; -import { JsonValue } from '@backstage/types'; +import { JsonPrimitive } from '@backstage/types'; import { LoggerService } from '@backstage/backend-plugin-api'; import { RootLoggerService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; @@ -55,7 +55,7 @@ export type WinstonLoggerLevelOverride = { // @public (undocumented) export type WinstonLoggerLevelOverrideMatchers = { - [key: string]: JsonValue | undefined; + [key: string]: JsonPrimitive | JsonPrimitive[] | undefined; }; // @public (undocumented) diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/config.ts b/packages/backend-defaults/src/entrypoints/rootLogger/config.ts index 24660f82c8..11687d72f4 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/config.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/config.ts @@ -15,7 +15,11 @@ */ import { RootConfigService } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; -import { RootLoggerConfig, winstonLevels } from './types'; +import { + RootLoggerConfig, + winstonLevels, + WinstonLoggerLevelOverrideMatchers, +} from './types'; export const getRootLoggerConfig = ( config: RootConfigService, @@ -36,7 +40,9 @@ export const getRootLoggerConfig = ( ); } - const matchers = override.getConfig('matchers').get(); + const matchers = override + .getConfig('matchers') + .get(); return { matchers, diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/types.ts b/packages/backend-defaults/src/entrypoints/rootLogger/types.ts index b1f6fcc5b3..f8277d8075 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/types.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/types.ts @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JsonObject, JsonValue } from '@backstage/types'; +import { JsonObject, JsonPrimitive } from '@backstage/types'; import { config as winstonConfig } from 'winston'; /** * @public */ export type WinstonLoggerLevelOverrideMatchers = { - [key: string]: JsonValue | undefined; + [key: string]: JsonPrimitive | JsonPrimitive[] | undefined; }; /** From 7b5129c490bff5a50edd742fd504c1003152ca74 Mon Sep 17 00:00:00 2001 From: Thomas Cardonne Date: Mon, 1 Sep 2025 15:20:48 +0200 Subject: [PATCH 5/5] refactor: avoid recompiling the regex on every test Signed-off-by: Thomas Cardonne --- .../entrypoints/rootLogger/WinstonLogger.ts | 17 +++-- .../src/entrypoints/rootLogger/utils.test.ts | 76 ++++++++----------- .../src/entrypoints/rootLogger/utils.ts | 68 ++++++++++------- 3 files changed, 86 insertions(+), 75 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts index 90f3042e33..c48cef2f1f 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts @@ -31,7 +31,7 @@ import { import { MESSAGE } from 'triple-beam'; import { escapeRegExp } from '../../lib/escapeRegExp'; import { winstonLevels, WinstonLoggerLevelOverride } from './types'; -import { isLogMatching } from './utils'; +import { createLogMatcher } from './utils'; /** * @public @@ -188,12 +188,15 @@ export class WinstonLogger implements RootLoggerService { format: Format; setOverrides: (overrides: WinstonLoggerLevelOverride[]) => void; } { - const overrides: WinstonLoggerLevelOverride[] = []; + const overrides: { + predicate: (log: TransformableInfo) => boolean; + level: string; + }[] = []; return { format: format(log => { for (const override of overrides) { - if (isLogMatching(log, override.matchers)) { + if (override.predicate(log)) { // 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]) { @@ -213,8 +216,12 @@ export class WinstonLogger implements RootLoggerService { return log; })(), setOverrides: newOverrides => { - // Replace the content while preserving the reference - overrides.splice(0, overrides.length, ...newOverrides); + const newOverridesPredicates = newOverrides.map(o => ({ + predicate: createLogMatcher(o.matchers), + level: o.level, + })); + // Replace the content while preserving the reference to support live config updates + overrides.splice(0, overrides.length, ...newOverridesPredicates); }, }; } diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/utils.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/utils.test.ts index c60414cfc3..ede3de600b 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/utils.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/utils.test.ts @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { isLogMatching } from './utils'; +import { createLogMatcher } from './utils'; -describe('isLogMatching', () => { +describe('createLogMatcher', () => { const log = { level: 'info', message: 'This is a simple log from the catalog plugin', @@ -25,68 +25,56 @@ describe('isLogMatching', () => { }; it('should match with a simple matcher', () => { - expect( - isLogMatching(log, { - plugin: 'catalog', - }), - ).toEqual(true); + const matcher = createLogMatcher({ plugin: 'catalog' }); + expect(matcher(log)).toEqual(true); }); it('should not match with a simple matcher', () => { - expect( - isLogMatching(log, { - plugin: 'search', - }), - ).toEqual(false); + const matcher = createLogMatcher({ plugin: 'search' }); + expect(matcher(log)).toEqual(false); }); it('should match with an AND matcher', () => { - expect( - isLogMatching(log, { - plugin: 'catalog', - action: 'read', - }), - ).toEqual(true); + const matcher = createLogMatcher({ + plugin: 'catalog', + action: 'read', + }); + expect(matcher(log)).toEqual(true); }); it('should not match log with an AND matcher', () => { - expect( - isLogMatching(log, { - plugin: 'catalog', - action: 'write', - }), - ).toEqual(false); + const matcher = createLogMatcher({ + plugin: 'catalog', + action: 'write', + }); + expect(matcher(log)).toEqual(false); }); it('should match with an OR matcher', () => { - expect( - isLogMatching(log, { - plugin: ['auth', 'catalog'], - }), - ).toEqual(true); + const matcher = createLogMatcher({ + plugin: ['auth', 'catalog'], + }); + expect(matcher(log)).toEqual(true); }); it('should not match log with an OR matcher', () => { - expect( - isLogMatching(log, { - plugin: ['auth', 'search'], - }), - ).toEqual(false); + const matcher = createLogMatcher({ + plugin: ['auth', 'search'], + }); + expect(matcher(log)).toEqual(false); }); it('should match log with a regex matcher', () => { - expect( - isLogMatching(log, { - message: '/This is a simple log/', - }), - ).toEqual(true); + const matcher = createLogMatcher({ + message: '/This is a simple log/', + }); + expect(matcher(log)).toEqual(true); }); it('should not match log with a regex matcher', () => { - expect( - isLogMatching(log, { - message: '/^simple log/', - }), - ).toEqual(false); + const matcher = createLogMatcher({ + message: '/^simple log/', + }); + expect(matcher(log)).toEqual(false); }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/utils.ts b/packages/backend-defaults/src/entrypoints/rootLogger/utils.ts index 205a1b1520..adf38fc4da 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/utils.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/utils.ts @@ -17,62 +17,78 @@ import { TransformableInfo } from 'logform'; import { WinstonLoggerLevelOverrideMatchers } from './types'; +/** Parse a slash-delimited regex like `/pattern/flags` into a RegExp, or null if not a regex-string */ +const parseRegex = (s: string): RegExp | null => { + if (!s.startsWith('/')) return null; + const lastSlash = s.lastIndexOf('/'); + if (lastSlash <= 0) return null; + + const pattern = s.slice(1, lastSlash); + const flags = s.slice(lastSlash + 1); + + try { + return new RegExp(pattern, flags); + } catch { + return null; // fall back to treating it as a plain string + } +}; + /** - * Determines if a given log field matches a specified matcher. + * Create a predicate function that determines whether a log field matches a given 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`. + * @returns A function that takes a log field and returns `true` if it matches the matcher, otherwise `false`. */ -const isLogFieldMatching = ( - logField: unknown, +const createLogFieldMatcher = ( matcher: WinstonLoggerLevelOverrideMatchers[0], -): boolean => { +): ((logField: unknown) => boolean) => { + // Array of matchers: create predicates for each element and OR them together if (Array.isArray(matcher)) { - return matcher.some(m => isLogFieldMatching(logField, m)); + const fns = matcher.map(m => createLogFieldMatcher(m)); + return (logField: unknown) => fns.some(fn => fn(logField)); } + // Non-string matcher: strict equality if (typeof matcher !== 'string') { - return logField === matcher; + return (logField: unknown) => logField === matcher; } - if ( - matcher.startsWith('/') && - matcher.endsWith('/') && - typeof logField === 'string' - ) { - const regex = new RegExp(matcher.slice(1, -1)); - return regex.test(logField); + // String matcher: maybe a slash-delimited regex (/pattern/flags) + const regex = parseRegex(matcher); + if (regex) { + return (logField: unknown) => + typeof logField === 'string' && regex.test(logField); } - return logField === matcher; + // Plain string matcher: strict equality + return (logField: unknown) => logField === matcher; }; /** - * Determines whether a log entry matches all specified override matchers. + * Create a predicate function that 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`. + * @returns A function that takes a log entry and returns `true` if it matches all specified matchers, otherwise `false`. */ -export const isLogMatching = ( - log: TransformableInfo, +export const createLogMatcher = ( matchers: WinstonLoggerLevelOverrideMatchers, -): boolean => { - const matched = Object.entries(matchers).every(([key, matcher]) => { - const logField = log[key]; - return isLogFieldMatching(logField, matcher); +): ((log: TransformableInfo) => boolean) => { + const logFieldMatchers = Object.entries(matchers).map(([key, m]) => { + const fn = createLogFieldMatcher(m); + return [key, fn] as const; }); - return matched; + return (log: TransformableInfo) => + logFieldMatchers.every(([key, fn]) => fn(log[key])); };