diff --git a/docker/images/n8n/n8n-task-runners.json b/docker/images/n8n/n8n-task-runners.json index 699794d504..9eab58d91b 100644 --- a/docker/images/n8n/n8n-task-runners.json +++ b/docker/images/n8n/n8n-task-runners.json @@ -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 diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index b595fefe6e..dd9ee6ae17 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -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:*", diff --git a/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts b/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts new file mode 100644 index 0000000000..9345819329 --- /dev/null +++ b/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/@n8n/task-runner/src/config/main-config.ts b/packages/@n8n/task-runner/src/config/main-config.ts index a290c0c380..10b504f1d6 100644 --- a/packages/@n8n/task-runner/src/config/main-config.ts +++ b/packages/@n8n/task-runner/src/config/main-config.ts @@ -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; } diff --git a/packages/@n8n/task-runner/src/config/sentry-config.ts b/packages/@n8n/task-runner/src/config/sentry-config.ts new file mode 100644 index 0000000000..691f64244f --- /dev/null +++ b/packages/@n8n/task-runner/src/config/sentry-config.ts @@ -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 +} diff --git a/packages/@n8n/task-runner/src/error-reporter.ts b/packages/@n8n/task-runner/src/error-reporter.ts new file mode 100644 index 0000000000..167cc37c92 --- /dev/null +++ b/packages/@n8n/task-runner/src/error-reporter.ts @@ -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(); + + 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; + } +} diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index 621a9c81a7..cd966ef8ac 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -36,6 +36,12 @@ describe('JsTaskRunner', () => { ...defaultConfig.jsRunnerConfig, ...opts, }, + sentryConfig: { + sentryDsn: '', + deploymentName: '', + environment: '', + n8nVersion: '', + }, }); const defaultTaskRunner = createRunnerWithOpts(); diff --git a/packages/@n8n/task-runner/src/start.ts b/packages/@n8n/task-runner/src/start.ts index fcaab84d51..c6e8cb314c 100644 --- a/packages/@n8n/task-runner/src/start.ts +++ b/packages/@n8n/task-runner/src/start.ts @@ -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')); diff --git a/packages/cli/package.json b/packages/cli/package.json index db203199f7..1dcbbebbd1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -98,8 +98,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", diff --git a/packages/cli/src/runners/__tests__/task-runner-process.test.ts b/packages/cli/src/runners/__tests__/task-runner-process.test.ts index fbab9ee1e3..92e8483d03 100644 --- a/packages/cli/src/runners/__tests__/task-runner-process.test.ts +++ b/packages/cli/src/runners/__tests__/task-runner-process.test.ts @@ -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'; + 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(); + 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', - }), - ); - }, - ); + // @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'); diff --git a/packages/cli/src/runners/task-runner-process.ts b/packages/cli/src/runners/task-runner-process.ts index 9e731a99c5..ba63cbe9e7 100644 --- a/packages/cli/src/runners/task-runner-process.ts +++ b/packages/cli/src/runners/task-runner-process.ts @@ -59,6 +59,11 @@ export class TaskRunnerProcess extends TypedEmitter { 'PATH', 'NODE_FUNCTION_ALLOW_BUILTIN', 'NODE_FUNCTION_ALLOW_EXTERNAL', + 'N8N_SENTRY_DSN', + // Metadata about the environment + 'N8N_VERSION', + 'ENVIRONMENT', + 'DEPLOYMENT_NAME', ] as const; constructor( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a43366f16..e0568d9dc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,12 @@ catalogs: '@langchain/core': specifier: 0.3.15 version: 0.3.15 + '@sentry/integrations': + specifier: 7.87.0 + version: 7.87.0 + '@sentry/node': + specifier: 7.87.0 + version: 7.87.0 '@types/basic-auth': specifier: ^1.1.3 version: 1.1.3 @@ -271,7 +277,7 @@ importers: version: 4.0.7 axios: specifier: 'catalog:' - version: 1.7.4 + version: 1.7.4(debug@4.3.7) dotenv: specifier: 8.6.0 version: 8.6.0 @@ -339,7 +345,7 @@ importers: dependencies: axios: specifier: 'catalog:' - version: 1.7.4 + version: 1.7.4(debug@4.3.7) packages/@n8n/codemirror-lang: dependencies: @@ -413,7 +419,7 @@ importers: version: 3.666.0(@aws-sdk/client-sts@3.666.0) '@getzep/zep-cloud': specifier: 1.0.12 - version: 1.0.12(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.5(7umjwzmwnymi4lyinuvazmp6ki)) + version: 1.0.12(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.5(4ubssgvn2k3t3hxnzmxuoc2aja)) '@getzep/zep-js': specifier: 0.9.0 version: 0.9.0 @@ -440,7 +446,7 @@ importers: version: 0.3.1(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) '@langchain/community': specifier: 0.3.11 - version: 0.3.11(simkpjwqw7qnwbripe37u5qu7a) + version: 0.3.11(tzffvezibmkr4px5bpuitcp7xu) '@langchain/core': specifier: 'catalog:' version: 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)) @@ -527,7 +533,7 @@ importers: version: 23.0.1 langchain: specifier: 0.3.5 - version: 0.3.5(7umjwzmwnymi4lyinuvazmp6ki) + version: 0.3.5(4ubssgvn2k3t3hxnzmxuoc2aja) lodash: specifier: 'catalog:' version: 4.17.21 @@ -648,6 +654,12 @@ importers: '@n8n/config': specifier: workspace:* version: link:../config + '@sentry/integrations': + specifier: 'catalog:' + version: 7.87.0 + '@sentry/node': + specifier: 'catalog:' + version: 7.87.0 acorn: specifier: 8.14.0 version: 8.14.0 @@ -770,17 +782,17 @@ importers: specifier: 2.0.9 version: 2.0.9(tslib@2.6.2) '@sentry/integrations': - specifier: 7.87.0 + specifier: 'catalog:' version: 7.87.0 '@sentry/node': - specifier: 7.87.0 + specifier: 'catalog:' version: 7.87.0 aws4: specifier: 1.11.0 version: 1.11.0 axios: specifier: 'catalog:' - version: 1.7.4 + version: 1.7.4(debug@4.3.7) bcryptjs: specifier: 2.4.3 version: 2.4.3 @@ -1111,7 +1123,7 @@ importers: version: 1.11.0 axios: specifier: 'catalog:' - version: 1.7.4 + version: 1.7.4(debug@4.3.7) concat-stream: specifier: 2.0.0 version: 2.0.0 @@ -1401,7 +1413,7 @@ importers: version: 10.11.0(vue@3.5.11(typescript@5.6.2)) axios: specifier: 'catalog:' - version: 1.7.4 + version: 1.7.4(debug@4.3.7) bowser: specifier: 2.11.0 version: 2.11.0 @@ -1881,7 +1893,7 @@ importers: version: 0.15.2 axios: specifier: 'catalog:' - version: 1.7.4 + version: 1.7.4(debug@4.3.7) callsites: specifier: 3.1.0 version: 3.1.0 @@ -14082,7 +14094,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.5(7umjwzmwnymi4lyinuvazmp6ki))': + '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.5(4ubssgvn2k3t3hxnzmxuoc2aja))': dependencies: form-data: 4.0.0 node-fetch: 2.7.0(encoding@0.1.13) @@ -14091,7 +14103,7 @@ snapshots: zod: 3.23.8 optionalDependencies: '@langchain/core': 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)) - langchain: 0.3.5(7umjwzmwnymi4lyinuvazmp6ki) + langchain: 0.3.5(4ubssgvn2k3t3hxnzmxuoc2aja) transitivePeerDependencies: - encoding @@ -14558,7 +14570,7 @@ snapshots: - aws-crt - encoding - '@langchain/community@0.3.11(simkpjwqw7qnwbripe37u5qu7a)': + '@langchain/community@0.3.11(tzffvezibmkr4px5bpuitcp7xu)': dependencies: '@ibm-cloud/watsonx-ai': 1.1.2 '@langchain/core': 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)) @@ -14568,7 +14580,7 @@ snapshots: flat: 5.0.2 ibm-cloud-sdk-core: 5.1.0 js-yaml: 4.1.0 - langchain: 0.3.5(7umjwzmwnymi4lyinuvazmp6ki) + langchain: 0.3.5(4ubssgvn2k3t3hxnzmxuoc2aja) langsmith: 0.2.3(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)) uuid: 10.0.0 zod: 3.23.8 @@ -14581,7 +14593,7 @@ snapshots: '@aws-sdk/client-s3': 3.666.0 '@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0) '@azure/storage-blob': 12.18.0(encoding@0.1.13) - '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.5(7umjwzmwnymi4lyinuvazmp6ki)) + '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.5(4ubssgvn2k3t3hxnzmxuoc2aja)) '@getzep/zep-js': 0.9.0 '@google-ai/generativelanguage': 2.6.0(encoding@0.1.13) '@google-cloud/storage': 7.12.1(encoding@0.1.13) @@ -15294,7 +15306,7 @@ snapshots: '@rudderstack/rudder-sdk-node@2.0.9(tslib@2.6.2)': dependencies: - axios: 1.7.4 + axios: 1.7.4(debug@4.3.7) axios-retry: 3.7.0 component-type: 1.2.1 join-component: 1.1.0 @@ -17550,17 +17562,9 @@ snapshots: '@babel/runtime': 7.24.7 is-retry-allowed: 2.2.0 - axios@1.7.4: - dependencies: - follow-redirects: 1.15.6(debug@4.3.6) - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.7.4(debug@4.3.7): dependencies: - follow-redirects: 1.15.6(debug@4.3.7) + follow-redirects: 1.15.6(debug@4.3.6) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -19915,7 +19919,7 @@ snapshots: gaxios@6.6.0(encoding@0.1.13): dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 is-stream: 2.0.1 node-fetch: 2.7.0(encoding@0.1.13) uuid: 9.0.1 @@ -21396,7 +21400,7 @@ snapshots: kuler@2.0.0: {} - langchain@0.3.5(7umjwzmwnymi4lyinuvazmp6ki): + langchain@0.3.5(4ubssgvn2k3t3hxnzmxuoc2aja): dependencies: '@langchain/core': 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)) '@langchain/openai': 0.3.11(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) @@ -21420,7 +21424,7 @@ snapshots: '@langchain/groq': 0.1.2(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) '@langchain/mistralai': 0.1.1(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) '@langchain/ollama': 0.1.1(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8))) - axios: 1.7.4 + axios: 1.7.4(debug@4.3.7) cheerio: 1.0.0 handlebars: 4.7.8 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 52e1230ca3..8b692007af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,8 @@ packages: - cypress catalog: + '@sentry/integrations': 7.87.0 + '@sentry/node': 7.87.0 '@types/basic-auth': ^1.1.3 '@types/express': ^4.17.21 '@types/lodash': ^4.14.195