feat(core): Add sentry for task runner (no-changelog) (#11683)

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
Tomi Turtiainen
2024-11-15 11:35:02 +02:00
committed by GitHub
parent f4f0b5110c
commit f1ca8c128b
13 changed files with 238 additions and 47 deletions

View File

@@ -35,6 +35,8 @@
},
"dependencies": {
"@n8n/config": "workspace:*",
"@sentry/integrations": "catalog:",
"@sentry/node": "catalog:",
"acorn": "8.14.0",
"acorn-walk": "8.3.4",
"n8n-core": "workspace:*",

View File

@@ -0,0 +1,31 @@
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

@@ -2,6 +2,7 @@ import { Config, Nested } from '@n8n/config';
import { BaseRunnerConfig } from './base-runner-config';
import { JsRunnerConfig } from './js-runner-config';
import { SentryConfig } from './sentry-config';
@Config
export class MainConfig {
@@ -10,4 +11,7 @@ export class MainConfig {
@Nested
jsRunnerConfig!: JsRunnerConfig;
@Nested
sentryConfig!: SentryConfig;
}

View File

@@ -0,0 +1,21 @@
import { Config, Env } from '@n8n/config';
@Config
export class SentryConfig {
/** Sentry DSN */
@Env('N8N_SENTRY_DSN')
sentryDsn: string = '';
//#region Metadata about the environment
@Env('N8N_VERSION')
n8nVersion: string = '';
@Env('ENVIRONMENT')
environment: string = '';
@Env('DEPLOYMENT_NAME')
deploymentName: string = '';
//#endregion
}

View File

@@ -0,0 +1,93 @@
import { RewriteFrames } from '@sentry/integrations';
import { init, setTag, captureException, close } from '@sentry/node';
import type { ErrorEvent, EventHint } from '@sentry/types';
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)),
new RewriteFrames({ 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

@@ -36,6 +36,12 @@ describe('JsTaskRunner', () => {
...defaultConfig.jsRunnerConfig,
...opts,
},
sentryConfig: {
sentryDsn: '',
deploymentName: '',
environment: '',
n8nVersion: '',
},
});
const defaultTaskRunner = createRunnerWithOpts();

View File

@@ -2,10 +2,12 @@ import { ensureError } from 'n8n-workflow';
import Container from 'typedi';
import { MainConfig } from './config/main-config';
import type { ErrorReporter } from './error-reporter';
import { JsTaskRunner } from './js-task-runner/js-task-runner';
let runner: JsTaskRunner | undefined;
let isShuttingDown = false;
let errorReporter: ErrorReporter | undefined;
function createSignalHandler(signal: string) {
return async function onSignal() {
@@ -21,10 +23,16 @@ function createSignalHandler(signal: string) {
await runner.stop();
runner = undefined;
}
if (errorReporter) {
await errorReporter.stop();
errorReporter = undefined;
}
} catch (e) {
const error = ensureError(e);
console.error('Error stopping task runner', { error });
} finally {
console.log('Task runner stopped');
process.exit(0);
}
};
@@ -33,6 +41,12 @@ function createSignalHandler(signal: string) {
void (async function start() {
const config = Container.get(MainConfig);
if (config.sentryConfig.sentryDsn) {
const { ErrorReporter } = await import('@/error-reporter');
errorReporter = new ErrorReporter(config.sentryConfig);
await errorReporter.start();
}
runner = new JsTaskRunner(config);
process.on('SIGINT', createSignalHandler('SIGINT'));