first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,213 @@
import type { Logger } from '@n8n/backend-common';
import { QueryFailedError } from '@n8n/typeorm';
import type { ErrorEvent } from '@sentry/core';
import { AxiosError } from 'axios';
import { mock } from 'jest-mock-extended';
import { ApplicationError, BaseError } from 'n8n-workflow';
import { ErrorReporter } from '../error-reporter';
jest.mock('@sentry/node', () => ({
init: jest.fn(),
setTag: jest.fn(),
captureException: jest.fn(),
Integrations: {},
}));
jest.spyOn(process, 'on');
describe('ErrorReporter', () => {
const errorReporter = new ErrorReporter(mock(), mock());
const event = {} as ErrorEvent;
describe('beforeSend', () => {
it('should ignore errors with level warning', async () => {
const originalException = new ApplicationError('test');
originalException.level = 'warning';
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(null);
});
it('should keep events with a cause with error level', async () => {
const cause = new Error('cause-error');
const originalException = new ApplicationError('test', cause);
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(event);
});
it('should ignore events with error cause with warning level', async () => {
const cause: Error & { level?: 'warning' } = new Error('cause-error');
cause.level = 'warning';
const originalException = new ApplicationError('test', cause);
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(null);
});
it('should set level, extra, and tags from ApplicationError', async () => {
const originalException = new ApplicationError('Test error', {
level: 'error',
extra: { foo: 'bar' },
tags: { tag1: 'value1' },
});
const testEvent = {} as ErrorEvent;
const result = await errorReporter.beforeSend(testEvent, { originalException });
expect(result).toEqual({
level: 'error',
extra: { foo: 'bar' },
tags: { tag1: 'value1' },
});
});
it('should deduplicate errors with same stack trace', async () => {
const originalException = new Error();
const firstResult = await errorReporter.beforeSend(event, { originalException });
expect(firstResult).toEqual(event);
const secondResult = await errorReporter.beforeSend(event, { originalException });
expect(secondResult).toBeNull();
});
it('should handle Promise rejections', async () => {
const originalException = Promise.reject(new Error());
const result = await errorReporter.beforeSend(event, { originalException });
expect(result).toEqual(event);
});
test.each([
['undefined', undefined],
['null', null],
['an AxiosError', new AxiosError()],
['a rejected Promise with AxiosError', Promise.reject(new AxiosError())],
[
'a QueryFailedError with SQLITE_FULL',
new QueryFailedError('', [], new Error('SQLITE_FULL')),
],
[
'a QueryFailedError with SQLITE_IOERR',
new QueryFailedError('', [], new Error('SQLITE_IOERR')),
],
['an ApplicationError with "warning" level', new ApplicationError('', { level: 'warning' })],
[
'an Error with ApplicationError as cause with "warning" level',
new Error('', { cause: new ApplicationError('', { level: 'warning' }) }),
],
])('should ignore if originalException is %s', async (_, originalException) => {
const result = await errorReporter.beforeSend(event, { originalException });
expect(result).toBeNull();
});
describe('beforeSendFilter', () => {
const newErrorReportedWithBeforeSendFilter = (beforeSendFilter: jest.Mock) => {
const errorReporter = new ErrorReporter(mock(), mock());
// @ts-expect-error - beforeSendFilter is private
errorReporter.beforeSendFilter = beforeSendFilter;
return errorReporter;
};
it('should filter out based on the beforeSendFilter', async () => {
const beforeSendFilter = jest.fn().mockReturnValue(true);
const errorReporter = newErrorReportedWithBeforeSendFilter(beforeSendFilter);
const hint = { originalException: new Error() };
const result = await errorReporter.beforeSend(event, hint);
expect(result).toBeNull();
expect(beforeSendFilter).toHaveBeenCalledWith(event, hint);
});
it('should not filter out when beforeSendFilter returns false', async () => {
const beforeSendFilter = jest.fn().mockReturnValue(false);
const errorReporter = newErrorReportedWithBeforeSendFilter(beforeSendFilter);
const hint = { originalException: new Error() };
const result = await errorReporter.beforeSend(event, hint);
expect(result).toEqual(event);
expect(beforeSendFilter).toHaveBeenCalledWith(event, hint);
});
});
describe('BaseError', () => {
class TestError extends BaseError {}
it('should drop errors with shouldReport false', async () => {
const originalException = new TestError('test', { shouldReport: false });
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(null);
});
it('should keep events with shouldReport true', async () => {
const originalException = new TestError('test', { shouldReport: true });
expect(await errorReporter.beforeSend(event, { originalException })).toEqual(event);
});
it('should set level, extra, and tags from BaseError', async () => {
const originalException = new TestError('Test error', {
level: 'error',
extra: { foo: 'bar' },
tags: { tag1: 'value1' },
});
const testEvent = {} as ErrorEvent;
const result = await errorReporter.beforeSend(testEvent, { originalException });
expect(result).toEqual({
level: 'error',
extra: { foo: 'bar' },
tags: {
packageName: 'core',
tag1: 'value1',
},
});
});
});
});
describe('error', () => {
let error: ApplicationError;
let logger: Logger;
let errorReporter: ErrorReporter;
const metadata = undefined;
beforeEach(() => {
error = new ApplicationError('Test error');
logger = mock<Logger>();
errorReporter = new ErrorReporter(logger, mock());
});
it('should include stack trace for error-level `ApplicationError`', () => {
error.level = 'error';
errorReporter.error(error);
expect(logger.error).toHaveBeenCalledWith(`Test error\n${error.stack}\n`, metadata);
});
it('should exclude stack trace for warning-level `ApplicationError`', () => {
error.level = 'warning';
errorReporter.error(error);
expect(logger.error).toHaveBeenCalledWith('Test error', metadata);
});
it.each([true, undefined])(
'should log the error when shouldBeLogged is %s',
(shouldBeLogged) => {
error.level = 'error';
errorReporter.error(error, { shouldBeLogged });
expect(logger.error).toHaveBeenCalledTimes(1);
},
);
it('should not log the error when shouldBeLogged is false', () => {
error.level = 'error';
errorReporter.error(error, { shouldBeLogged: false });
expect(logger.error).toHaveBeenCalledTimes(0);
});
});
});
@@ -0,0 +1,3 @@
import { ApplicationError } from '@n8n/errors';
export abstract class BinaryDataError extends ApplicationError {}
@@ -0,0 +1,7 @@
import { ApplicationError } from '@n8n/errors';
export abstract class FileSystemError extends ApplicationError {
constructor(message: string, filePath: string) {
super(message, { extra: { filePath } });
}
}
@@ -0,0 +1,7 @@
import { UnexpectedError } from 'n8n-workflow';
export class BinaryDataFileNotFoundError extends UnexpectedError {
constructor(fileId: string) {
super('Binary data file not found', { extra: { fileId } });
}
}
@@ -0,0 +1,7 @@
import { FileSystemError } from './abstract/filesystem.error';
export class DisallowedFilepathError extends FileSystemError {
constructor(filePath: string) {
super('Disallowed path detected', filePath);
}
}
+364
View File
@@ -0,0 +1,364 @@
import { inTest, Logger } from '@n8n/backend-common';
import { type InstanceType } from '@n8n/constants';
import { Service } from '@n8n/di';
import type { ReportingOptions } from '@n8n/errors';
import type { ErrorEvent, EventHint } from '@sentry/core';
import type { NodeOptions } from '@sentry/node';
import { AxiosError } from 'axios';
import { ApplicationError, ExecutionCancelledError, BaseError } from 'n8n-workflow';
import { createHash } from 'node:crypto';
import { Tracing, SentryTracing } from '@/observability';
type SentryIntegration = 'Redis' | 'Postgres' | 'Http' | 'Express';
type ErrorReporterInitOptions = {
serverType: InstanceType | 'task_runner';
dsn: string;
release: string;
environment: string;
serverName: string;
releaseDate?: Date;
/** Whether to enable event loop block detection, if Sentry is enabled. */
withEventLoopBlockDetection: boolean;
/** Threshold in ms for event loop block detection. Only used if `withEventLoopBlockDetection` is true. */
eventLoopBlockThreshold?: number;
/** Sample rate for Sentry traces (0.0 to 1.0). 0 means disabled */
tracesSampleRate: number;
/** Sample rate for Sentry profiling (0.0 to 1.0). 0 means disabled */
profilesSampleRate: number;
/**
* Function to allow filtering out errors before they are sent to Sentry.
* Return true if the error should be filtered out.
*/
beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
/**
* Integrations eligible for enablement. `tracesSampleRate` still determines
* whether they are actually enabled or not.
*/
eligibleIntegrations?: Partial<Record<SentryIntegration, boolean>>;
/** Health endpoint path */
healthEndpoint?: string;
};
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
const SIX_WEEKS_IN_MS = 6 * 7 * ONE_DAY_IN_MS;
const RELEASE_EXPIRATION_WARNING =
'Error tracking disabled because this release is older than 6 weeks.';
@Service()
export class ErrorReporter {
private expirationTimer?: NodeJS.Timeout;
/** Hashes of error stack traces, to deduplicate error reports. */
private seenErrors = new Set<string>();
private report: (error: Error | string, options?: ReportingOptions) => void;
private beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean;
constructor(
private readonly logger: Logger,
private readonly tracing: Tracing,
) {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.report = this.defaultReport;
}
private defaultReport(error: Error | string, options?: ReportingOptions) {
if (error instanceof Error) {
let e = error;
const { executionId } = options ?? {};
const context = executionId ? ` (execution ${executionId})` : '';
do {
let stack = '';
let meta = undefined;
if (e instanceof ApplicationError || e instanceof BaseError) {
if (e.level === 'error' && e.stack) {
stack = `\n${e.stack}\n`;
}
meta = e.extra;
}
const msg = [e.message + context, stack].join('');
// Default to logging the error if option is not specified
if (options?.shouldBeLogged ?? true) {
this.logger.error(msg, meta);
}
e = e.cause as Error;
} while (e);
}
}
async shutdown(timeoutInMs = 1000) {
clearTimeout(this.expirationTimer);
const { close } = await import('@sentry/node');
await close(timeoutInMs);
}
async init({
beforeSendFilter,
dsn,
serverType,
release,
environment,
serverName,
releaseDate,
withEventLoopBlockDetection,
eventLoopBlockThreshold,
profilesSampleRate,
tracesSampleRate,
eligibleIntegrations = {},
healthEndpoint = '/healthz',
}: ErrorReporterInitOptions) {
if (inTest) return;
process.on('uncaughtException', (error) => {
this.error(error);
});
if (releaseDate) {
const releaseExpiresAtMs = releaseDate.getTime() + SIX_WEEKS_IN_MS;
const releaseExpiresInMs = () => releaseExpiresAtMs - Date.now();
if (releaseExpiresInMs() <= 0) {
this.logger.warn(RELEASE_EXPIRATION_WARNING);
return;
}
const checkForExpiration = () => {
// Once this release expires, reject all events
if (releaseExpiresInMs() <= 0) {
this.logger.warn(RELEASE_EXPIRATION_WARNING);
// eslint-disable-next-line @typescript-eslint/unbound-method
this.report = this.defaultReport;
} else {
this.expirationTimer = setTimeout(checkForExpiration, ONE_DAY_IN_MS);
}
};
checkForExpiration();
}
if (!dsn) return;
// Collect longer stacktraces
Error.stackTraceLimit = 50;
const sentry = await import('@sentry/node');
const {
init,
captureException,
setTag,
setUser,
requestDataIntegration,
rewriteFramesIntegration,
} = sentry;
// Most of the integrations are listed here:
// https://docs.sentry.io/platforms/javascript/guides/node/configuration/integrations/
const enabledIntegrations = new Set([
'InboundFilters',
'FunctionToString',
'LinkedErrors',
'OnUnhandledRejection',
'ContextLines',
]);
const isTracingEnabled = tracesSampleRate > 0;
if (isTracingEnabled) {
const tracingIntegrations: SentryIntegration[] = ['Http', 'Postgres', 'Redis', 'Express'];
tracingIntegrations
.filter((integrationName) => !!eligibleIntegrations[integrationName])
.forEach((integrationName) => enabledIntegrations.add(integrationName));
this.tracing.setTracingImplementation(new SentryTracing(sentry));
}
const isProfilingEnabled = profilesSampleRate > 0;
if (isProfilingEnabled && !isTracingEnabled) {
this.logger.warn('Profiling is enabled but tracing is disabled. Profiling will not work.');
}
const eventLoopBlockIntegration = withEventLoopBlockDetection
? // The EventLoopBlockIntegration doesn't automatically include the
// same tags, so we set them explicitly.
await this.getEventLoopBlockIntegration(
{
server_name: serverName,
server_type: serverType,
},
eventLoopBlockThreshold,
)
: [];
const profilingIntegration = isProfilingEnabled ? await this.getProfilingIntegration() : [];
init({
dsn,
release,
environment,
serverName,
...(isTracingEnabled ? { tracesSampleRate } : {}),
...(isProfilingEnabled ? { profilesSampleRate, profileLifecycle: 'trace' } : {}),
beforeSend: this.beforeSend.bind(this) as NodeOptions['beforeSend'],
ignoreTransactions: [`GET ${healthEndpoint}`, 'GET /metrics', 'SET search_path TO'],
ignoreSpans: [`GET ${healthEndpoint}`, 'GET /metrics', 'SET search_path TO'],
integrations: (integrations) => [
...integrations.filter(({ name }) => enabledIntegrations.has(name)),
rewriteFramesIntegration({ root: '/' }),
requestDataIntegration({
include: {
cookies: false,
data: false,
headers: false,
query_string: false,
url: true,
},
}),
...eventLoopBlockIntegration,
...profilingIntegration,
],
});
setTag('server_type', serverType);
if (serverName) {
setUser({ id: serverName });
}
this.report = (error, options) => captureException(error, options);
this.beforeSendFilter = beforeSendFilter;
}
async beforeSend(event: ErrorEvent, hint: EventHint) {
let { originalException } = hint;
if (!originalException) return null;
if (originalException instanceof Promise) {
originalException = await originalException.catch((error) => error as Error);
}
if (
this.beforeSendFilter?.(event, {
...hint,
originalException,
})
) {
return null;
}
if (originalException instanceof AxiosError) return null;
if (originalException instanceof BaseError) {
if (!originalException.shouldReport) return null;
this.extractEventDetailsFromN8nError(event, originalException);
}
if (this.isIgnoredSqliteError(originalException)) return null;
if (originalException instanceof ApplicationError || originalException instanceof BaseError) {
if (this.isIgnoredN8nError(originalException)) return null;
this.extractEventDetailsFromN8nError(event, originalException);
}
if (
originalException instanceof Error &&
'cause' in originalException &&
originalException.cause instanceof Error &&
'level' in originalException.cause &&
(originalException.cause.level === 'warning' || originalException.cause.level === 'info')
) {
// handle underlying errors propagating from dependencies like ai-assistant-sdk
return null;
}
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;
}
error(e: unknown, options?: ReportingOptions) {
if (e instanceof ExecutionCancelledError) return;
const toReport = this.wrap(e);
if (toReport) this.report(toReport, options);
}
warn(warning: Error | string, options?: ReportingOptions) {
this.error(warning, { ...options, level: 'warning' });
}
info(msg: string, options?: ReportingOptions) {
this.report(msg, { ...options, level: 'info' });
}
private wrap(e: unknown) {
if (e instanceof Error) return e;
if (typeof e === 'string') return new ApplicationError(e);
return;
}
/** @returns true if the error should be filtered out */
private isIgnoredSqliteError(error: unknown) {
return (
error instanceof Error &&
error.name === 'QueryFailedError' &&
typeof error.message === 'string' &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => error.message.includes(errMsg))
);
}
private isIgnoredN8nError(error: ApplicationError | BaseError) {
return error.level === 'warning' || error.level === 'info';
}
private extractEventDetailsFromN8nError(
event: ErrorEvent,
originalException: ApplicationError | BaseError,
) {
const { level, extra, tags } = originalException;
event.level = level;
if (extra) event.extra = { ...event.extra, ...extra };
if (tags) event.tags = { ...event.tags, ...tags };
}
private async getEventLoopBlockIntegration(tags: Record<string, string>, threshold?: number) {
try {
const { eventLoopBlockIntegration } = await import('@sentry/node-native');
return [
eventLoopBlockIntegration({
...(threshold ? { threshold } : {}),
staticTags: tags,
}),
];
} catch {
this.logger.warn(
"Sentry's event loop block integration is disabled, because the native binary for `@sentry/node-native` was not found",
);
return [];
}
}
private async getProfilingIntegration() {
try {
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
return [nodeProfilingIntegration()];
} catch {
this.logger.warn(
'Sentry profiling is disabled, because the `@sentry/profiling-node` package was not found',
);
return [];
}
}
}
@@ -0,0 +1,7 @@
import { FileSystemError } from './abstract/filesystem.error';
export class FileNotFoundError extends FileSystemError {
constructor(filePath: string) {
super('File not found', filePath);
}
}
@@ -0,0 +1,21 @@
import { UserError } from 'n8n-workflow';
export class FileTooLargeError extends UserError {
constructor({
fileSizeMb,
maxFileSizeMb,
fileId,
fileName,
}: {
fileSizeMb: number;
maxFileSizeMb: number;
fileId: string;
fileName?: string;
}) {
const id = fileName ? `"${fileName}" (${fileId})` : fileId;
const roundedSize = Math.round(fileSizeMb * 100) / 100;
super(
`Failed to write binary file ${id} because its size of ${roundedSize} MB exceeds the max size limit of ${maxFileSizeMb} MB set for \`database\` mode. Consider increasing \`N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE\` up to 1 GB, or using S3 storage mode if you require writes larger than 1 GB.`,
);
}
}
+12
View File
@@ -0,0 +1,12 @@
export { BinaryDataFileNotFoundError } from './binary-data-file-not-found.error';
export { FileNotFoundError } from './file-not-found.error';
export { FileTooLargeError } from './file-too-large.error';
export { DisallowedFilepathError } from './disallowed-filepath.error';
export { InvalidManagerError } from './invalid-manager.error';
export { InvalidExecutionMetadataError } from './invalid-execution-metadata.error';
export { InvalidSourceTypeError } from './invalid-source-type.error';
export { MissingSourceIdError } from './missing-source-id.error';
export { UnrecognizedCredentialTypeError } from './unrecognized-credential-type.error';
export { UnrecognizedNodeTypeError } from './unrecognized-node-type.error';
export { ErrorReporter } from './error-reporter';
@@ -0,0 +1,13 @@
import { ApplicationError } from '@n8n/errors';
export class InvalidExecutionMetadataError extends ApplicationError {
constructor(
public type: 'key' | 'value',
key: unknown,
message?: string,
options?: ErrorOptions,
) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
super(message ?? `Custom data ${type}s must be a string (key "${key}")`, options);
}
}
@@ -0,0 +1,7 @@
import { BinaryDataError } from './abstract/binary-data.error';
export class InvalidManagerError extends BinaryDataError {
constructor(mode: string) {
super(`No binary data manager found for: ${mode}`);
}
}
@@ -0,0 +1,7 @@
import { UnexpectedError } from 'n8n-workflow';
export class InvalidSourceTypeError extends UnexpectedError {
constructor(sourceType: string) {
super(`Custom file location with invalid source type: ${sourceType}`);
}
}
@@ -0,0 +1,7 @@
import { UnexpectedError } from 'n8n-workflow';
export class MissingSourceIdError extends UnexpectedError {
constructor(pathSegments: string[]) {
super(`Custom file location missing sourceId: ${pathSegments.join('/')}`);
}
}
@@ -0,0 +1,7 @@
import { UserError } from 'n8n-workflow';
export class UnrecognizedCredentialTypeError extends UserError {
constructor(credentialType: string) {
super(`Unrecognized credential type: ${credentialType}`);
}
}
@@ -0,0 +1,7 @@
import { UserError } from 'n8n-workflow';
export class UnrecognizedNodeTypeError extends UserError {
constructor(packageName: string, nodeType: string) {
super(`Unrecognized node type: ${packageName}.${nodeType}`);
}
}
@@ -0,0 +1,7 @@
import { WorkflowOperationError } from 'n8n-workflow';
export class WorkflowHasIssuesError extends WorkflowOperationError {
constructor() {
super('The workflow has issues and cannot be executed for that reason. Please fix them first.');
}
}