feat: propagate errors as objects and align Winston service
Signed-off-by: Paul Schultz <pschultz@pobox.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user