Skip to content

Commit

Permalink
feat: Add sentry for task runner (no-changelog)
Browse files Browse the repository at this point in the history
To make sure we capture exceptions from the task runner process.
  • Loading branch information
tomi committed Nov 11, 2024
1 parent c08d23c commit e795d0b
Show file tree
Hide file tree
Showing 12 changed files with 206 additions and 49 deletions.
6 changes: 5 additions & 1 deletion docker/images/n8n/n8n-task-runners.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
"N8N_RUNNERS_MAX_CONCURRENCY",
"NODE_FUNCTION_ALLOW_BUILTIN",
"NODE_FUNCTION_ALLOW_EXTERNAL",
"NODE_OPTIONS"
"NODE_OPTIONS",
"N8N_SENTRY_DSN",
"N8N_VERSION",
"ENVIRONMENT",
"DEPLOYMENT_NAME"
],
"uid": 2000,
"gid": 2000
Expand Down
2 changes: 2 additions & 0 deletions packages/@n8n/task-runner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"@n8n/config": "workspace:*",
"acorn": "8.14.0",
"acorn-walk": "8.3.4",
"@sentry/integrations": "catalog:",
"@sentry/node": "catalog:",
"n8n-core": "workspace:*",
"n8n-workflow": "workspace:*",
"nanoid": "^3.3.6",
Expand Down
4 changes: 4 additions & 0 deletions packages/@n8n/task-runner/src/config/main-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -10,4 +11,7 @@ export class MainConfig {

@Nested
jsRunnerConfig!: JsRunnerConfig;

@Nested
sentryConfig!: SentryConfig;
}
21 changes: 21 additions & 0 deletions packages/@n8n/task-runner/src/config/sentry-config.ts
Original file line number Diff line number Diff line change
@@ -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
}
90 changes: 90 additions & 0 deletions packages/@n8n/task-runner/src/error-reporting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { RewriteFrames } from '@sentry/integrations';
import { init, setTag, captureException, close } 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 ErrorReporting {
private isInitialized = false;

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', (error) => {
captureException(error);
});

const enabledIntegrations = [
'InboundFilters',
'FunctionToString',
'LinkedErrors',
'OnUnhandledRejection',
'ContextLines',
];
const seenErrors = new Set<string>();

init({
dsn: this.dsn,
release: this.sentryConfig.n8nVersion,
environment: this.sentryConfig.environment,
enableTracing: false,
serverName: this.sentryConfig.deploymentName,
beforeBreadcrumb: () => null,
integrations: (integrations) => [
...integrations.filter(({ name }) => enabledIntegrations.includes(name)),
new RewriteFrames({ root: process.cwd() }),
],
async beforeSend(event, { originalException }) {
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 (seenErrors.has(eventHash)) return null;
seenErrors.add(eventHash);
}

return event;
},
});

setTag('server_type', 'task_runner');

this.isInitialized = true;
}

async stop() {
if (!this.isInitialized) {
return;
}

await close(1000);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ describe('JsTaskRunner', () => {
...defaultConfig.jsRunnerConfig,
...opts,
},
sentryConfig: {
sentryDsn: '',
deploymentName: '',
environment: '',
n8nVersion: '',
},
});

const defaultTaskRunner = createRunnerWithOpts();
Expand Down
14 changes: 14 additions & 0 deletions packages/@n8n/task-runner/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { ensureError } from 'n8n-workflow';
import Container from 'typedi';

import { MainConfig } from './config/main-config';
import type { ErrorReporting } from './error-reporting';
import { JsTaskRunner } from './js-task-runner/js-task-runner';

let runner: JsTaskRunner | undefined;
let isShuttingDown = false;
let errorReporting: ErrorReporting | undefined;

function createSignalHandler(signal: string) {
return async function onSignal() {
Expand All @@ -21,10 +23,16 @@ function createSignalHandler(signal: string) {
await runner.stop();
runner = undefined;
}

if (errorReporting) {
await errorReporting.stop();
errorReporting = undefined;
}
} catch (e) {
const error = ensureError(e);
console.error('Error stopping task runner', { error });
} finally {
console.log('Task runner stopped');
process.exit(0);
}
};
Expand All @@ -33,6 +41,12 @@ function createSignalHandler(signal: string) {
void (async function start() {
const config = Container.get(MainConfig);

if (config.sentryConfig.sentryDsn) {
const { ErrorReporting } = await import('@/error-reporting');
errorReporting = new ErrorReporting(config.sentryConfig);
await errorReporting.start();
}

runner = new JsTaskRunner(config);

process.on('SIGINT', createSignalHandler('SIGINT'));
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@
"@n8n_io/license-sdk": "2.13.1",
"@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.0.9",
"@sentry/integrations": "7.87.0",
"@sentry/node": "7.87.0",
"@sentry/integrations": "catalog:",
"@sentry/node": "catalog:",
"aws4": "1.11.0",
"axios": "catalog:",
"bcryptjs": "2.4.3",
Expand Down
39 changes: 22 additions & 17 deletions packages/cli/src/runners/__tests__/task-runner-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,28 @@ describe('TaskRunnerProcess', () => {
taskRunnerProcess = new TaskRunnerProcess(logger, runnerConfig, authService);
});

test.each(['PATH', 'NODE_FUNCTION_ALLOW_BUILTIN', 'NODE_FUNCTION_ALLOW_EXTERNAL'])(
'should propagate %s from env as is',
async (envVar) => {
jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken');
process.env[envVar] = 'custom value';

await taskRunnerProcess.start();

// @ts-expect-error The type is not correct
const options = spawnMock.mock.calls[0][2] as SpawnOptions;
expect(options.env).toEqual(
expect.objectContaining({
[envVar]: 'custom value',
}),
);
},
);
test.each([
'PATH',
'NODE_FUNCTION_ALLOW_BUILTIN',
'NODE_FUNCTION_ALLOW_EXTERNAL',
'N8N_SENTRY_DSN',
'N8N_VERSION',
'ENVIRONMENT',
'DEPLOYMENT_NAME',
])('should propagate %s from env as is', async (envVar) => {
jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken');
process.env[envVar] = 'custom value';

await taskRunnerProcess.start();

// @ts-expect-error The type is not correct
const options = spawnMock.mock.calls[0][2] as SpawnOptions;
expect(options.env).toEqual(
expect.objectContaining({
[envVar]: 'custom value',
}),
);
});

it('should pass NODE_OPTIONS env if maxOldSpaceSize is configured', async () => {
jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken');
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/runners/task-runner-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
'PATH',
'NODE_FUNCTION_ALLOW_BUILTIN',
'NODE_FUNCTION_ALLOW_EXTERNAL',
'N8N_SENTRY_DSN',
// Metadata about the environment
'N8N_VERSION',
'ENVIRONMENT',
'DEPLOYMENT_NAME',
] as const;

constructor(
Expand Down
Loading

0 comments on commit e795d0b

Please sign in to comment.