Merge pull request #30727 from leboncoin/feat-root-logger-service-overrides

feat(backend-defaults): add backend.logger configuration options
This commit is contained in:
Patrik Oldsberg
2025-09-16 10:03:41 +02:00
committed by GitHub
14 changed files with 713 additions and 8 deletions
+9
View File
@@ -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.
@@ -13,6 +13,51 @@ If you want to override the implementation for logging across all of the backend
## Configuring the service
### Configuration parameters
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`. See [Modifying the log level](#modifying-the-log-level).
- `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
```
### Modifying the log level
The available log levels, in order of decreasing verbosity, are:
@@ -21,7 +66,6 @@ The available log levels, in order of decreasing verbosity, are:
- `info`: General information about the application's operation. This is the default level.
- `warn`: Indicates potential issues or situations that may require attention.
- `error`: Indicates errors that have occurred but may not necessarily prevent the application from continuing.
- `critical`: Indicates critical errors that require immediate attention and likely prevent the application from functioning correctly.
The verbosity of the logging can be controlled by setting the log level. The log level determines the minimum severity level of events that will be output to the console. For example, if the log level is set to `info`, events with a severity level of `debug` will be ignored.
+61 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { HumanDuration } from '@backstage/types';
import { HumanDuration, JsonObject } from '@backstage/types';
export interface Config {
app: {
@@ -985,6 +985,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.
*/
@@ -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 { 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';
@@ -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<string>) => 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]: JsonPrimitive | JsonPrimitive[] | undefined;
};
// @public (undocumented)
export interface WinstonLoggerOptions {
// (undocumented)
@@ -118,4 +118,82 @@ 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),
);
});
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();
});
});
@@ -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 { createLogMatcher } from './utils';
/**
* @public
@@ -48,20 +51,27 @@ export interface WinstonLoggerOptions {
export class WinstonLogger implements RootLoggerService {
#winston: Logger;
#addRedactions?: (redactions: Iterable<string>) => 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,61 @@ 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: {
predicate: (log: TransformableInfo) => boolean;
level: string;
}[] = [];
return {
format: format(log => {
for (const override of overrides) {
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]) {
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 => {
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);
},
};
}
private constructor(
winston: Logger,
addRedactions?: (redactions: Iterable<string>) => void,
setLevelOverrides?: (overrides: WinstonLoggerLevelOverride[]) => void,
) {
this.#winston = winston;
this.#addRedactions = addRedactions;
this.#setLevelOverrides = setLevelOverrides;
}
error(message: string, meta?: JsonObject): void {
@@ -200,4 +259,8 @@ export class WinstonLogger implements RootLoggerService {
addRedactions(redactions: Iterable<string>) {
this.#addRedactions?.(redactions);
}
setLevelOverrides(overrides: WinstonLoggerLevelOverride[]) {
this.#setLevelOverrides?.(overrides);
}
}
@@ -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 logging level, must be one of 'error', 'warn', 'info' or 'debug'.",
);
});
});
@@ -0,0 +1,58 @@
/*
* 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,
WinstonLoggerLevelOverrideMatchers,
} from './types';
export const getRootLoggerConfig = (
config: RootConfigService,
): RootLoggerConfig => {
const level = config.getOptionalString('backend.logger.level');
const meta = config
.getOptionalConfig('backend.logger.meta')
?.get<JsonObject>();
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 logging level, must be one of 'error', 'warn', 'info' or 'debug'.`,
);
}
const matchers = override
.getConfig('matchers')
.get<WinstonLoggerLevelOverrideMatchers>();
return {
matchers,
level: overrideLevel,
};
});
return {
meta,
level,
overrides,
};
};
@@ -16,3 +16,7 @@
export { rootLoggerServiceFactory } from './rootLoggerServiceFactory';
export { WinstonLogger, type WinstonLoggerOptions } from './WinstonLogger';
export {
type WinstonLoggerLevelOverride,
type WinstonLoggerLevelOverrideMatchers,
} from './types';
@@ -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(),
});
});
});
@@ -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;
},
});
@@ -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, JsonPrimitive } from '@backstage/types';
import { config as winstonConfig } from 'winston';
/**
* @public
*/
export type WinstonLoggerLevelOverrideMatchers = {
[key: string]: JsonPrimitive | JsonPrimitive[] | undefined;
};
/**
* @public
*/
export type WinstonLoggerLevelOverride = {
matchers: WinstonLoggerLevelOverrideMatchers;
level: string;
};
export type RootLoggerConfig = {
level?: string;
meta?: JsonObject;
overrides?: WinstonLoggerLevelOverride[];
};
export const winstonLevels = winstonConfig.npm.levels;
@@ -0,0 +1,80 @@
/*
* 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 { createLogMatcher } from './utils';
describe('createLogMatcher', () => {
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', () => {
const matcher = createLogMatcher({ plugin: 'catalog' });
expect(matcher(log)).toEqual(true);
});
it('should not match with a simple matcher', () => {
const matcher = createLogMatcher({ plugin: 'search' });
expect(matcher(log)).toEqual(false);
});
it('should match with an AND matcher', () => {
const matcher = createLogMatcher({
plugin: 'catalog',
action: 'read',
});
expect(matcher(log)).toEqual(true);
});
it('should not match log with an AND matcher', () => {
const matcher = createLogMatcher({
plugin: 'catalog',
action: 'write',
});
expect(matcher(log)).toEqual(false);
});
it('should match with an OR matcher', () => {
const matcher = createLogMatcher({
plugin: ['auth', 'catalog'],
});
expect(matcher(log)).toEqual(true);
});
it('should not match log with an OR matcher', () => {
const matcher = createLogMatcher({
plugin: ['auth', 'search'],
});
expect(matcher(log)).toEqual(false);
});
it('should match log with a regex matcher', () => {
const matcher = createLogMatcher({
message: '/This is a simple log/',
});
expect(matcher(log)).toEqual(true);
});
it('should not match log with a regex matcher', () => {
const matcher = createLogMatcher({
message: '/^simple log/',
});
expect(matcher(log)).toEqual(false);
});
});
@@ -0,0 +1,94 @@
/*
* 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';
/** 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
}
};
/**
* 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 matcher - The matcher or array of matchers to compare against the log field.
* @returns A function that takes a log field and returns `true` if it matches the matcher, otherwise `false`.
*/
const createLogFieldMatcher = (
matcher: WinstonLoggerLevelOverrideMatchers[0],
): ((logField: unknown) => boolean) => {
// Array of matchers: create predicates for each element and OR them together
if (Array.isArray(matcher)) {
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: unknown) => logField === matcher;
}
// String matcher: maybe a slash-delimited regex (/pattern/flags)
const regex = parseRegex(matcher);
if (regex) {
return (logField: unknown) =>
typeof logField === 'string' && regex.test(logField);
}
// Plain string matcher: strict equality
return (logField: unknown) => logField === matcher;
};
/**
* 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 matchers - An object where each key corresponds to a log field and each value is a matcher to test against that field.
* @returns A function that takes a log entry and returns `true` if it matches all specified matchers, otherwise `false`.
*/
export const createLogMatcher = (
matchers: WinstonLoggerLevelOverrideMatchers,
): ((log: TransformableInfo) => boolean) => {
const logFieldMatchers = Object.entries(matchers).map(([key, m]) => {
const fn = createLogFieldMatcher(m);
return [key, fn] as const;
});
return (log: TransformableInfo) =>
logFieldMatchers.every(([key, fn]) => fn(log[key]));
};