feat: propagate errors as objects and align Winston service

Signed-off-by: Paul Schultz <pschultz@pobox.com>
This commit is contained in:
Paul Schultz
2025-05-27 15:00:08 -05:00
parent 3736ee4c65
commit 3ccb7fc151
9 changed files with 150 additions and 57 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': minor
---
Enhanced error handling in the auditor service factory to pass errors as objects. Aligned WinstonRootAuditorService with the default service factory's error handling.
@@ -8,6 +8,7 @@ import type { AuditorServiceCreateEventOptions } from '@backstage/backend-plugin
import type { AuditorServiceEvent } from '@backstage/backend-plugin-api';
import type { AuditorServiceEventSeverityLevel } from '@backstage/backend-plugin-api';
import type { AuthService } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import type { Format } from 'logform';
import type { HttpAuthService } from '@backstage/backend-plugin-api';
import type { JsonObject } from '@backstage/types';
@@ -58,7 +59,7 @@ export type AuditorEventStatus =
}
| {
status: 'failed';
error: string;
error: Error;
};
// @public
@@ -95,6 +96,7 @@ export class WinstonRootAuditorService {
// (undocumented)
forPlugin(deps: {
auth: AuthService;
config: Config;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
}): AuditorService;
@@ -83,7 +83,7 @@ describe('DefaultAuditorService', () => {
expect(logFn).toHaveBeenLastCalledWith({
eventId: 'test-event',
status: 'failed',
error: error.toString(),
error,
plugin: 'test',
severityLevel: 'low',
actor: {},
@@ -135,7 +135,7 @@ describe('DefaultAuditorService', () => {
initiated: 'test',
failed: 'test',
},
error: error.toString(),
error,
plugin: 'test',
severityLevel: 'low',
actor: {},
@@ -48,7 +48,7 @@ export type AuditorEventStatus =
| { status: 'succeeded' }
| {
status: 'failed';
error: string;
error: Error;
};
/**
@@ -195,7 +195,7 @@ export class DefaultAuditorService implements AuditorService {
await this.log({
...options,
...params,
error: String(params.error),
error: params.error,
meta: { ...options.meta, ...params?.meta },
status: 'failed',
});
@@ -32,6 +32,7 @@ describe('WinstonRootAuditorService', () => {
plugin: {
getId: () => 'test-plugin',
},
config: mockServices.rootConfig.mock(),
});
expect(childLogger).toBeInstanceOf(DefaultAuditorService);
});
@@ -45,6 +46,7 @@ describe('WinstonRootAuditorService', () => {
plugin: {
getId: () => pluginId,
},
config: mockServices.rootConfig.mock(),
});
// workaround to spy on private method
const auditorSpy = jest.spyOn(auditor as any, 'log');
@@ -68,6 +70,7 @@ describe('WinstonRootAuditorService', () => {
plugin: {
getId: () => pluginId,
},
config: mockServices.rootConfig.mock(),
});
// workaround to spy on private method
const auditorSpy = jest.spyOn(auditor as any, 'log');
@@ -95,6 +98,7 @@ describe('WinstonRootAuditorService', () => {
plugin: {
getId: () => pluginId,
},
config: mockServices.rootConfig.mock(),
});
// workaround to spy on private method
const auditorSpy = jest.spyOn(auditor as any, 'log');
@@ -111,7 +115,7 @@ describe('WinstonRootAuditorService', () => {
eventId: 'test-event',
meta: {},
status: 'failed',
error: error.toString(),
error,
});
});
@@ -124,6 +128,7 @@ describe('WinstonRootAuditorService', () => {
plugin: {
getId: () => pluginId,
},
config: mockServices.rootConfig.mock(),
});
// workaround to spy on private method
const auditorSpy = jest.spyOn(auditor as any, 'log');
@@ -163,7 +168,7 @@ describe('WinstonRootAuditorService', () => {
initiated: 'test',
failed: 'test',
},
error: error.toString(),
error,
});
});
});
@@ -20,11 +20,13 @@ import type {
HttpAuthService,
PluginMetadataService,
} from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import type { JsonObject } from '@backstage/types';
import type { Format } from 'logform';
import * as winston from 'winston';
import { WinstonLogger } from '../rootLogger';
import { DefaultAuditorService } from './DefaultAuditorService';
import { getSeverityLogLevelMappings } from './utils';
/** @public */
export const defaultFormatter = winston.format.combine(
@@ -108,12 +110,28 @@ export class WinstonRootAuditorService {
forPlugin(deps: {
auth: AuthService;
config: Config;
httpAuth: HttpAuthService;
plugin: PluginMetadataService;
}): AuditorService {
return DefaultAuditorService.create(
e => this.winstonLogger.info(`${e.plugin}.${e.eventId}`, e),
deps,
);
const severityLogLevelMappings = getSeverityLogLevelMappings(deps.config);
return DefaultAuditorService.create(event => {
if ('error' in event) {
const { error, ...rest } = event;
const childAuditLogger = this.winstonLogger.child(rest);
childAuditLogger[severityLogLevelMappings[event.severityLevel]](
`${event.plugin}.${event.eventId}`,
error,
);
} else {
// the else statement is required for typechecking
this.winstonLogger[severityLogLevelMappings[event.severityLevel]](
`${event.plugin}.${event.eventId}`,
event,
);
}
}, deps);
}
}
@@ -19,15 +19,7 @@ import {
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultAuditorService } from './DefaultAuditorService';
import { z } from 'zod';
import { InputError } from '@backstage/errors';
const CONFIG_ROOT_KEY = 'backend.auditor';
const severityLogLevelMappingsSchema = z.record(
z.enum(['low', 'medium', 'high', 'critical']),
z.enum(['debug', 'info', 'warn', 'error']),
);
import { getSeverityLogLevelMappings } from './utils';
/**
* Plugin-level auditing.
@@ -49,47 +41,26 @@ export const auditorServiceFactory = createServiceFactory({
},
factory({ config, logger, plugin, auth, httpAuth }) {
const auditLogger = logger.child({ isAuditEvent: true });
const auditorConfig = config.getOptionalConfig(CONFIG_ROOT_KEY);
const severityLogLevelMappings = {
low:
auditorConfig?.getOptionalString('severityLogLevelMappings.low') ??
'debug',
medium:
auditorConfig?.getOptionalString('severityLogLevelMappings.medium') ??
'info',
high:
auditorConfig?.getOptionalString('severityLogLevelMappings.high') ??
'info',
critical:
auditorConfig?.getOptionalString('severityLogLevelMappings.critical') ??
'info',
} as Required<z.infer<typeof severityLogLevelMappingsSchema>>;
const res = severityLogLevelMappingsSchema.safeParse(
severityLogLevelMappings,
);
if (!res.success) {
const key = res.error.issues.at(0)?.path.at(0) as string;
const value = (
res.error.issues.at(0) as unknown as Record<PropertyKey, unknown>
).received as string;
const validKeys = (
res.error.issues.at(0) as unknown as Record<PropertyKey, unknown>
).options as string[];
throw new InputError(
`The configuration value for 'backend.auditor.severityLogLevelMappings.${key}' was given an invalid value: '${value}'. Expected one of the following valid values: '${validKeys.join(
', ',
)}'.`,
);
}
const severityLogLevelMappings = getSeverityLogLevelMappings(config);
return DefaultAuditorService.create(
event => {
auditLogger[severityLogLevelMappings[event.severityLevel]](
`${event.plugin}.${event.eventId}`,
event,
);
if ('error' in event) {
const { error, ...rest } = event;
const childAuditLogger = auditLogger.child(rest);
childAuditLogger[severityLogLevelMappings[event.severityLevel]](
`${event.plugin}.${event.eventId}`,
error,
);
} else {
// the else statement is required for typechecking
auditLogger[severityLogLevelMappings[event.severityLevel]](
`${event.plugin}.${event.eventId}`,
event,
);
}
},
{ plugin, auth, httpAuth },
);
@@ -0,0 +1,26 @@
/*
* 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 { z } from 'zod';
/** @internal */
export const severityLogLevelMappingsSchema = z.record(
z.enum(['low', 'medium', 'high', 'critical']),
z.enum(['debug', 'info', 'warn', 'error']),
);
/** @internal */
export const CONFIG_ROOT_KEY = 'backend.auditor';
@@ -0,0 +1,66 @@
/*
* 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 type { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import { z } from 'zod';
import { CONFIG_ROOT_KEY, severityLogLevelMappingsSchema } from './types';
/**
* Gets the `backend.auditor.severityLogLevelMappings` configuration.
*
* @param config - The root Backstage {@link @backstage/config#Config} object.
* @returns The validated severity-to-log-level mappings.
* @throws error - {@link @backstage/errors#InputError} if the mapping configuration is invalid.
*/
export function getSeverityLogLevelMappings(config: Config) {
const auditorConfig = config.getOptionalConfig(CONFIG_ROOT_KEY);
const severityLogLevelMappings = {
low:
auditorConfig?.getOptionalString('severityLogLevelMappings.low') ??
'debug',
medium:
auditorConfig?.getOptionalString('severityLogLevelMappings.medium') ??
'info',
high:
auditorConfig?.getOptionalString('severityLogLevelMappings.high') ??
'info',
critical:
auditorConfig?.getOptionalString('severityLogLevelMappings.critical') ??
'info',
} as Required<z.infer<typeof severityLogLevelMappingsSchema>>;
const res = severityLogLevelMappingsSchema.safeParse(
severityLogLevelMappings,
);
if (!res.success) {
const key = res.error.issues.at(0)?.path.at(0) as string;
const value = (
res.error.issues.at(0) as unknown as Record<PropertyKey, unknown>
).received as string;
const validKeys = (
res.error.issues.at(0) as unknown as Record<PropertyKey, unknown>
).options as string[];
throw new InputError(
`The configuration value for 'backend.auditor.severityLogLevelMappings.${key}' was given an invalid value: '${value}'. Expected one of the following valid values: '${validKeys.join(
', ',
)}'.`,
);
}
return severityLogLevelMappings;
}