feat(backend-defaults): add backend.logger configuration options

Signed-off-by: Thomas Cardonne <thomas.cardonne@adevinta.com>
This commit is contained in:
Thomas Cardonne
2025-08-02 01:08:02 +02:00
parent 4ba9f986d2
commit f244e61d20
14 changed files with 670 additions and 7 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
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
+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: {
@@ -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.
*/
@@ -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<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]: JsonValue | undefined;
};
// @public (undocumented)
export interface WinstonLoggerOptions {
// (undocumented)
@@ -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),
);
});
});
@@ -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<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,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<string>) => 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<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 Winston npm logging level",
);
});
});
@@ -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<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 Winston npm logging level`,
);
}
const matchers = override.getConfig('matchers').get<JsonObject>();
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, 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;
@@ -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);
});
});
@@ -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;
};