1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-12-25 21:23:11 +03:00

refactor(core): Unify error reporters in core and task runners (#12168)

Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
Iván Ovejero 2024-12-13 17:34:37 +01:00 committed by GitHub
parent d937e5abe8
commit 120499291d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 33 additions and 175 deletions

View File

@ -1,31 +0,0 @@
import { mock } from 'jest-mock-extended';
import { ApplicationError } from 'n8n-workflow';
import { ErrorReporter } from '../error-reporter';
describe('ErrorReporter', () => {
const errorReporting = new ErrorReporter(mock());
describe('beforeSend', () => {
it('should return null if originalException is an ApplicationError with level warning', () => {
const hint = { originalException: new ApplicationError('Test error', { level: 'warning' }) };
expect(errorReporting.beforeSend(mock(), hint)).toBeNull();
});
it('should return event if originalException is an ApplicationError with level error', () => {
const hint = { originalException: new ApplicationError('Test error', { level: 'error' }) };
expect(errorReporting.beforeSend(mock(), hint)).not.toBeNull();
});
it('should return null if originalException is an Error with a non-unique stack', () => {
const hint = { originalException: new Error('Test error') };
errorReporting.beforeSend(mock(), hint);
expect(errorReporting.beforeSend(mock(), hint)).toBeNull();
});
it('should return event if originalException is an Error with a unique stack', () => {
const hint = { originalException: new Error('Test error') };
expect(errorReporting.beforeSend(mock(), hint)).not.toBeNull();
});
});
});

View File

@ -1,99 +0,0 @@
import {
init,
setTag,
captureException,
close,
rewriteFramesIntegration,
type EventHint,
type ErrorEvent,
} from '@sentry/node';
import * as a from 'assert/strict';
import { createHash } from 'crypto';
import { ApplicationError } from 'n8n-workflow';
import type { SentryConfig } from '@/config/sentry-config';
/**
* Handles error reporting using Sentry
*/
export class ErrorReporter {
private isInitialized = false;
/** Hashes of error stack traces, to deduplicate error reports. */
private readonly seenErrors = new Set<string>();
private get dsn() {
return this.sentryConfig.sentryDsn;
}
constructor(private readonly sentryConfig: SentryConfig) {
a.ok(this.dsn, 'Sentry DSN is required to initialize Sentry');
}
async start() {
if (this.isInitialized) return;
// Collect longer stacktraces
Error.stackTraceLimit = 50;
process.on('uncaughtException', captureException);
const ENABLED_INTEGRATIONS = [
'InboundFilters',
'FunctionToString',
'LinkedErrors',
'OnUnhandledRejection',
'ContextLines',
];
setTag('server_type', 'task_runner');
init({
dsn: this.dsn,
release: this.sentryConfig.n8nVersion,
environment: this.sentryConfig.environment,
enableTracing: false,
serverName: this.sentryConfig.deploymentName,
beforeBreadcrumb: () => null,
beforeSend: async (event, hint) => await this.beforeSend(event, hint),
integrations: (integrations) => [
...integrations.filter(({ name }) => ENABLED_INTEGRATIONS.includes(name)),
rewriteFramesIntegration({ root: process.cwd() }),
],
});
this.isInitialized = true;
}
async stop() {
if (!this.isInitialized) {
return;
}
await close(1000);
}
async beforeSend(event: ErrorEvent, { originalException }: EventHint) {
if (!originalException) return null;
if (originalException instanceof Promise) {
originalException = await originalException.catch((error) => error as Error);
}
if (originalException instanceof ApplicationError) {
const { level, extra, tags } = originalException;
if (level === 'warning') return null;
event.level = level;
if (extra) event.extra = { ...event.extra, ...extra };
if (tags) event.tags = { ...event.tags, ...tags };
}
if (originalException instanceof Error && originalException.stack) {
const eventHash = createHash('sha1').update(originalException.stack).digest('base64');
if (this.seenErrors.has(eventHash)) return null;
this.seenErrors.add(eventHash);
}
return event;
}
}

View File

@ -1,8 +1,8 @@
import type { ErrorReporter } from 'n8n-core';
import { ensureError, setGlobalState } from 'n8n-workflow'; import { ensureError, setGlobalState } from 'n8n-workflow';
import Container from 'typedi'; import Container from 'typedi';
import { MainConfig } from './config/main-config'; import { MainConfig } from './config/main-config';
import type { ErrorReporter } from './error-reporter';
import type { HealthCheckServer } from './health-check-server'; import type { HealthCheckServer } from './health-check-server';
import { JsTaskRunner } from './js-task-runner/js-task-runner'; import { JsTaskRunner } from './js-task-runner/js-task-runner';
@ -33,7 +33,7 @@ function createSignalHandler(signal: string, timeoutInS = 10) {
} }
if (errorReporter) { if (errorReporter) {
await errorReporter.stop(); await errorReporter.shutdown();
errorReporter = undefined; errorReporter = undefined;
} }
} catch (e) { } catch (e) {
@ -54,9 +54,9 @@ void (async function start() {
}); });
if (config.sentryConfig.sentryDsn) { if (config.sentryConfig.sentryDsn) {
const { ErrorReporter } = await import('@/error-reporter'); const { ErrorReporter } = await import('n8n-core');
errorReporter = new ErrorReporter(config.sentryConfig); errorReporter = new ErrorReporter();
await errorReporter.start(); await errorReporter.init('task_runner', config.sentryConfig.sentryDsn);
} }
runner = new JsTaskRunner(config); runner = new JsTaskRunner(config);

View File

@ -34,7 +34,7 @@ import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-hi
export abstract class BaseCommand extends Command { export abstract class BaseCommand extends Command {
protected logger = Container.get(Logger); protected logger = Container.get(Logger);
protected readonly errorReporter = Container.get(ErrorReporter); protected errorReporter: ErrorReporter;
protected externalHooks?: ExternalHooks; protected externalHooks?: ExternalHooks;
@ -60,7 +60,11 @@ export abstract class BaseCommand extends Command {
protected needsCommunityPackages = false; protected needsCommunityPackages = false;
async init(): Promise<void> { async init(): Promise<void> {
await this.errorReporter.init(); this.errorReporter = Container.get(ErrorReporter);
await this.errorReporter.init(
this.instanceSettings.instanceType,
this.globalConfig.sentry.backendDsn,
);
initExpressionEvaluator(); initExpressionEvaluator();
process.once('SIGTERM', this.onTerminationSignal('SIGTERM')); process.once('SIGTERM', this.onTerminationSignal('SIGTERM'));

View File

@ -8,7 +8,7 @@
*/ */
export const retryUntil = async ( export const retryUntil = async (
assertion: () => Promise<void> | void, assertion: () => Promise<void> | void,
{ interval = 20, timeout = 1000 } = {}, { intervalMs = 200, timeoutMs = 5000 } = {},
) => { ) => {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const startTime = Date.now(); const startTime = Date.now();
@ -18,13 +18,13 @@ export const retryUntil = async (
try { try {
resolve(await assertion()); resolve(await assertion());
} catch (error) { } catch (error) {
if (Date.now() - startTime > timeout) { if (Date.now() - startTime > timeoutMs) {
reject(error); reject(error);
} else { } else {
tryAgain(); tryAgain();
} }
} }
}, interval); }, intervalMs);
}; };
tryAgain(); tryAgain();

View File

@ -1,26 +1,21 @@
import { GlobalConfig } from '@n8n/config';
import type { NodeOptions } from '@sentry/node'; import type { NodeOptions } from '@sentry/node';
import { close } from '@sentry/node';
import type { ErrorEvent, EventHint } from '@sentry/types'; import type { ErrorEvent, EventHint } from '@sentry/types';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { ApplicationError, LoggerProxy, type ReportingOptions } from 'n8n-workflow'; import { ApplicationError, LoggerProxy, type ReportingOptions } from 'n8n-workflow';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { InstanceSettings } from './InstanceSettings'; import type { InstanceType } from './InstanceSettings';
@Service() @Service()
export class ErrorReporter { export class ErrorReporter {
private initialized = false;
/** Hashes of error stack traces, to deduplicate error reports. */ /** Hashes of error stack traces, to deduplicate error reports. */
private seenErrors = new Set<string>(); private seenErrors = new Set<string>();
private report: (error: Error | string, options?: ReportingOptions) => void; private report: (error: Error | string, options?: ReportingOptions) => void;
constructor( constructor() {
private readonly globalConfig: GlobalConfig,
private readonly instanceSettings: InstanceSettings,
) {
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
this.report = this.defaultReport; this.report = this.defaultReport;
} }
@ -41,18 +36,16 @@ export class ErrorReporter {
} }
} }
async init() { async shutdown(timeoutInMs = 1000) {
if (this.initialized) return; await close(timeoutInMs);
}
async init(instanceType: InstanceType | 'task_runner', dsn: string) {
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
this.error(error); this.error(error);
}); });
const dsn = this.globalConfig.sentry.backendDsn; if (!dsn) return;
if (!dsn) {
this.initialized = true;
return;
}
// Collect longer stacktraces // Collect longer stacktraces
Error.stackTraceLimit = 50; Error.stackTraceLimit = 50;
@ -98,11 +91,9 @@ export class ErrorReporter {
], ],
}); });
setTag('server_type', this.instanceSettings.instanceType); setTag('server_type', instanceType);
this.report = (error, options) => captureException(error, options); this.report = (error, options) => captureException(error, options);
this.initialized = true;
} }
async beforeSend(event: ErrorEvent, { originalException }: EventHint) { async beforeSend(event: ErrorEvent, { originalException }: EventHint) {

View File

@ -1,17 +1,12 @@
import type { GlobalConfig } from '@n8n/config';
import { QueryFailedError } from '@n8n/typeorm'; import { QueryFailedError } from '@n8n/typeorm';
import type { ErrorEvent } from '@sentry/types'; import type { ErrorEvent } from '@sentry/types';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { mock } from 'jest-mock-extended';
import { ApplicationError } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow';
import { ErrorReporter } from '@/error-reporter'; import { ErrorReporter } from '@/error-reporter';
import type { InstanceSettings } from '@/InstanceSettings';
const init = jest.fn();
jest.mock('@sentry/node', () => ({ jest.mock('@sentry/node', () => ({
init, init: jest.fn(),
setTag: jest.fn(), setTag: jest.fn(),
captureException: jest.fn(), captureException: jest.fn(),
Integrations: {}, Integrations: {},
@ -20,9 +15,7 @@ jest.mock('@sentry/node', () => ({
jest.spyOn(process, 'on'); jest.spyOn(process, 'on');
describe('ErrorReporter', () => { describe('ErrorReporter', () => {
const globalConfig = mock<GlobalConfig>(); const errorReporter = new ErrorReporter();
const instanceSettings = mock<InstanceSettings>();
const errorReporting = new ErrorReporter(globalConfig, instanceSettings);
const event = {} as ErrorEvent; const event = {} as ErrorEvent;
describe('beforeSend', () => { describe('beforeSend', () => {
@ -30,14 +23,14 @@ describe('ErrorReporter', () => {
const originalException = new ApplicationError('test'); const originalException = new ApplicationError('test');
originalException.level = 'warning'; originalException.level = 'warning';
expect(await errorReporting.beforeSend(event, { originalException })).toEqual(null); expect(await errorReporter.beforeSend(event, { originalException })).toEqual(null);
}); });
it('should keep events with a cause with error level', async () => { it('should keep events with a cause with error level', async () => {
const cause = new Error('cause-error'); const cause = new Error('cause-error');
const originalException = new ApplicationError('test', cause); const originalException = new ApplicationError('test', cause);
expect(await errorReporting.beforeSend(event, { originalException })).toEqual(event); expect(await errorReporter.beforeSend(event, { originalException })).toEqual(event);
}); });
it('should ignore events with error cause with warning level', async () => { it('should ignore events with error cause with warning level', async () => {
@ -45,7 +38,7 @@ describe('ErrorReporter', () => {
cause.level = 'warning'; cause.level = 'warning';
const originalException = new ApplicationError('test', cause); const originalException = new ApplicationError('test', cause);
expect(await errorReporting.beforeSend(event, { originalException })).toEqual(null); expect(await errorReporter.beforeSend(event, { originalException })).toEqual(null);
}); });
it('should set level, extra, and tags from ApplicationError', async () => { it('should set level, extra, and tags from ApplicationError', async () => {
@ -57,7 +50,7 @@ describe('ErrorReporter', () => {
const testEvent = {} as ErrorEvent; const testEvent = {} as ErrorEvent;
const result = await errorReporting.beforeSend(testEvent, { originalException }); const result = await errorReporter.beforeSend(testEvent, { originalException });
expect(result).toEqual({ expect(result).toEqual({
level: 'error', level: 'error',
@ -69,17 +62,17 @@ describe('ErrorReporter', () => {
it('should deduplicate errors with same stack trace', async () => { it('should deduplicate errors with same stack trace', async () => {
const originalException = new Error(); const originalException = new Error();
const firstResult = await errorReporting.beforeSend(event, { originalException }); const firstResult = await errorReporter.beforeSend(event, { originalException });
expect(firstResult).toEqual(event); expect(firstResult).toEqual(event);
const secondResult = await errorReporting.beforeSend(event, { originalException }); const secondResult = await errorReporter.beforeSend(event, { originalException });
expect(secondResult).toBeNull(); expect(secondResult).toBeNull();
}); });
it('should handle Promise rejections', async () => { it('should handle Promise rejections', async () => {
const originalException = Promise.reject(new Error()); const originalException = Promise.reject(new Error());
const result = await errorReporting.beforeSend(event, { originalException }); const result = await errorReporter.beforeSend(event, { originalException });
expect(result).toEqual(event); expect(result).toEqual(event);
}); });
@ -103,7 +96,7 @@ describe('ErrorReporter', () => {
new Error('', { cause: new ApplicationError('', { level: 'warning' }) }), new Error('', { cause: new ApplicationError('', { level: 'warning' }) }),
], ],
])('should ignore if originalException is %s', async (_, originalException) => { ])('should ignore if originalException is %s', async (_, originalException) => {
const result = await errorReporting.beforeSend(event, { originalException }); const result = await errorReporter.beforeSend(event, { originalException });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });