diff --git a/package-lock.json b/package-lock.json index bc16ca2381dcf..641fe040c393e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "n8n", - "version": "0.184.0", + "version": "0.185.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "n8n", - "version": "0.184.0", + "version": "0.185.0", "dependencies": { "@apidevtools/swagger-cli": "4.0.0", "@babel/core": "^7.14.6", @@ -75,6 +75,7 @@ "@types/parseurl": "^1.3.1", "@types/passport-jwt": "^3.0.6", "@types/promise-ftp": "^1.3.4", + "@types/psl": "^1.1.0", "@types/quill": "^2.0.1", "@types/redis": "^2.8.11", "@types/request-promise-native": "~1.0.15", @@ -222,6 +223,7 @@ "prismjs": "^1.17.1", "prom-client": "^13.1.0", "promise-ftp": "^1.3.5", + "psl": "^1.8.0", "qs": "^6.10.1", "quill": "^2.0.0-dev.3", "quill-autoformat": "^0.1.1", @@ -15310,6 +15312,11 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, + "node_modules/@types/psl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/psl/-/psl-1.1.0.tgz", + "integrity": "sha512-HhZnoLAvI2koev3czVPzBNRYvdrzJGLjQbWZhqFmS9Q6a0yumc5qtfSahBGb5g+6qWvA8iiQktqGkwoIXa/BNQ==" + }, "node_modules/@types/q": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.5.tgz", @@ -73705,6 +73712,11 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, + "@types/psl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/psl/-/psl-1.1.0.tgz", + "integrity": "sha512-HhZnoLAvI2koev3czVPzBNRYvdrzJGLjQbWZhqFmS9Q6a0yumc5qtfSahBGb5g+6qWvA8iiQktqGkwoIXa/BNQ==" + }, "@types/q": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.5.tgz", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5be0c6a4252c7..14dfaa6547307 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -75,6 +75,7 @@ "@types/open": "^6.1.0", "@types/parseurl": "^1.3.1", "@types/passport-jwt": "^3.0.6", + "@types/psl": "^1.1.0", "@types/request-promise-native": "~1.0.15", "@types/superagent": "4.1.13", "@types/supertest": "^2.0.11", @@ -145,6 +146,7 @@ "passport-jwt": "^4.0.0", "pg": "^8.3.0", "prom-client": "^13.1.0", + "psl": "^1.8.0", "request-promise-native": "^1.0.7", "shelljs": "^0.8.5", "sqlite3": "^5.0.2", diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index d41381072504c..be09ec0353107 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -13,6 +13,7 @@ import { IRunExecutionData, ITaskData, ITelemetrySettings, + ITelemetryTrackProperties, IWorkflowBase as IWorkflowBaseWorkflow, Workflow, WorkflowExecuteMode, @@ -667,3 +668,14 @@ export interface IWorkflowExecuteProcess { } export type WhereClause = Record; + +// ---------------------------------- +// telemetry +// ---------------------------------- + +export interface IExecutionTrackProperties extends ITelemetryTrackProperties { + workflow_id: string; + success: boolean; + error_node_type?: string; + is_manual: boolean; +} diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 12d6d009a9dbf..e597bc7faa7da 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1,6 +1,13 @@ /* eslint-disable import/no-cycle */ +import { get as pslGet } from 'psl'; import { BinaryDataManager } from 'n8n-core'; -import { IDataObject, INodeTypes, IRun, TelemetryHelpers } from 'n8n-workflow'; +import { + INodesGraphResult, + INodeTypes, + IRun, + ITelemetryTrackProperties, + TelemetryHelpers, +} from 'n8n-workflow'; import { snakeCase } from 'change-case'; import { IDiagnosticInfo, @@ -10,6 +17,7 @@ import { IWorkflowDb, } from '.'; import { Telemetry } from './telemetry'; +import { IExecutionTrackProperties } from './Interfaces'; export class InternalHooksClass implements IInternalHooksClass { private versionCli: string; @@ -48,6 +56,10 @@ export class InternalHooksClass implements IInternalHooksClass { ]); } + async onFrontendSettingsAPI(sessionId?: string): Promise { + return this.telemetry.track('Session started', { session_id: sessionId }); + } + async onPersonalizationSurveySubmitted( userId: string, answers: Record, @@ -73,7 +85,6 @@ export class InternalHooksClass implements IInternalHooksClass { return this.telemetry.track('User created workflow', { user_id: userId, workflow_id: workflow.id, - node_graph: nodeGraph, node_graph_string: JSON.stringify(nodeGraph), public_api: publicApi, }); @@ -98,7 +109,6 @@ export class InternalHooksClass implements IInternalHooksClass { return this.telemetry.track('User saved workflow', { user_id: userId, workflow_id: workflow.id, - node_graph: nodeGraph, node_graph_string: JSON.stringify(nodeGraph), notes_count_overlapping: overlappingCount, notes_count_non_overlapping: notesCount - overlappingCount, @@ -115,10 +125,16 @@ export class InternalHooksClass implements IInternalHooksClass { userId?: string, ): Promise { const promises = [Promise.resolve()]; - const properties: IDataObject = { - workflow_id: workflow.id, + + if (!workflow.id) { + return Promise.resolve(); + } + + const properties: IExecutionTrackProperties = { + workflow_id: workflow.id.toString(), is_manual: false, version_cli: this.versionCli, + success: false, }; if (userId) { @@ -130,7 +146,7 @@ export class InternalHooksClass implements IInternalHooksClass { properties.success = !!runData.finished; properties.is_manual = runData.mode === 'manual'; - let nodeGraphResult; + let nodeGraphResult: INodesGraphResult | null = null; if (!properties.success && runData?.data.resultData.error) { properties.error_message = runData?.data.resultData.error.message; @@ -165,22 +181,19 @@ export class InternalHooksClass implements IInternalHooksClass { nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); } - const manualExecEventProperties = { - workflow_id: workflow.id, + const manualExecEventProperties: ITelemetryTrackProperties = { + workflow_id: workflow.id.toString(), status: properties.success ? 'success' : 'failed', - error_message: properties.error_message, + error_message: properties.error_message as string, error_node_type: properties.error_node_type, - node_graph: properties.node_graph, - node_graph_string: properties.node_graph_string, - error_node_id: properties.error_node_id, + node_graph_string: properties.node_graph_string as string, + error_node_id: properties.error_node_id as string, + webhook_domain: null, }; - if (!manualExecEventProperties.node_graph) { + if (!manualExecEventProperties.node_graph_string) { nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - manualExecEventProperties.node_graph = nodeGraphResult.nodeGraph; - manualExecEventProperties.node_graph_string = JSON.stringify( - manualExecEventProperties.node_graph, - ); + manualExecEventProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); } if (runData.data.startData?.destinationNode) { @@ -195,6 +208,16 @@ export class InternalHooksClass implements IInternalHooksClass { }), ); } else { + nodeGraphResult.webhookNodeNames.forEach((name: string) => { + const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] + ?.json as { headers?: { origin?: string } }; + if (execJson?.headers?.origin && execJson.headers.origin !== '') { + manualExecEventProperties.webhook_domain = pslGet( + execJson.headers.origin.replace(/^https?:\/\//, ''), + ); + } + }); + promises.push( this.telemetry.track('Manual workflow exec finished', manualExecEventProperties), ); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 822e9f93c2346..435992cb455ab 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -2856,6 +2856,10 @@ class App { `/${this.restEndpoint}/settings`, ResponseHelper.send( async (req: express.Request, res: express.Response): Promise => { + void InternalHooksManager.getInstance().onFrontendSettingsAPI( + req.headers.sessionid as string, + ); + return this.getSettingsForFrontend(); }, ), diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index fe648e69721e2..31515e25a2b1f 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -2,37 +2,25 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import TelemetryClient from '@rudderstack/rudder-sdk-node'; -import { IDataObject, LoggerProxy } from 'n8n-workflow'; +import { ITelemetryTrackProperties, LoggerProxy } from 'n8n-workflow'; import * as config from '../../config'; +import { IExecutionTrackProperties } from '../Interfaces'; import { getLogger } from '../Logger'; -type CountBufferItemKey = - | 'manual_success_count' - | 'manual_error_count' - | 'prod_success_count' - | 'prod_error_count'; +type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; -type FirstExecutionItemKey = - | 'first_manual_success' - | 'first_manual_error' - | 'first_prod_success' - | 'first_prod_error'; - -type IExecutionCountsBufferItem = { - [key in CountBufferItemKey]: number; -}; - -interface IExecutionCountsBuffer { - [workflowId: string]: IExecutionCountsBufferItem; +interface IExecutionTrackData { + count: number; + first: Date; } -type IFirstExecutions = { - [key in FirstExecutionItemKey]: Date | undefined; -}; - interface IExecutionsBuffer { - counts: IExecutionCountsBuffer; - firstExecutions: IFirstExecutions; + [workflowId: string]: { + manual_error?: IExecutionTrackData; + manual_success?: IExecutionTrackData; + prod_error?: IExecutionTrackData; + prod_success?: IExecutionTrackData; + }; } export class Telemetry { @@ -44,15 +32,7 @@ export class Telemetry { private pulseIntervalReference: NodeJS.Timeout; - private executionCountsBuffer: IExecutionsBuffer = { - counts: {}, - firstExecutions: { - first_manual_error: undefined, - first_manual_success: undefined, - first_prod_error: undefined, - first_prod_success: undefined, - }, - }; + private executionCountsBuffer: IExecutionsBuffer = {}; constructor(instanceId: string, versionCli: string) { this.instanceId = instanceId; @@ -71,85 +51,70 @@ export class Telemetry { return; } - this.client = new TelemetryClient(key, url, { logLevel }); + this.client = this.createTelemetryClient(key, url, logLevel); - this.pulseIntervalReference = setInterval(async () => { - void this.pulse(); - }, 6 * 60 * 60 * 1000); // every 6 hours + this.startPulse(); } } + private createTelemetryClient( + key: string, + url: string, + logLevel: string, + ): TelemetryClient | undefined { + return new TelemetryClient(key, url, { logLevel }); + } + + private startPulse() { + this.pulseIntervalReference = setInterval(async () => { + void this.pulse(); + }, 6 * 60 * 60 * 1000); // every 6 hours + } + private async pulse(): Promise { if (!this.client) { return Promise.resolve(); } - const allPromises = Object.keys(this.executionCountsBuffer.counts).map(async (workflowId) => { + const allPromises = Object.keys(this.executionCountsBuffer).map(async (workflowId) => { const promise = this.track('Workflow execution count', { - version_cli: this.versionCli, + event_version: '2', workflow_id: workflowId, - ...this.executionCountsBuffer.counts[workflowId], - ...this.executionCountsBuffer.firstExecutions, + ...this.executionCountsBuffer[workflowId], }); - this.executionCountsBuffer.counts[workflowId].manual_error_count = 0; - this.executionCountsBuffer.counts[workflowId].manual_success_count = 0; - this.executionCountsBuffer.counts[workflowId].prod_error_count = 0; - this.executionCountsBuffer.counts[workflowId].prod_success_count = 0; - return promise; }); - allPromises.push(this.track('pulse', { version_cli: this.versionCli })); + this.executionCountsBuffer = {}; + allPromises.push(this.track('pulse')); return Promise.all(allPromises); } - async trackWorkflowExecution(properties: IDataObject): Promise { + async trackWorkflowExecution(properties: IExecutionTrackProperties): Promise { if (this.client) { - const workflowId = properties.workflow_id as string; - this.executionCountsBuffer.counts[workflowId] = this.executionCountsBuffer.counts[ - workflowId - ] ?? { - manual_error_count: 0, - manual_success_count: 0, - prod_error_count: 0, - prod_success_count: 0, - }; - - let countKey: CountBufferItemKey; - let firstExecKey: FirstExecutionItemKey; - - if ( - properties.success === false && - properties.error_node_type && - (properties.error_node_type as string).startsWith('n8n-nodes-base') - ) { - // errored exec - void this.track('Workflow execution errored', properties); + const execTime = new Date(); + const workflowId = properties.workflow_id; + + this.executionCountsBuffer[workflowId] = this.executionCountsBuffer[workflowId] ?? {}; + + const key: ExecutionTrackDataKey = `${properties.is_manual ? 'manual' : 'prod'}_${ + properties.success ? 'success' : 'error' + }`; - if (properties.is_manual) { - firstExecKey = 'first_manual_error'; - countKey = 'manual_error_count'; - } else { - firstExecKey = 'first_prod_error'; - countKey = 'prod_error_count'; - } - } else if (properties.is_manual) { - countKey = 'manual_success_count'; - firstExecKey = 'first_manual_success'; + if (!this.executionCountsBuffer[workflowId][key]) { + this.executionCountsBuffer[workflowId][key] = { + count: 1, + first: execTime, + }; } else { - countKey = 'prod_success_count'; - firstExecKey = 'first_prod_success'; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.executionCountsBuffer[workflowId][key]!.count++; } - if ( - !this.executionCountsBuffer.firstExecutions[firstExecKey] && - this.executionCountsBuffer.counts[workflowId][countKey] === 0 - ) { - this.executionCountsBuffer.firstExecutions[firstExecKey] = new Date(); + if (!properties.success && properties.error_node_type?.startsWith('n8n-nodes-base')) { + void this.track('Workflow execution errored', properties); } - - this.executionCountsBuffer.counts[workflowId][countKey]++; } } @@ -165,7 +130,9 @@ export class Telemetry { }); } - async identify(traits?: IDataObject): Promise { + async identify(traits?: { + [key: string]: string | number | boolean | object | undefined | null; + }): Promise { return new Promise((resolve) => { if (this.client) { this.client.identify( @@ -185,20 +152,22 @@ export class Telemetry { }); } - async track( - eventName: string, - properties: { [key: string]: unknown; user_id?: string } = {}, - ): Promise { + async track(eventName: string, properties: ITelemetryTrackProperties = {}): Promise { return new Promise((resolve) => { if (this.client) { const { user_id } = properties; - Object.assign(properties, { instance_id: this.instanceId }); + const updatedProperties: ITelemetryTrackProperties = { + ...properties, + instance_id: this.instanceId, + version_cli: this.versionCli, + }; + this.client.track( { userId: `${this.instanceId}${user_id ? `#${user_id}` : ''}`, anonymousId: '000000000000', event: eventName, - properties, + properties: updatedProperties, }, resolve, ); @@ -207,4 +176,10 @@ export class Telemetry { } }); } + + // test helpers + + getCountsBuffer(): IExecutionsBuffer { + return this.executionCountsBuffer; + } } diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/test/unit/Telemetry.test.ts new file mode 100644 index 0000000000000..199aef92cb42f --- /dev/null +++ b/packages/cli/test/unit/Telemetry.test.ts @@ -0,0 +1,381 @@ +import { Telemetry } from '../../src/telemetry'; + +jest.spyOn(Telemetry.prototype as any, 'createTelemetryClient').mockImplementation(() => { + return { + flush: () => {}, + identify: () => {}, + track: () => {}, + }; +}); + +describe('Telemetry', () => { + let startPulseSpy: jest.SpyInstance; + const spyTrack = jest.spyOn(Telemetry.prototype, 'track'); + + let telemetry: Telemetry; + const n8nVersion = '0.0.0'; + const instanceId = 'Telemetry unit test'; + const testDateTime = new Date('2022-01-01 00:00:00'); + + beforeAll(() => { + startPulseSpy = jest.spyOn(Telemetry.prototype as any, 'startPulse').mockImplementation(() => {}); + jest.useFakeTimers(); + jest.setSystemTime(testDateTime); + }); + + afterAll(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + startPulseSpy.mockRestore(); + telemetry.trackN8nStop(); + }); + + beforeEach(() => { + spyTrack.mockClear(); + telemetry = new Telemetry(instanceId, n8nVersion); + }); + + afterEach(() => { + telemetry.trackN8nStop(); + }); + + describe('trackN8nStop', () => { + test('should call track method', () => { + telemetry.trackN8nStop(); + expect(spyTrack).toHaveBeenCalledTimes(1); + }); + }); + + describe('trackWorkflowExecution', () => { + beforeEach(() => { + jest.setSystemTime(testDateTime); + }); + + test('should count executions correctly', async () => { + const payload = { + workflow_id: '1', + is_manual: true, + success: true, + error_node_type: 'custom-nodes-base.node-type' + }; + + payload.is_manual = true; + payload.success = true; + const execTime1 = fakeJestSystemTime('2022-01-01 12:00:00'); + await telemetry.trackWorkflowExecution(payload); + fakeJestSystemTime('2022-01-01 12:30:00'); + await telemetry.trackWorkflowExecution(payload); + + payload.is_manual = false; + payload.success = true; + const execTime2 = fakeJestSystemTime('2022-01-01 13:00:00'); + await telemetry.trackWorkflowExecution(payload); + fakeJestSystemTime('2022-01-01 12:30:00'); + await telemetry.trackWorkflowExecution(payload); + + payload.is_manual = true; + payload.success = false; + const execTime3 = fakeJestSystemTime('2022-01-01 14:00:00'); + await telemetry.trackWorkflowExecution(payload); + fakeJestSystemTime('2022-01-01 12:30:00'); + await telemetry.trackWorkflowExecution(payload); + + payload.is_manual = false; + payload.success = false; + const execTime4 = fakeJestSystemTime('2022-01-01 15:00:00'); + await telemetry.trackWorkflowExecution(payload); + fakeJestSystemTime('2022-01-01 12:30:00'); + await telemetry.trackWorkflowExecution(payload); + + expect(spyTrack).toHaveBeenCalledTimes(0); + + const execBuffer = telemetry.getCountsBuffer(); + + expect(execBuffer['1'].manual_success?.count).toBe(2); + expect(execBuffer['1'].manual_success?.first).toEqual(execTime1); + expect(execBuffer['1'].prod_success?.count).toBe(2); + expect(execBuffer['1'].prod_success?.first).toEqual(execTime2); + expect(execBuffer['1'].manual_error?.count).toBe(2); + expect(execBuffer['1'].manual_error?.first).toEqual(execTime3); + expect(execBuffer['1'].prod_error?.count).toBe(2); + expect(execBuffer['1'].prod_error?.first).toEqual(execTime4); + }); + + test('should fire "Workflow execution errored" event for failed executions', async () => { + const payload = { + workflow_id: '1', + is_manual: true, + success: false, + error_node_type: 'custom-nodes-base.node-type' + }; + + const execTime1 = fakeJestSystemTime('2022-01-01 12:00:00'); + await telemetry.trackWorkflowExecution(payload); + fakeJestSystemTime('2022-01-01 12:30:00'); + await telemetry.trackWorkflowExecution(payload); + + let execBuffer = telemetry.getCountsBuffer(); + + // should not fire event for custom nodes + expect(spyTrack).toHaveBeenCalledTimes(0); + expect(execBuffer['1'].manual_error?.count).toBe(2); + expect(execBuffer['1'].manual_error?.first).toEqual(execTime1); + + payload.error_node_type = 'n8n-nodes-base.node-type'; + fakeJestSystemTime('2022-01-01 13:00:00'); + await telemetry.trackWorkflowExecution(payload); + fakeJestSystemTime('2022-01-01 12:30:00'); + await telemetry.trackWorkflowExecution(payload); + + execBuffer = telemetry.getCountsBuffer(); + + // should fire event for custom nodes + expect(spyTrack).toHaveBeenCalledTimes(2); + expect(spyTrack).toHaveBeenCalledWith('Workflow execution errored', payload); + expect(execBuffer['1'].manual_error?.count).toBe(4); + expect(execBuffer['1'].manual_error?.first).toEqual(execTime1); + }); + + test('should track production executions count correctly', async () => { + const payload = { + workflow_id: '1', + is_manual: false, + success: true, + error_node_type: 'node_type' + }; + + // successful execution + const execTime1 = fakeJestSystemTime('2022-01-01 12:00:00'); + await telemetry.trackWorkflowExecution(payload); + + expect(spyTrack).toHaveBeenCalledTimes(0); + + let execBuffer = telemetry.getCountsBuffer(); + expect(execBuffer['1'].manual_error).toBeUndefined(); + expect(execBuffer['1'].manual_success).toBeUndefined(); + expect(execBuffer['1'].prod_error).toBeUndefined(); + + expect(execBuffer['1'].prod_success?.count).toBe(1); + expect(execBuffer['1'].prod_success?.first).toEqual(execTime1); + + // successful execution n8n node + payload.error_node_type = 'n8n-nodes-base.merge'; + payload.workflow_id = '2'; + + await telemetry.trackWorkflowExecution(payload); + + expect(spyTrack).toHaveBeenCalledTimes(0); + + execBuffer = telemetry.getCountsBuffer(); + expect(execBuffer['1'].manual_error).toBeUndefined(); + expect(execBuffer['1'].manual_success).toBeUndefined(); + expect(execBuffer['1'].prod_error).toBeUndefined(); + + expect(execBuffer['1'].prod_success?.count).toBe(1); + expect(execBuffer['2'].prod_success?.count).toBe(1); + + expect(execBuffer['1'].prod_success?.first).toEqual(execTime1); + expect(execBuffer['2'].prod_success?.first).toEqual(execTime1); + + // additional successful execution + payload.error_node_type = 'n8n-nodes-base.merge'; + payload.workflow_id = '2'; + + await telemetry.trackWorkflowExecution(payload); + + payload.error_node_type = 'n8n-nodes-base.merge'; + payload.workflow_id = '1'; + + await telemetry.trackWorkflowExecution(payload); + + expect(spyTrack).toHaveBeenCalledTimes(0); + + execBuffer = telemetry.getCountsBuffer(); + + expect(execBuffer['1'].manual_error).toBeUndefined(); + expect(execBuffer['1'].manual_success).toBeUndefined(); + expect(execBuffer['1'].prod_error).toBeUndefined(); + expect(execBuffer['2'].manual_error).toBeUndefined(); + expect(execBuffer['2'].manual_success).toBeUndefined(); + expect(execBuffer['2'].prod_error).toBeUndefined(); + + expect(execBuffer['1'].prod_success?.count).toBe(2); + expect(execBuffer['2'].prod_success?.count).toBe(2); + + expect(execBuffer['1'].prod_success?.first).toEqual(execTime1); + expect(execBuffer['2'].prod_success?.first).toEqual(execTime1); + + // failed execution + const execTime2 = fakeJestSystemTime('2022-01-01 12:00:00'); + payload.error_node_type = 'custom-package.custom-node'; + payload.success = false; + await telemetry.trackWorkflowExecution(payload); + + expect(spyTrack).toHaveBeenCalledTimes(0); + + execBuffer = telemetry.getCountsBuffer(); + + expect(execBuffer['1'].manual_error).toBeUndefined(); + expect(execBuffer['1'].manual_success).toBeUndefined(); + expect(execBuffer['2'].manual_error).toBeUndefined(); + expect(execBuffer['2'].manual_success).toBeUndefined(); + expect(execBuffer['2'].prod_error).toBeUndefined(); + + expect(execBuffer['1'].prod_error?.count).toBe(1); + expect(execBuffer['1'].prod_success?.count).toBe(2); + expect(execBuffer['2'].prod_success?.count).toBe(2); + + expect(execBuffer['1'].prod_error?.first).toEqual(execTime2); + expect(execBuffer['1'].prod_success?.first).toEqual(execTime1); + expect(execBuffer['2'].prod_success?.first).toEqual(execTime1); + + // failed execution n8n node + payload.success = false; + payload.error_node_type = 'n8n-nodes-base.merge'; + await telemetry.trackWorkflowExecution(payload); + + expect(spyTrack).toHaveBeenCalledTimes(1); + + execBuffer = telemetry.getCountsBuffer(); + + expect(execBuffer['1'].manual_error).toBeUndefined(); + expect(execBuffer['1'].manual_success).toBeUndefined(); + expect(execBuffer['2'].manual_error).toBeUndefined(); + expect(execBuffer['2'].manual_success).toBeUndefined(); + expect(execBuffer['2'].prod_error).toBeUndefined(); + expect(execBuffer['1'].prod_success?.count).toBe(2); + expect(execBuffer['1'].prod_error?.count).toBe(2); + expect(execBuffer['2'].prod_success?.count).toBe(2); + + expect(execBuffer['1'].prod_error?.first).toEqual(execTime2); + expect(execBuffer['1'].prod_success?.first).toEqual(execTime1); + expect(execBuffer['2'].prod_success?.first).toEqual(execTime1); + }); + }); + + describe('pulse', () => { + let pulseSpy: jest.SpyInstance; + beforeAll(() => { + startPulseSpy.mockRestore(); + }); + + beforeEach(() => { + fakeJestSystemTime(testDateTime); + pulseSpy = jest.spyOn(Telemetry.prototype as any, 'pulse'); + }); + + afterEach(() => { + pulseSpy.mockClear(); + }) + + test('should trigger pulse in intervals', () => { + expect(pulseSpy).toBeCalledTimes(0); + + jest.advanceTimersToNextTimer(); + + expect(pulseSpy).toBeCalledTimes(1); + expect(spyTrack).toHaveBeenCalledTimes(1); + expect(spyTrack).toHaveBeenCalledWith('pulse'); + + jest.advanceTimersToNextTimer(); + + expect(pulseSpy).toBeCalledTimes(2); + expect(spyTrack).toHaveBeenCalledTimes(2); + expect(spyTrack).toHaveBeenCalledWith('pulse'); + }); + + test('should track workflow counts correctly', async () => { + expect(pulseSpy).toBeCalledTimes(0); + + let execBuffer = telemetry.getCountsBuffer(); + + // expect clear counters on start + expect(Object.keys(execBuffer).length).toBe(0); + + const payload = { + workflow_id: '1', + is_manual: true, + success: true, + error_node_type: 'custom-nodes-base.node-type' + }; + + await telemetry.trackWorkflowExecution(payload); + await telemetry.trackWorkflowExecution(payload); + + payload.is_manual = false; + payload.success = true; + await telemetry.trackWorkflowExecution(payload); + await telemetry.trackWorkflowExecution(payload); + + payload.is_manual = true; + payload.success = false; + await telemetry.trackWorkflowExecution(payload); + await telemetry.trackWorkflowExecution(payload); + + payload.is_manual = false; + payload.success = false; + await telemetry.trackWorkflowExecution(payload); + await telemetry.trackWorkflowExecution(payload); + + payload.workflow_id = '2'; + await telemetry.trackWorkflowExecution(payload); + await telemetry.trackWorkflowExecution(payload); + + expect(spyTrack).toHaveBeenCalledTimes(0); + expect(pulseSpy).toBeCalledTimes(0); + + jest.advanceTimersToNextTimer(); + + execBuffer = telemetry.getCountsBuffer(); + + expect(pulseSpy).toBeCalledTimes(1); + expect(spyTrack).toHaveBeenCalledTimes(3); + console.log(spyTrack.getMockImplementation()); + expect(spyTrack).toHaveBeenNthCalledWith(1, 'Workflow execution count', { + event_version: '2', + workflow_id: '1', + manual_error: { + count: 2, + first: testDateTime, + }, + manual_success: { + count: 2, + first: testDateTime, + }, + prod_error: { + count: 2, + first: testDateTime, + }, + prod_success: { + count: 2, + first: testDateTime, + } + }); + expect(spyTrack).toHaveBeenNthCalledWith(2, 'Workflow execution count', { + event_version: '2', + workflow_id: '2', + prod_error: { + count: 2, + first: testDateTime, + } + }); + expect(spyTrack).toHaveBeenNthCalledWith(3, 'pulse'); + expect(Object.keys(execBuffer).length).toBe(0); + + jest.advanceTimersToNextTimer(); + + execBuffer = telemetry.getCountsBuffer(); + expect(Object.keys(execBuffer).length).toBe(0); + + expect(pulseSpy).toBeCalledTimes(2); + expect(spyTrack).toHaveBeenCalledTimes(4); + expect(spyTrack).toHaveBeenNthCalledWith(4, 'pulse'); + }); + }); +}); + +const fakeJestSystemTime = (dateTime: string | Date): Date => { + const dt = new Date(dateTime); + jest.setSystemTime(dt); + return dt; +} diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index 6b79502bcbcf5..c32d22ead49f3 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -112,6 +112,7 @@ import { INodeParameters, INodeProperties, INodeTypeDescription, + ITelemetryTrackProperties, NodeHelpers, } from 'n8n-workflow'; import CredentialIcon from '../CredentialIcon.vue'; @@ -620,7 +621,9 @@ export default mixins(showMessage, nodeHelpers).extend({ let credential; - if (this.mode === 'new' && !this.credentialId) { + const isNewCredential = this.mode === 'new' && !this.credentialId; + + if (isNewCredential) { credential = await this.createCredential( credentialDetails, ); @@ -647,6 +650,30 @@ export default mixins(showMessage, nodeHelpers).extend({ this.authError = ''; this.testedSuccessfully = false; } + + const trackProperties: ITelemetryTrackProperties = { + credential_type: credentialDetails.type, + workflow_id: this.$store.getters.workflowId, + credential_id: credential.id, + is_complete: !!this.requiredPropertiesFilled, + is_new: isNewCredential, + }; + + if (this.isOAuthType) { + trackProperties.is_valid = !!this.isOAuthConnected; + } else if (this.isCredentialTestable) { + trackProperties.is_valid = !!this.testedSuccessfully; + } + + if (this.$store.getters.activeNode) { + trackProperties.node_type = this.$store.getters.activeNode.type; + } + + if (this.authError && this.authError !== '') { + trackProperties.authError = this.authError; + } + + this.$telemetry.track('User saved credentials', trackProperties); } return credential; diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue index df6e0c0142a59..fe01f479dec64 100644 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -435,6 +435,12 @@ export default mixins( } this.retryExecution(commandData.row, loadWorkflow); + + this.$telemetry.track('User clicked retry execution button', { + workflow_id: this.$store.getters.workflowId, + execution_id: commandData.row.id, + retry_type: loadWorkflow ? 'current' : 'original', + }); }, getRowClass (data: IDataObject): string { const classes: string[] = []; diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue index a973db5e25d1a..296048ebf5398 100644 --- a/packages/editor-ui/src/components/ExpressionEdit.vue +++ b/packages/editor-ui/src/components/ExpressionEdit.vue @@ -102,6 +102,59 @@ export default mixins( itemSelected (eventData: IVariableItemSelected) { (this.$refs.inputFieldExpression as any).itemSelected(eventData); // tslint:disable-line:no-any this.$externalHooks().run('expressionEdit.itemSelected', { parameter: this.parameter, value: this.value, selectedItem: eventData }); + + const trackProperties: { + event_version: string; + node_type_dest: string; + node_type_source?: string; + parameter_name_dest: string; + parameter_name_source?: string; + variable_type?: string; + is_immediate_input: boolean; + variable_expression: string; + node_name: string; + } = { + event_version: '2', + node_type_dest: this.$store.getters.activeNode.type, + parameter_name_dest: this.parameter.displayName, + is_immediate_input: false, + variable_expression: eventData.variable, + node_name: this.$store.getters.activeNode.name, + }; + + if (eventData.variable) { + let splitVar = eventData.variable.split('.'); + + if (eventData.variable.startsWith('Object.keys')) { + splitVar = eventData.variable.split('(')[1].split(')')[0].split('.'); + trackProperties.variable_type = 'Keys'; + } else if (eventData.variable.startsWith('Object.values')) { + splitVar = eventData.variable.split('(')[1].split(')')[0].split('.'); + trackProperties.variable_type = 'Values'; + } else { + trackProperties.variable_type = 'Raw value'; + } + + if (splitVar[0].startsWith('$node')) { + const sourceNodeName = splitVar[0].split('"')[1]; + trackProperties.node_type_source = this.$store.getters.getNodeByName(sourceNodeName).type; + const nodeConnections: Array> = this.$store.getters.outgoingConnectionsByNodeName(sourceNodeName).main; + trackProperties.is_immediate_input = (nodeConnections && nodeConnections[0] && !!nodeConnections[0].find(({ node }) => node === this.$store.getters.activeNode.name)) ? true : false; + + if (splitVar[1].startsWith('parameter')) { + trackProperties.parameter_name_source = splitVar[1].split('"')[1]; + } + + } else { + trackProperties.is_immediate_input = true; + + if(splitVar[0].startsWith('$parameter')) { + trackProperties.parameter_name_source = splitVar[0].split('"')[1]; + } + } + } + + this.$telemetry.track('User inserted item from Expression Editor variable selector', trackProperties); }, }, watch: { diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 637a91b15d920..6507813ebd1fc 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -853,6 +853,17 @@ export default mixins( }; this.$emit('valueChanged', parameterData); + + if (this.parameter.name === 'operation' || this.parameter.name === 'mode') { + this.$telemetry.track('User set node operation or mode', { + workflow_id: this.$store.getters.workflowId, + node_type: this.node && this.node.type, + resource: this.node && this.node.parameters.resource, + is_custom: value === CUSTOM_API_CALL_KEY, + session_id: this.$store.getters['ui/ndvSessionId'], + parameter: this.parameter.name, + }); + } }, optionSelected (command: string) { if (command === 'resetValue') { diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index d6e00df77da60..6fc7e5f83e425 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -258,11 +258,7 @@ export const workflowHelpers = mixins( return workflowIssues; }, - // Returns a workflow instance. - getWorkflow (nodes?: INodeUi[], connections?: IConnections, copyData?: boolean): Workflow { - nodes = nodes || this.getNodes(); - connections = connections || (this.$store.getters.allConnections as IConnections); - + getNodeTypes (): INodeTypes { const nodeTypes: INodeTypes = { nodeTypes: {}, init: async (nodeTypes?: INodeTypeData): Promise => { }, @@ -287,6 +283,15 @@ export const workflowHelpers = mixins( }, }; + return nodeTypes; + }, + + // Returns a workflow instance. + getWorkflow (nodes?: INodeUi[], connections?: IConnections, copyData?: boolean): Workflow { + nodes = nodes || this.getNodes(); + connections = connections || (this.$store.getters.allConnections as IConnections); + + const nodeTypes = this.getNodeTypes(); let workflowId = this.$store.getters.workflowId; if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) { workflowId = undefined; diff --git a/packages/editor-ui/src/components/mixins/workflowRun.ts b/packages/editor-ui/src/components/mixins/workflowRun.ts index 5bae8fb45856e..ee243467ce1d9 100644 --- a/packages/editor-ui/src/components/mixins/workflowRun.ts +++ b/packages/editor-ui/src/components/mixins/workflowRun.ts @@ -7,7 +7,9 @@ import { import { IRunData, IRunExecutionData, + IWorkflowBase, NodeHelpers, + TelemetryHelpers, } from 'n8n-workflow'; import { externalHooks } from '@/components/mixins/externalHooks'; @@ -77,11 +79,32 @@ export const workflowRun = mixins( if (workflowIssues !== null) { const errorMessages = []; let nodeIssues: string[]; + const trackNodeIssues: Array<{ + node_type: string; + error: string; + }> = []; + const trackErrorNodeTypes: string[] = []; for (const nodeName of Object.keys(workflowIssues)) { nodeIssues = NodeHelpers.nodeIssuesToString(workflowIssues[nodeName]); + let issueNodeType = 'UNKNOWN'; + const issueNode = this.$store.getters.getNodeByName(nodeName); + + if (issueNode) { + issueNodeType = issueNode.type; + } + + trackErrorNodeTypes.push(issueNodeType); + const trackNodeIssue = { + node_type: issueNodeType, + error: '', + caused_by_credential: !!workflowIssues[nodeName].credentials, + }; + for (const nodeIssue of nodeIssues) { errorMessages.push(`${nodeName}: ${nodeIssue}`); + trackNodeIssue.error = trackNodeIssue.error.concat(', ', nodeIssue); } + trackNodeIssues.push(trackNodeIssue); } this.$showMessage({ @@ -92,6 +115,17 @@ export const workflowRun = mixins( }); this.$titleSet(workflow.name as string, 'ERROR'); this.$externalHooks().run('workflowRun.runError', { errorMessages, nodeName }); + + this.getWorkflowDataToSave().then((workflowData) => { + this.$telemetry.track('Workflow execution preflight failed', { + workflow_id: workflow.id, + workflow_name: workflow.name, + execution_type: nodeName ? 'node' : 'workflow', + node_graph_string: JSON.stringify(TelemetryHelpers.generateNodesGraph(workflowData as IWorkflowBase, this.getNodeTypes()).nodeGraph), + error_node_types: JSON.stringify(trackErrorNodeTypes), + errors: JSON.stringify(trackNodeIssues), + }); + }); return; } } diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts index c571353e33bbf..5b6b4660eb293 100644 --- a/packages/editor-ui/src/plugins/telemetry/index.ts +++ b/packages/editor-ui/src/plugins/telemetry/index.ts @@ -1,6 +1,7 @@ import _Vue from "vue"; import { ITelemetrySettings, + ITelemetryTrackProperties, IDataObject, } from 'n8n-workflow'; import { ILogLevel, INodeCreateElement, IRootState } from "@/Interface"; @@ -72,6 +73,7 @@ class Telemetry { this.loadTelemetryLibrary(options.config.key, options.config.url, { integrations: { All: false }, loadIntegration: false, ...logging}); this.identify(instanceId, userId); this.flushPageEvents(); + this.track('Session started', { session_id: store.getters.sessionId }); } } @@ -86,9 +88,14 @@ class Telemetry { } } - track(event: string, properties?: IDataObject) { + track(event: string, properties?: ITelemetryTrackProperties) { if (this.telemetry) { - this.telemetry.track(event, properties); + const updatedProperties = { + ...properties, + version_cli: this.store && this.store.getters.versionCli, + }; + + this.telemetry.track(event, updatedProperties); } } @@ -131,21 +138,21 @@ class Telemetry { if (properties.createNodeActive !== false) { this.resetNodesPanelSession(); properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId; - this.telemetry.track('User opened nodes panel', properties); + this.track('User opened nodes panel', properties); } break; case 'nodeCreateList.selectedTypeChanged': this.userNodesPanelSession.data.filterMode = properties.new_filter as string; - this.telemetry.track('User changed nodes panel filter', properties); + this.track('User changed nodes panel filter', properties); break; case 'nodeCreateList.destroyed': if(this.userNodesPanelSession.data.nodeFilter.length > 0 && this.userNodesPanelSession.data.nodeFilter !== '') { - this.telemetry.track('User entered nodes panel search term', this.generateNodesPanelEvent()); + this.track('User entered nodes panel search term', this.generateNodesPanelEvent()); } break; case 'nodeCreateList.nodeFilterChanged': if((properties.newValue as string).length === 0 && this.userNodesPanelSession.data.nodeFilter.length > 0) { - this.telemetry.track('User entered nodes panel search term', this.generateNodesPanelEvent()); + this.track('User entered nodes panel search term', this.generateNodesPanelEvent()); } if((properties.newValue as string).length > (properties.oldValue as string || '').length) { @@ -155,7 +162,7 @@ class Telemetry { break; case 'nodeCreateList.onCategoryExpanded': properties.is_subcategory = false; - this.telemetry.track('User viewed node category', properties); + this.track('User viewed node category', properties); break; case 'nodeCreateList.onSubcategorySelected': const selectedProperties = (properties.selected as IDataObject).properties as IDataObject; @@ -164,13 +171,13 @@ class Telemetry { } properties.is_subcategory = true; delete properties.selected; - this.telemetry.track('User viewed node category', properties); + this.track('User viewed node category', properties); break; case 'nodeView.addNodeButton': - this.telemetry.track('User added node to workflow canvas', properties); + this.track('User added node to workflow canvas', properties); break; case 'nodeView.addSticky': - this.telemetry.track('User inserted workflow note', properties); + this.track('User inserted workflow note', properties); break; default: break; diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 3fca5d2549604..91f4bc772ac53 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -193,6 +193,9 @@ import { IRun, ITaskData, INodeCredentialsDetails, + TelemetryHelpers, + ITelemetryTrackProperties, + IWorkflowBase, } from 'n8n-workflow'; import { ICredentialsResponse, @@ -409,7 +412,13 @@ export default mixins( this.runWorkflow(nodeName, source); }, onRunWorkflow() { - this.$telemetry.track('User clicked execute workflow button', { workflow_id: this.$store.getters.workflowId }); + this.getWorkflowDataToSave().then((workflowData) => { + this.$telemetry.track('User clicked execute workflow button', { + workflow_id: this.$store.getters.workflowId, + node_graph_string: JSON.stringify(TelemetryHelpers.generateNodesGraph(workflowData as IWorkflowBase, this.getNodeTypes()).nodeGraph), + }); + }); + this.runWorkflow(); }, onCreateMenuHoverIn(mouseinEvent: MouseEvent) { @@ -1169,6 +1178,15 @@ export default mixins( } } this.stopExecutionInProgress = false; + + this.getWorkflowDataToSave().then((workflowData) => { + const trackProps = { + workflow_id: this.$store.getters.workflowId, + node_graph_string: JSON.stringify(TelemetryHelpers.generateNodesGraph(workflowData as IWorkflowBase, this.getNodeTypes()).nodeGraph), + }; + + this.$telemetry.track('User clicked stop workflow execution', trackProps); + }); }, async stopWaitingForWebhook () { @@ -1501,11 +1519,17 @@ export default mixins( this.$telemetry.trackNodesPanel('nodeView.addSticky', { workflow_id: this.$store.getters.workflowId }); } else { this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName }); - this.$telemetry.trackNodesPanel('nodeView.addNodeButton', { + const trackProperties: ITelemetryTrackProperties = { node_type: nodeTypeName, workflow_id: this.$store.getters.workflowId, drag_and_drop: options.dragAndDrop, - } as IDataObject); + }; + + if (lastSelectedNode) { + trackProperties.input_node_type = lastSelectedNode.type; + } + + this.$telemetry.trackNodesPanel('nodeView.addNodeButton', trackProperties); } // Automatically deselect all nodes and select the current one and also active diff --git a/packages/workflow/package.json b/packages/workflow/package.json index f65a9a0e12f81..e7b1b886e04fe 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -52,13 +52,13 @@ "typescript": "~4.6.0" }, "dependencies": { + "@n8n_io/riot-tmpl": "^1.0.1", "jmespath": "^0.16.0", "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", "lodash.merge": "^4.6.2", "lodash.set": "^4.3.2", "luxon": "^2.3.0", - "@n8n_io/riot-tmpl": "^1.0.1", "xml2js": "^0.4.23" }, "jest": { diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 6a6802460ce46..4fb1ebdbe7fbb 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1476,6 +1476,11 @@ export type PropertiesOf = Ar // Telemetry +export interface ITelemetryTrackProperties { + user_id?: string; + [key: string]: GenericValue; +} + export interface INodesGraph { node_types: string[]; node_connections: IDataObject[]; @@ -1519,6 +1524,7 @@ export interface INodeNameIndex { export interface INodesGraphResult { nodeGraph: INodesGraph; nameIndices: INodeNameIndex; + webhookNodeNames: string[]; } export interface ITelemetryClientConfig { diff --git a/packages/workflow/src/TelemetryHelpers.ts b/packages/workflow/src/TelemetryHelpers.ts index cbb9729424e45..72f9054cfc944 100644 --- a/packages/workflow/src/TelemetryHelpers.ts +++ b/packages/workflow/src/TelemetryHelpers.ts @@ -11,8 +11,6 @@ import { } from '.'; import { INodeType } from './Interfaces'; -import { getInstance as getLoggerInstance } from './LoggerProxy'; - const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote'; export function getNodeTypeForName(workflow: IWorkflowBase, nodeName: string): INode | undefined { @@ -124,6 +122,7 @@ export function generateNodesGraph( notes: {}, }; const nodeNameAndIndex: INodeNameIndex = {}; + const webhookNodeNames: string[] = []; try { const notes = workflow.nodes.filter((node) => node.type === STICKY_NODE_TYPE); @@ -177,6 +176,8 @@ export function generateNodesGraph( nodeItem.domain_base = getDomainBase(url); nodeItem.domain_path = getDomainPath(url); nodeItem.method = node.parameters.requestMethod as string; + } else if (node.type === 'n8n-nodes-base.webhook') { + webhookNodeNames.push(node.name); } else { const nodeType = nodeTypes.getByNameAndVersion(node.type); @@ -210,12 +211,9 @@ export function generateNodesGraph( }); }); }); - } catch (e) { - const logger = getLoggerInstance(); - logger.warn(`Failed to generate nodes graph for workflowId: ${workflow.id as string | number}`); - logger.warn((e as Error).message); - logger.warn((e as Error).stack ?? ''); + } catch (_) { + return { nodeGraph: nodesGraph, nameIndices: nodeNameAndIndex, webhookNodeNames }; } - return { nodeGraph: nodesGraph, nameIndices: nodeNameAndIndex }; + return { nodeGraph: nodesGraph, nameIndices: nodeNameAndIndex, webhookNodeNames }; }