chore: Convert ErrorReporting to a Service to use DI. Add some tests (no-changelog) (#11279)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-12-11 15:36:17 +01:00 committed by GitHub
parent 5300e0ac45
commit 73145b70b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 443 additions and 386 deletions

View file

@ -1,60 +0,0 @@
import { GlobalConfig } from '@n8n/config';
import type { ClientOptions, ErrorEvent } from '@sentry/types';
import { strict as assert } from 'node:assert';
import { Container } from 'typedi';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
const init = jest.fn();
jest.mock('@sentry/node', () => ({
init,
setTag: jest.fn(),
captureException: jest.fn(),
Integrations: {},
}));
jest.spyOn(process, 'on');
describe('initErrorHandling', () => {
let beforeSend: ClientOptions['beforeSend'];
beforeAll(async () => {
Container.get(GlobalConfig).sentry.backendDsn = 'backend-dsn';
const errorReporting = require('@/error-reporting');
await errorReporting.initErrorHandling();
const options = (init.mock.calls[0] as [ClientOptions])[0];
beforeSend = options.beforeSend;
});
it('ignores errors with level warning', async () => {
const originalException = new InternalServerError('test');
originalException.level = 'warning';
const event = {} as ErrorEvent;
assert(beforeSend);
expect(await beforeSend(event, { originalException })).toEqual(null);
});
it('keeps events with a cause with error level', async () => {
const cause = new Error('cause-error');
const originalException = new InternalServerError('test', cause);
const event = {} as ErrorEvent;
assert(beforeSend);
expect(await beforeSend(event, { originalException })).toEqual(event);
});
it('ignores events with error cause with warning level', async () => {
const cause: Error & { level?: 'warning' } = new Error('cause-error');
cause.level = 'warning';
const originalException = new InternalServerError('test', cause);
const event = {} as ErrorEvent;
assert(beforeSend);
expect(await beforeSend(event, { originalException })).toEqual(null);
});
});

View file

@ -8,7 +8,7 @@ describe('LoadNodesAndCredentials', () => {
let instance: LoadNodesAndCredentials;
beforeEach(() => {
instance = new LoadNodesAndCredentials(mock(), mock(), mock());
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock());
instance.loaders.package1 = mock<DirectoryLoader>({
directory: '/icons/package1',
});

View file

@ -2,6 +2,7 @@
import {
ActiveWorkflows,
ErrorReporter,
InstanceSettings,
NodeExecuteFunctions,
PollContext,
@ -25,7 +26,6 @@ import type {
import {
Workflow,
WorkflowActivationError,
ErrorReporterProxy as ErrorReporter,
WebhookPathTakenError,
ApplicationError,
} from 'n8n-workflow';
@ -41,10 +41,12 @@ import {
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { OnShutdown } from '@/decorators/on-shutdown';
import { ExecutionService } from '@/executions/execution.service';
import { ExternalHooks } from '@/external-hooks';
import type { IWorkflowDb } from '@/interfaces';
import { Logger } from '@/logging/logger.service';
import { NodeTypes } from '@/node-types';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { ActiveWorkflowsService } from '@/services/active-workflows.service';
import { OrchestrationService } from '@/services/orchestration.service';
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
@ -53,9 +55,6 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
import { ExecutionService } from './executions/execution.service';
import { Publisher } from './scaling/pubsub/publisher.service';
interface QueuedActivation {
activationMode: WorkflowActivateMode;
lastTimeout: number;
@ -69,6 +68,7 @@ export class ActiveWorkflowManager {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly activeWorkflows: ActiveWorkflows,
private readonly activeExecutions: ActiveExecutions,
private readonly externalHooks: ExternalHooks,
@ -205,7 +205,7 @@ export class ActiveWorkflowManager {
try {
await this.clearWebhooks(workflow.id);
} catch (error1) {
ErrorReporter.error(error1);
this.errorReporter.error(error1);
this.logger.error(
`Could not remove webhooks of workflow "${workflow.id}" because of error: "${error1.message}"`,
);
@ -439,7 +439,7 @@ export class ActiveWorkflowManager {
this.logger.info(' => Started');
}
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
this.logger.info(
' => ERROR: Workflow could not be activated on first try, keep on trying if not an auth issue',
);
@ -635,7 +635,7 @@ export class ActiveWorkflowManager {
try {
await this.add(workflowId, activationMode, workflowData);
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
let lastTimeout = this.queuedActivations[workflowId].lastTimeout;
if (lastTimeout < WORKFLOW_REACTIVATE_MAX_TIMEOUT) {
lastTimeout = Math.min(lastTimeout * 2, WORKFLOW_REACTIVATE_MAX_TIMEOUT);
@ -707,7 +707,7 @@ export class ActiveWorkflowManager {
try {
await this.clearWebhooks(workflowId);
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
this.logger.error(
`Could not remove webhooks of workflow "${workflowId}" because of error: "${error.message}"`,
);
@ -724,7 +724,7 @@ export class ActiveWorkflowManager {
try {
await this.clearWebhooks(workflowId);
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
this.logger.error(
`Could not remove webhooks of workflow "${workflowId}" because of error: "${error.message}"`,
);

View file

@ -1,6 +1,7 @@
import type { PushPayload } from '@n8n/api-types';
import { ErrorReporter } from 'n8n-core';
import type { Workflow } from 'n8n-workflow';
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import { Service } from 'typedi';
import { CollaborationState } from '@/collaboration/collaboration.state';
@ -20,6 +21,7 @@ import { parseWorkflowMessage } from './collaboration.message';
@Service()
export class CollaborationService {
constructor(
private readonly errorReporter: ErrorReporter,
private readonly push: Push,
private readonly state: CollaborationState,
private readonly userRepository: UserRepository,
@ -31,7 +33,7 @@ export class CollaborationService {
try {
await this.handleUserMessage(event.userId, event.msg);
} catch (error) {
ErrorReporterProxy.error(
this.errorReporter.error(
new ApplicationError('Error handling CollaborationService push message', {
extra: {
msg: event.msg,

View file

@ -6,13 +6,9 @@ import {
InstanceSettings,
ObjectStoreService,
DataDeduplicationService,
ErrorReporter,
} from 'n8n-core';
import {
ApplicationError,
ensureError,
ErrorReporterProxy as ErrorReporter,
sleep,
} from 'n8n-workflow';
import { ApplicationError, ensureError, sleep } from 'n8n-workflow';
import { Container } from 'typedi';
import type { AbstractServer } from '@/abstract-server';
@ -22,7 +18,6 @@ import * as CrashJournal from '@/crash-journal';
import * as Db from '@/db';
import { getDataDeduplicationService } from '@/deduplication';
import { DeprecationService } from '@/deprecation/deprecation.service';
import { initErrorHandling } from '@/error-reporting';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
import { initExpressionEvaluator } from '@/expression-evaluator';
@ -39,6 +34,8 @@ import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-hi
export abstract class BaseCommand extends Command {
protected logger = Container.get(Logger);
protected readonly errorReporter = Container.get(ErrorReporter);
protected externalHooks?: ExternalHooks;
protected nodeTypes: NodeTypes;
@ -63,7 +60,7 @@ export abstract class BaseCommand extends Command {
protected needsCommunityPackages = false;
async init(): Promise<void> {
await initErrorHandling();
await this.errorReporter.init();
initExpressionEvaluator();
process.once('SIGTERM', this.onTerminationSignal('SIGTERM'));
@ -130,7 +127,7 @@ export abstract class BaseCommand extends Command {
}
protected async exitWithCrash(message: string, error: unknown) {
ErrorReporter.error(new Error(message, { cause: error }), { level: 'fatal' });
this.errorReporter.error(new Error(message, { cause: error }), { level: 'fatal' });
await sleep(2000);
process.exit(1);
}

View file

@ -4,7 +4,7 @@ import fs from 'fs';
import { diff } from 'json-diff';
import pick from 'lodash/pick';
import type { IRun, ITaskData, IWorkflowExecutionDataProcess } from 'n8n-workflow';
import { ApplicationError, jsonParse, ErrorReporterProxy } from 'n8n-workflow';
import { ApplicationError, jsonParse } from 'n8n-workflow';
import os from 'os';
import { sep } from 'path';
import { Container } from 'typedi';
@ -822,7 +822,7 @@ export class ExecuteBatch extends BaseCommand {
}
}
} catch (e) {
ErrorReporterProxy.error(e, {
this.errorReporter.error(e, {
extra: {
workflowId: workflowData.id,
},

View file

@ -21,12 +21,8 @@ import {
import { DateUtils } from '@n8n/typeorm/util/DateUtils';
import { parse, stringify } from 'flatted';
import pick from 'lodash/pick';
import { BinaryDataService } from 'n8n-core';
import {
ExecutionCancelledError,
ErrorReporterProxy as ErrorReporter,
ApplicationError,
} from 'n8n-workflow';
import { BinaryDataService, ErrorReporter } from 'n8n-core';
import { ExecutionCancelledError, ApplicationError } from 'n8n-workflow';
import type {
AnnotationVote,
ExecutionStatus,
@ -125,6 +121,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
dataSource: DataSource,
private readonly globalConfig: GlobalConfig,
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly executionDataRepository: ExecutionDataRepository,
private readonly binaryDataService: BinaryDataService,
) {
@ -209,7 +206,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
reportInvalidExecutions(executions: ExecutionEntity[]) {
if (executions.length === 0) return;
ErrorReporter.error(
this.errorReporter.error(
new ApplicationError('Found executions without executionData', {
extra: { executionIds: executions.map(({ id }) => id) },
}),

View file

@ -1,5 +1,5 @@
import { DataSource, Repository } from '@n8n/typeorm';
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import { Service } from 'typedi';
import config from '@/config';
@ -9,7 +9,10 @@ import { Settings } from '../entities/settings';
@Service()
export class SettingsRepository extends Repository<Settings> {
constructor(dataSource: DataSource) {
constructor(
dataSource: DataSource,
private readonly errorReporter: ErrorReporter,
) {
super(Settings, dataSource.manager);
}
@ -49,7 +52,7 @@ export class SettingsRepository extends Repository<Settings> {
config.set(key, value);
return { success: true };
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
}
return { success: false };
}

View file

@ -1,6 +1,7 @@
import type { EntitySubscriberInterface, UpdateEvent } from '@n8n/typeorm';
import { EventSubscriber } from '@n8n/typeorm';
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import { ApplicationError } from 'n8n-workflow';
import { Container } from 'typedi';
import { Logger } from '@/logging/logger.service';
@ -11,6 +12,8 @@ import { UserRepository } from '../repositories/user.repository';
@EventSubscriber()
export class UserSubscriber implements EntitySubscriberInterface<User> {
private readonly eventReporter = Container.get(ErrorReporter);
listenTo() {
return User;
}
@ -47,7 +50,7 @@ export class UserSubscriber implements EntitySubscriberInterface<User> {
const message = "Could not update the personal project's name";
Container.get(Logger).warn(message, event.entity);
const exception = new ApplicationError(message);
ErrorReporterProxy.warn(exception, event.entity);
this.eventReporter.warn(exception, event.entity);
return;
}
@ -69,7 +72,7 @@ export class UserSubscriber implements EntitySubscriberInterface<User> {
const message = "Could not update the personal project's name";
Container.get(Logger).warn(message, event.entity);
const exception = new ApplicationError(message);
ErrorReporterProxy.warn(exception, event.entity);
this.eventReporter.warn(exception, event.entity);
}
}
}

View file

@ -2,11 +2,8 @@
import type { EntityManager } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { DataSource as Connection } from '@n8n/typeorm';
import {
DbConnectionTimeoutError,
ensureError,
ErrorReporterProxy as ErrorReporter,
} from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import { DbConnectionTimeoutError, ensureError } from 'n8n-workflow';
import { Container } from 'typedi';
import { inTest } from '@/constants';
@ -38,7 +35,7 @@ if (!inTest) {
connectionState.connected = true;
return;
} catch (error) {
ErrorReporter.error(error);
Container.get(ErrorReporter).error(error);
} finally {
pingTimer = setTimeout(pingDBFn, 2000);
}

View file

@ -8,7 +8,7 @@ describe('OnShutdown', () => {
let shutdownService: ShutdownService;
beforeEach(() => {
shutdownService = new ShutdownService(mock());
shutdownService = new ShutdownService(mock(), mock());
Container.set(ShutdownService, shutdownService);
jest.spyOn(shutdownService, 'register');
});

View file

@ -1,13 +1,8 @@
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import glob from 'fast-glob';
import { Credentials, InstanceSettings } from 'n8n-core';
import {
ApplicationError,
jsonParse,
ErrorReporterProxy as ErrorReporter,
ensureError,
} from 'n8n-workflow';
import { Credentials, ErrorReporter, InstanceSettings } from 'n8n-core';
import { ApplicationError, jsonParse, ensureError } from 'n8n-workflow';
import { readFile as fsReadFile } from 'node:fs/promises';
import path from 'path';
import { Container, Service } from 'typedi';
@ -56,6 +51,7 @@ export class SourceControlImportService {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly variablesService: VariablesService,
private readonly activeWorkflowManager: ActiveWorkflowManager,
private readonly tagRepository: TagRepository,
@ -104,7 +100,7 @@ export class SourceControlImportService {
if (local.updatedAt instanceof Date) {
updatedAt = local.updatedAt;
} else {
ErrorReporter.warn('updatedAt is not a Date', {
this.errorReporter.warn('updatedAt is not a Date', {
extra: {
type: typeof local.updatedAt,
value: local.updatedAt,

View file

@ -1,120 +0,0 @@
import { GlobalConfig } from '@n8n/config';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { QueryFailedError } from '@n8n/typeorm';
import { AxiosError } from 'axios';
import { createHash } from 'crypto';
import { InstanceSettings } from 'n8n-core';
import { ErrorReporterProxy, ApplicationError } from 'n8n-workflow';
import Container from 'typedi';
let initialized = false;
export const initErrorHandling = async () => {
if (initialized) return;
process.on('uncaughtException', (error) => {
ErrorReporterProxy.error(error);
});
const dsn = Container.get(GlobalConfig).sentry.backendDsn;
if (!dsn) {
initialized = true;
return;
}
// Collect longer stacktraces
Error.stackTraceLimit = 50;
const {
N8N_VERSION: release,
ENVIRONMENT: environment,
DEPLOYMENT_NAME: serverName,
} = process.env;
const { init, captureException, setTag } = await import('@sentry/node');
const { requestDataIntegration, rewriteFramesIntegration } = await import('@sentry/node');
const enabledIntegrations = [
'InboundFilters',
'FunctionToString',
'LinkedErrors',
'OnUnhandledRejection',
'ContextLines',
];
const seenErrors = new Set<string>();
init({
dsn,
release,
environment,
enableTracing: false,
serverName,
beforeBreadcrumb: () => null,
integrations: (integrations) => [
...integrations.filter(({ name }) => enabledIntegrations.includes(name)),
rewriteFramesIntegration({ root: process.cwd() }),
requestDataIntegration({
include: {
cookies: false,
data: false,
headers: false,
query_string: false,
url: true,
user: false,
},
}),
],
async beforeSend(event, { originalException }) {
if (!originalException) return null;
if (originalException instanceof Promise) {
originalException = await originalException.catch((error) => error as Error);
}
if (originalException instanceof AxiosError) return null;
if (
originalException instanceof QueryFailedError &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))
) {
return null;
}
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 &&
'cause' in originalException &&
originalException.cause instanceof Error &&
'level' in originalException.cause &&
originalException.cause.level === 'warning'
) {
// 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 (seenErrors.has(eventHash)) return null;
seenErrors.add(eventHash);
}
return event;
},
});
setTag('server_type', Container.get(InstanceSettings).instanceType);
ErrorReporterProxy.init({
report: (error, options) => captureException(error, options),
});
initialized = true;
};

View file

@ -1,9 +1,5 @@
import {
ErrorReporterProxy,
type IRunExecutionData,
type ITaskData,
type IWorkflowBase,
} from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { saveExecutionProgress } from '@/execution-lifecycle-hooks/save-execution-progress';
@ -13,7 +9,7 @@ import { Logger } from '@/logging/logger.service';
import { mockInstance } from '@test/mocking';
mockInstance(Logger);
const errorReporter = mockInstance(ErrorReporter);
const executionRepository = mockInstance(ExecutionRepository);
afterEach(() => {
@ -63,8 +59,6 @@ test('should update execution when saving progress is enabled', async () => {
progress: true,
});
const reporterSpy = jest.spyOn(ErrorReporterProxy, 'error');
executionRepository.findSingleExecution.mockResolvedValue({} as IExecutionResponse);
await saveExecutionProgress(...commonArgs);
@ -83,7 +77,7 @@ test('should update execution when saving progress is enabled', async () => {
status: 'running',
});
expect(reporterSpy).not.toHaveBeenCalled();
expect(errorReporter.error).not.toHaveBeenCalled();
});
test('should report error on failure', async () => {
@ -92,8 +86,6 @@ test('should report error on failure', async () => {
progress: true,
});
const reporterSpy = jest.spyOn(ErrorReporterProxy, 'error');
const error = new Error('Something went wrong');
executionRepository.findSingleExecution.mockImplementation(() => {
@ -103,5 +95,5 @@ test('should report error on failure', async () => {
await saveExecutionProgress(...commonArgs);
expect(executionRepository.updateExistingExecution).not.toHaveBeenCalled();
expect(reporterSpy).toHaveBeenCalledWith(error);
expect(errorReporter.error).toHaveBeenCalledWith(error);
});

View file

@ -1,5 +1,5 @@
import { ErrorReporter } from 'n8n-core';
import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow';
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { Container } from 'typedi';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
@ -85,7 +85,7 @@ export async function saveExecutionProgress(
} catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`);
ErrorReporter.error(error);
Container.get(ErrorReporter).error(error);
// TODO: Improve in the future!
// Errors here might happen because of database access
// For busy machines, we may get "Database is locked" errors.

View file

@ -1,4 +1,6 @@
import { ErrorReporterProxy, ExpressionEvaluatorProxy } from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import { ExpressionEvaluatorProxy } from 'n8n-workflow';
import Container from 'typedi';
import config from '@/config';
@ -6,7 +8,7 @@ export const initExpressionEvaluator = () => {
ExpressionEvaluatorProxy.setEvaluator(config.getEnv('expression.evaluator'));
ExpressionEvaluatorProxy.setDifferEnabled(config.getEnv('expression.reportDifference'));
ExpressionEvaluatorProxy.setDiffReporter((expr) => {
ErrorReporterProxy.warn('Expression difference', {
Container.get(ErrorReporter).warn('Expression difference', {
extra: {
expression: expr,
},

View file

@ -4,6 +4,7 @@ import fsPromises from 'fs/promises';
import type { Class, DirectoryLoader, Types } from 'n8n-core';
import {
CUSTOM_EXTENSION_ENV,
ErrorReporter,
InstanceSettings,
CustomDirectoryLoader,
PackageDirectoryLoader,
@ -22,7 +23,7 @@ import type {
INodeType,
IVersionedNodeType,
} from 'n8n-workflow';
import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { NodeHelpers, ApplicationError } from 'n8n-workflow';
import path from 'path';
import picocolors from 'picocolors';
import { Container, Service } from 'typedi';
@ -63,6 +64,7 @@ export class LoadNodesAndCredentials {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly instanceSettings: InstanceSettings,
private readonly globalConfig: GlobalConfig,
) {}
@ -155,7 +157,7 @@ export class LoadNodesAndCredentials {
);
} catch (error) {
this.logger.error((error as Error).message);
ErrorReporter.error(error);
this.errorReporter.error(error);
}
}
}

View file

@ -1,4 +1,5 @@
import type { PushPayload, PushType } from '@n8n/api-types';
import { ErrorReporter } from 'n8n-core';
import { assert, jsonStringify } from 'n8n-workflow';
import { Service } from 'typedi';
@ -27,7 +28,10 @@ export abstract class AbstractPush<Connection> extends TypedEmitter<AbstractPush
protected abstract sendToOneConnection(connection: Connection, data: string): void;
protected abstract ping(connection: Connection): void;
constructor(protected readonly logger: Logger) {
constructor(
protected readonly logger: Logger,
protected readonly errorReporter: ErrorReporter,
) {
super();
// Ping all connected clients every 60 seconds
setInterval(() => this.pingAll(), 60 * 1000);

View file

@ -1,4 +1,4 @@
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import { Service } from 'typedi';
import type WebSocket from 'ws';
@ -24,7 +24,7 @@ export class WebSocketPush extends AbstractPush<WebSocket> {
this.onMessageReceived(pushRef, JSON.parse(buffer.toString('utf8')));
} catch (error) {
ErrorReporterProxy.error(
this.errorReporter.error(
new ApplicationError('Error parsing push message', {
extra: {
userId,

View file

@ -1,10 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { Request, Response } from 'express';
import {
ErrorReporterProxy as ErrorReporter,
FORM_TRIGGER_PATH_IDENTIFIER,
NodeApiError,
} from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import { FORM_TRIGGER_PATH_IDENTIFIER, NodeApiError } from 'n8n-workflow';
import { Readable } from 'node:stream';
import picocolors from 'picocolors';
import Container from 'typedi';
@ -141,7 +138,7 @@ export const isUniqueConstraintError = (error: Error) =>
export function reportError(error: Error) {
if (!(error instanceof ResponseError) || error.httpStatusCode > 404) {
ErrorReporter.error(error);
Container.get(ErrorReporter).error(error);
}
}

View file

@ -1,5 +1,6 @@
import { TaskRunnersConfig } from '@n8n/config';
import { ErrorReporterProxy, sleep } from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import { sleep } from 'n8n-workflow';
import * as a from 'node:assert/strict';
import Container, { Service } from 'typedi';
@ -33,6 +34,7 @@ export class TaskRunnerModule {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly runnerConfig: TaskRunnersConfig,
) {
this.logger = this.logger.scoped('task-runner');
@ -114,7 +116,7 @@ export class TaskRunnerModule {
private onRunnerRestartLoopDetected = async (error: TaskRunnerRestartLoopError) => {
this.logger.error(error.message);
ErrorReporterProxy.error(error);
this.errorReporter.error(error);
// Allow some time for the error to be flushed
await sleep(1000);

View file

@ -12,7 +12,14 @@ describe('JobProcessor', () => {
executionRepository.findSingleExecution.mockResolvedValue(
mock<IExecutionResponse>({ status: 'crashed' }),
);
const jobProcessor = new JobProcessor(mock(), executionRepository, mock(), mock(), mock());
const jobProcessor = new JobProcessor(
mock(),
mock(),
executionRepository,
mock(),
mock(),
mock(),
);
const result = await jobProcessor.processJob(mock<Job>());

View file

@ -77,6 +77,7 @@ describe('ScalingService', () => {
scalingService = new ScalingService(
mockLogger(),
mock(),
mock(),
jobProcessor,
globalConfig,
mock(),

View file

@ -1,12 +1,7 @@
import type { RunningJobSummary } from '@n8n/api-types';
import { InstanceSettings, WorkflowExecute } from 'n8n-core';
import { ErrorReporter, InstanceSettings, WorkflowExecute } from 'n8n-core';
import type { ExecutionStatus, IExecuteResponsePromiseData, IRun } from 'n8n-workflow';
import {
BINARY_ENCODING,
ApplicationError,
Workflow,
ErrorReporterProxy as ErrorReporter,
} from 'n8n-workflow';
import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow';
import type PCancelable from 'p-cancelable';
import { Service } from 'typedi';
@ -35,6 +30,7 @@ export class JobProcessor {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly executionRepository: ExecutionRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly nodeTypes: NodeTypes,
@ -155,7 +151,7 @@ export class JobProcessor {
workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data);
workflowRun = workflowExecute.processRunExecutionData(workflow);
} else {
ErrorReporter.info(`Worker found execution ${executionId} without data`);
this.errorReporter.info(`Worker found execution ${executionId} without data`);
// Execute all nodes
// Can execute without webhook so go on
workflowExecute = new WorkflowExecute(additionalData, execution.mode);

View file

@ -1,13 +1,6 @@
import { GlobalConfig } from '@n8n/config';
import { InstanceSettings } from 'n8n-core';
import {
ApplicationError,
BINARY_ENCODING,
sleep,
jsonStringify,
ErrorReporterProxy,
ensureError,
} from 'n8n-workflow';
import { ErrorReporter, InstanceSettings } from 'n8n-core';
import { ApplicationError, BINARY_ENCODING, sleep, jsonStringify, ensureError } from 'n8n-workflow';
import type { IExecuteResponsePromiseData } from 'n8n-workflow';
import { strict } from 'node:assert';
import Container, { Service } from 'typedi';
@ -43,6 +36,7 @@ export class ScalingService {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly activeExecutions: ActiveExecutions,
private readonly jobProcessor: JobProcessor,
private readonly globalConfig: GlobalConfig,
@ -119,7 +113,7 @@ export class ScalingService {
await job.progress(msg);
ErrorReporterProxy.error(error, { executionId });
this.errorReporter.error(error, { executionId });
throw error;
}

View file

@ -8,7 +8,13 @@ import { CredentialsTester } from '@/services/credentials-tester.service';
describe('CredentialsTester', () => {
const credentialTypes = mock<CredentialTypes>();
const nodeTypes = mock<NodeTypes>();
const credentialsTester = new CredentialsTester(mock(), credentialTypes, nodeTypes, mock());
const credentialsTester = new CredentialsTester(
mock(),
mock(),
credentialTypes,
nodeTypes,
mock(),
);
beforeEach(() => {
jest.clearAllMocks();

View file

@ -4,7 +4,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import get from 'lodash/get';
import { NodeExecuteFunctions } from 'n8n-core';
import { ErrorReporter, NodeExecuteFunctions } from 'n8n-core';
import type {
ICredentialsDecrypted,
ICredentialTestFunction,
@ -28,7 +28,6 @@ import {
NodeHelpers,
RoutingNode,
Workflow,
ErrorReporterProxy as ErrorReporter,
ApplicationError,
} from 'n8n-workflow';
import { Service } from 'typedi';
@ -75,6 +74,7 @@ const mockNodeTypes: INodeTypes = {
export class CredentialsTester {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly credentialTypes: CredentialTypes,
private readonly nodeTypes: NodeTypes,
private readonly credentialsHelper: CredentialsHelper,
@ -316,7 +316,7 @@ export class CredentialsTester {
credentialsDecrypted,
);
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
// Do not fail any requests to allow custom error messages and
// make logic easier
if (error.cause?.response) {

View file

@ -1,5 +1,6 @@
import { mock } from 'jest-mock-extended';
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
import type { ErrorReporter } from 'n8n-core';
import { ApplicationError } from 'n8n-workflow';
import Container from 'typedi';
import type { ServiceClass } from '@/shutdown/shutdown.service';
@ -13,14 +14,13 @@ describe('ShutdownService', () => {
let shutdownService: ShutdownService;
let mockComponent: MockComponent;
let onShutdownSpy: jest.SpyInstance;
let mockErrorReporterProxy: jest.SpyInstance;
const errorReporter = mock<ErrorReporter>();
beforeEach(() => {
shutdownService = new ShutdownService(mock());
shutdownService = new ShutdownService(mock(), errorReporter);
mockComponent = new MockComponent();
Container.set(MockComponent, mockComponent);
onShutdownSpy = jest.spyOn(mockComponent, 'onShutdown');
mockErrorReporterProxy = jest.spyOn(ErrorReporterProxy, 'error').mockImplementation(() => {});
});
describe('shutdown', () => {
@ -83,8 +83,8 @@ describe('ShutdownService', () => {
shutdownService.shutdown();
await shutdownService.waitForShutdown();
expect(mockErrorReporterProxy).toHaveBeenCalledTimes(1);
const error = mockErrorReporterProxy.mock.calls[0][0];
expect(errorReporter.error).toHaveBeenCalledTimes(1);
const error = errorReporter.error.mock.calls[0][0] as ApplicationError;
expect(error).toBeInstanceOf(ApplicationError);
expect(error.message).toBe('Failed to shutdown gracefully');
expect(error.extra).toEqual({

View file

@ -1,5 +1,5 @@
import type { Class } from 'n8n-core';
import { ApplicationError, ErrorReporterProxy, assert } from 'n8n-workflow';
import { type Class, ErrorReporter } from 'n8n-core';
import { ApplicationError, assert } from 'n8n-workflow';
import { Container, Service } from 'typedi';
import { LOWEST_SHUTDOWN_PRIORITY, HIGHEST_SHUTDOWN_PRIORITY } from '@/constants';
@ -31,7 +31,10 @@ export class ShutdownService {
private shutdownPromise: Promise<void> | undefined;
constructor(private readonly logger: Logger) {}
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
) {}
/** Registers given listener to be notified when the application is shutting down */
register(priority: number, handler: ShutdownHandler) {
@ -108,7 +111,7 @@ export class ShutdownService {
await method.call(service);
} catch (error) {
assert(error instanceof Error);
ErrorReporterProxy.error(new ComponentShutdownError(name, error));
this.errorReporter.error(new ComponentShutdownError(name, error));
}
}
}

View file

@ -1,6 +1,6 @@
import { GlobalConfig } from '@n8n/config';
import { pick } from 'lodash';
import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import path from 'node:path';
import type { Transporter } from 'nodemailer';
import { createTransport } from 'nodemailer';
@ -20,6 +20,7 @@ export class NodeMailer {
constructor(
globalConfig: GlobalConfig,
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
) {
const smtpConfig = globalConfig.userManagement.emails.smtp;
const transportConfig: SMTPConnection.Options = pick(smtpConfig, ['host', 'port', 'secure']);
@ -66,7 +67,7 @@ export class NodeMailer {
`Email sent successfully to the following recipients: ${mailData.emailRecipients.toString()}`,
);
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
this.logger.error('Failed to send email', {
recipients: mailData.emailRecipients,
error: error as Error,

View file

@ -9,7 +9,7 @@
import { GlobalConfig } from '@n8n/config';
import type express from 'express';
import get from 'lodash/get';
import { BinaryDataService, NodeExecuteFunctions } from 'n8n-core';
import { BinaryDataService, ErrorReporter, NodeExecuteFunctions } from 'n8n-core';
import type {
IBinaryData,
IBinaryKeyData,
@ -33,8 +33,6 @@ import {
ApplicationError,
BINARY_ENCODING,
createDeferredPromise,
ErrorReporterProxy as ErrorReporter,
ErrorReporterProxy,
ExecutionCancelledError,
FORM_NODE_TYPE,
NodeHelpers,
@ -280,7 +278,7 @@ export async function executeWebhook(
errorMessage = err.message;
}
ErrorReporterProxy.error(err, {
Container.get(ErrorReporter).error(err, {
extra: {
nodeName: workflowStartNode.name,
nodeType: workflowStartNode.type,
@ -521,7 +519,7 @@ export async function executeWebhook(
didSendResponse = true;
})
.catch(async (error) => {
ErrorReporter.error(error);
Container.get(ErrorReporter).error(error);
Container.get(Logger).error(
`Error with Webhook-Response for execution "${executionId}": "${error.message}"`,
{ executionId, workflowId: workflow.id },

View file

@ -1,19 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { PushType } from '@n8n/api-types';
import { GlobalConfig } from '@n8n/config';
import { stringify } from 'flatted';
import { WorkflowExecute } from 'n8n-core';
import {
ApplicationError,
ErrorReporterProxy as ErrorReporter,
NodeOperationError,
Workflow,
WorkflowHooks,
} from 'n8n-workflow';
import { ErrorReporter, WorkflowExecute } from 'n8n-core';
import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow';
import type {
IDataObject,
IExecuteData,
@ -215,7 +208,7 @@ export function executeErrorWorkflow(
);
})
.catch((error: Error) => {
ErrorReporter.error(error);
Container.get(ErrorReporter).error(error);
logger.error(
`Could not execute ErrorWorkflow for execution ID ${this.executionId} because of error querying the workflow owner`,
{
@ -423,7 +416,7 @@ function hookFunctionsSave(): IWorkflowExecuteHooks {
newStaticData,
);
} catch (e) {
ErrorReporter.error(e);
Container.get(ErrorReporter).error(e);
logger.error(
`There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (hookFunctionsSave)`,
{ executionId: this.executionId, workflowId: this.workflowData.id },
@ -502,7 +495,7 @@ function hookFunctionsSave(): IWorkflowExecuteHooks {
);
}
} catch (error) {
ErrorReporter.error(error);
Container.get(ErrorReporter).error(error);
logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, {
executionId: this.executionId,
workflowId: this.workflowData.id,
@ -584,7 +577,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
newStaticData,
);
} catch (e) {
ErrorReporter.error(e);
Container.get(ErrorReporter).error(e);
logger.error(
`There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (workflowExecuteAfter)`,
{ pushRef: this.pushRef, workflowId: this.workflowData.id },
@ -653,7 +646,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
this.executionId,
]);
} catch (error) {
ErrorReporter.error(error);
Container.get(ErrorReporter).error(error);
Container.get(Logger).error(
'There was a problem running hook "workflow.postExecute"',
error,

View file

@ -5,6 +5,7 @@
import * as a from 'assert/strict';
import {
DirectedGraph,
ErrorReporter,
InstanceSettings,
WorkflowExecute,
filterDisabledNodes,
@ -22,11 +23,7 @@ import type {
IRunExecutionData,
IWorkflowExecuteAdditionalData,
} from 'n8n-workflow';
import {
ErrorReporterProxy as ErrorReporter,
ExecutionCancelledError,
Workflow,
} from 'n8n-workflow';
import { ExecutionCancelledError, Workflow } from 'n8n-workflow';
import PCancelable from 'p-cancelable';
import { Container, Service } from 'typedi';
@ -55,6 +52,7 @@ export class WorkflowRunner {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly activeExecutions: ActiveExecutions,
private readonly executionRepository: ExecutionRepository,
private readonly externalHooks: ExternalHooks,
@ -82,7 +80,7 @@ export class WorkflowRunner {
return;
}
ErrorReporter.error(error, { executionId });
this.errorReporter.error(error, { executionId });
const isQueueMode = config.getEnv('executions.mode') === 'queue';
@ -193,14 +191,14 @@ export class WorkflowRunner {
executionId,
]);
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
this.logger.error('There was a problem running hook "workflow.postExecute"', error);
}
}
})
.catch((error) => {
if (error instanceof ExecutionCancelledError) return;
ErrorReporter.error(error);
this.errorReporter.error(error);
this.logger.error(
'There was a problem running internal hook "onWorkflowPostExecute"',
error,

View file

@ -57,6 +57,7 @@ describe('WorkflowExecutionService', () => {
mock(),
mock(),
mock(),
mock(),
workflowRunner,
mock(),
mock(),

View file

@ -1,4 +1,5 @@
import { GlobalConfig } from '@n8n/config';
import { ErrorReporter } from 'n8n-core';
import type {
IDeferredPromise,
IExecuteData,
@ -11,11 +12,7 @@ import type {
WorkflowExecuteMode,
IWorkflowExecutionDataProcess,
} from 'n8n-workflow';
import {
SubworkflowOperationError,
Workflow,
ErrorReporterProxy as ErrorReporter,
} from 'n8n-workflow';
import { SubworkflowOperationError, Workflow } from 'n8n-workflow';
import { Service } from 'typedi';
import type { Project } from '@/databases/entities/project';
@ -36,6 +33,7 @@ import type { WorkflowRequest } from '@/workflows/workflow.request';
export class WorkflowExecutionService {
constructor(
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly executionRepository: ExecutionRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly nodeTypes: NodeTypes,
@ -293,7 +291,7 @@ export class WorkflowExecutionService {
await this.workflowRunner.run(runData);
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
this.logger.error(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
`Calling Error Workflow for "${workflowErrorData.workflow.id}": "${error.message}"`,

View file

@ -1,5 +1,6 @@
import { GlobalConfig } from '@n8n/config';
import { type IDataObject, type Workflow, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import { ErrorReporter } from 'n8n-core';
import type { IDataObject, Workflow } from 'n8n-workflow';
import { Service } from 'typedi';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@ -11,6 +12,7 @@ export class WorkflowStaticDataService {
constructor(
private readonly globalConfig: GlobalConfig,
private readonly logger: Logger,
private readonly errorReporter: ErrorReporter,
private readonly workflowRepository: WorkflowRepository,
) {}
@ -33,7 +35,7 @@ export class WorkflowStaticDataService {
await this.saveStaticDataById(workflow.id, workflow.staticData);
workflow.staticData.__dataChanged = false;
} catch (error) {
ErrorReporter.error(error);
this.errorReporter.error(error);
this.logger.error(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
`There was a problem saving the workflow with id "${workflow.id}" to save changed Data: "${error.message}"`,

View file

@ -283,6 +283,7 @@ describe('shouldAddWebhooks', () => {
mock(),
mock(),
mock(),
mock(),
mock<InstanceSettings>({ isLeader: true, isFollower: false }),
mock(),
);
@ -322,6 +323,7 @@ describe('shouldAddWebhooks', () => {
mock(),
mock(),
mock(),
mock(),
mock<InstanceSettings>({ isLeader: false, isFollower: true }),
mock(),
);

View file

@ -30,6 +30,7 @@ describe('SourceControlImportService', () => {
mock(),
mock(),
mock(),
mock(),
mock<InstanceSettings>({ n8nFolder: '/some-path' }),
);

View file

@ -33,7 +33,7 @@ describe('TaskRunnerModule in external mode', () => {
runnerConfig.enabled = true;
runnerConfig.authToken = '';
const module = new TaskRunnerModule(mock(), runnerConfig);
const module = new TaskRunnerModule(mock(), mock(), runnerConfig);
await expect(module.start()).rejects.toThrowError(MissingAuthTokenError);
});

View file

@ -39,6 +39,7 @@
"@langchain/core": "catalog:",
"@n8n/client-oauth2": "workspace:*",
"@n8n/config": "workspace:*",
"@sentry/node": "catalog:",
"aws4": "1.11.0",
"axios": "catalog:",
"concat-stream": "2.0.0",

View file

@ -11,7 +11,6 @@ import type {
} from 'n8n-workflow';
import {
ApplicationError,
ErrorReporterProxy as ErrorReporter,
LoggerProxy as Logger,
toCronExpression,
TriggerCloseError,
@ -20,12 +19,16 @@ import {
} from 'n8n-workflow';
import { Service } from 'typedi';
import { ErrorReporter } from './error-reporter';
import type { IWorkflowData } from './Interfaces';
import { ScheduledTaskManager } from './ScheduledTaskManager';
@Service()
export class ActiveWorkflows {
constructor(private readonly scheduledTaskManager: ScheduledTaskManager) {}
constructor(
private readonly scheduledTaskManager: ScheduledTaskManager,
private readonly errorReporter: ErrorReporter,
) {}
private activeWorkflows: { [workflowId: string]: IWorkflowData } = {};
@ -218,7 +221,7 @@ export class ActiveWorkflows {
Logger.error(
`There was a problem calling "closeFunction" on "${e.node.name}" in workflow "${workflowId}"`,
);
ErrorReporter.error(e, { extra: { workflowId } });
this.errorReporter.error(e, { extra: { workflowId } });
return;
}

View file

@ -46,11 +46,12 @@ import {
ApplicationError,
NodeExecutionOutput,
sleep,
ErrorReporterProxy,
ExecutionCancelledError,
} from 'n8n-workflow';
import PCancelable from 'p-cancelable';
import Container from 'typedi';
import { ErrorReporter } from './error-reporter';
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
import {
DirectedGraph,
@ -1428,7 +1429,7 @@ export class WorkflowExecute {
toReport = error;
}
if (toReport) {
ErrorReporterProxy.error(toReport, {
Container.get(ErrorReporter).error(toReport, {
extra: {
nodeName: executionNode.name,
nodeType: executionNode.type,

View file

@ -0,0 +1,171 @@
import { GlobalConfig } from '@n8n/config';
import type { NodeOptions } from '@sentry/node';
import type { ErrorEvent, EventHint } from '@sentry/types';
import { AxiosError } from 'axios';
import { ApplicationError, LoggerProxy, type ReportingOptions } from 'n8n-workflow';
import { createHash } from 'node:crypto';
import { Service } from 'typedi';
import { InstanceSettings } from './InstanceSettings';
@Service()
export class ErrorReporter {
private initialized = false;
/** Hashes of error stack traces, to deduplicate error reports. */
private seenErrors = new Set<string>();
private report: (error: Error | string, options?: ReportingOptions) => void;
constructor(
private readonly globalConfig: GlobalConfig,
private readonly instanceSettings: InstanceSettings,
) {
// 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 {
const msg = [e.message + context, e.stack ? `\n${e.stack}\n` : ''].join('');
const meta = e instanceof ApplicationError ? e.extra : undefined;
LoggerProxy.error(msg, meta);
e = e.cause as Error;
} while (e);
}
}
async init() {
if (this.initialized) return;
process.on('uncaughtException', (error) => {
this.error(error);
});
const dsn = this.globalConfig.sentry.backendDsn;
if (!dsn) {
this.initialized = true;
return;
}
// Collect longer stacktraces
Error.stackTraceLimit = 50;
const {
N8N_VERSION: release,
ENVIRONMENT: environment,
DEPLOYMENT_NAME: serverName,
} = process.env;
const { init, captureException, setTag } = await import('@sentry/node');
const { requestDataIntegration, rewriteFramesIntegration } = await import('@sentry/node');
const enabledIntegrations = [
'InboundFilters',
'FunctionToString',
'LinkedErrors',
'OnUnhandledRejection',
'ContextLines',
];
init({
dsn,
release,
environment,
enableTracing: false,
serverName,
beforeBreadcrumb: () => null,
beforeSend: this.beforeSend.bind(this) as NodeOptions['beforeSend'],
integrations: (integrations) => [
...integrations.filter(({ name }) => enabledIntegrations.includes(name)),
rewriteFramesIntegration({ root: process.cwd() }),
requestDataIntegration({
include: {
cookies: false,
data: false,
headers: false,
query_string: false,
url: true,
user: false,
},
}),
],
});
setTag('server_type', this.instanceSettings.instanceType);
this.report = (error, options) => captureException(error, options);
this.initialized = true;
}
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 AxiosError) return null;
if (
originalException instanceof Error &&
originalException.name === 'QueryFailedError' &&
['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg))
) {
return null;
}
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 &&
'cause' in originalException &&
originalException.cause instanceof Error &&
'level' in originalException.cause &&
originalException.cause.level === 'warning'
) {
// 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) {
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;
}
}

View file

@ -21,3 +21,4 @@ export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils';
export * from './ExecutionMetadata';
export * from './node-execution-context';
export * from './PartialExecutionUtils';
export { ErrorReporter } from './error-reporter';

View file

@ -0,0 +1,110 @@
import type { GlobalConfig } from '@n8n/config';
import { QueryFailedError } from '@n8n/typeorm';
import type { ErrorEvent } from '@sentry/types';
import { AxiosError } from 'axios';
import { mock } from 'jest-mock-extended';
import { ApplicationError } from 'n8n-workflow';
import { ErrorReporter } from '@/error-reporter';
import type { InstanceSettings } from '@/InstanceSettings';
const init = jest.fn();
jest.mock('@sentry/node', () => ({
init,
setTag: jest.fn(),
captureException: jest.fn(),
Integrations: {},
}));
jest.spyOn(process, 'on');
describe('ErrorReporter', () => {
const globalConfig = mock<GlobalConfig>();
const instanceSettings = mock<InstanceSettings>();
const errorReporting = new ErrorReporter(globalConfig, instanceSettings);
const event = {} as ErrorEvent;
describe('beforeSend', () => {
it('should ignore errors with level warning', async () => {
const originalException = new ApplicationError('test');
originalException.level = 'warning';
expect(await errorReporting.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 errorReporting.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 errorReporting.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 errorReporting.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 errorReporting.beforeSend(event, { originalException });
expect(firstResult).toEqual(event);
const secondResult = await errorReporting.beforeSend(event, { originalException });
expect(secondResult).toBeNull();
});
it('should handle Promise rejections', async () => {
const originalException = Promise.reject(new Error());
const result = await errorReporting.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 errorReporting.beforeSend(event, { originalException });
expect(result).toBeNull();
});
});
});

View file

@ -1,47 +0,0 @@
import { ApplicationError, type ReportingOptions } from './errors/application.error';
import * as Logger from './LoggerProxy';
interface ErrorReporter {
report: (error: Error | string, options?: ReportingOptions) => void;
}
const instance: ErrorReporter = {
report: (error, options) => {
if (error instanceof Error) {
let e = error;
const { executionId } = options ?? {};
const context = executionId ? ` (execution ${executionId})` : '';
do {
const msg = [e.message + context, e.stack ? `\n${e.stack}\n` : ''].join('');
const meta = e instanceof ApplicationError ? e.extra : undefined;
Logger.error(msg, meta);
e = e.cause as Error;
} while (e);
}
},
};
export function init(errorReporter: ErrorReporter) {
instance.report = errorReporter.report;
}
const wrap = (e: unknown) => {
if (e instanceof Error) return e;
if (typeof e === 'string') return new ApplicationError(e);
return;
};
export const error = (e: unknown, options?: ReportingOptions) => {
const toReport = wrap(e);
if (toReport) instance.report(toReport, options);
};
export const info = (msg: string, options?: ReportingOptions) => {
Logger.info(msg);
instance.report(msg, { ...options, level: 'info' });
};
export const warn = (warning: Error | string, options?: ReportingOptions) =>
error(warning, { ...options, level: 'warning' });

View file

@ -1,4 +1,4 @@
export { ApplicationError } from './application.error';
export { ApplicationError, ReportingOptions } from './application.error';
export { ExpressionError } from './expression.error';
export { CredentialAccessError } from './credential-access-error';
export { ExecutionCancelledError } from './execution-cancelled.error';

View file

@ -1,5 +1,4 @@
import * as LoggerProxy from './LoggerProxy';
export * as ErrorReporterProxy from './ErrorReporterProxy';
export * as ExpressionEvaluatorProxy from './ExpressionEvaluatorProxy';
import * as NodeHelpers from './NodeHelpers';
import * as ObservableObject from './ObservableObject';

View file

@ -1121,6 +1121,9 @@ importers:
'@n8n/config':
specifier: workspace:*
version: link:../@n8n/config
'@sentry/node':
specifier: 'catalog:'
version: 8.42.0
aws4:
specifier: 1.11.0
version: 1.11.0