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/nodes-base/credentials/ElasticsearchApi.credentials.ts b/packages/nodes-base/credentials/ElasticsearchApi.credentials.ts index eb3a35243dfeb..7762f96727f05 100644 --- a/packages/nodes-base/credentials/ElasticsearchApi.credentials.ts +++ b/packages/nodes-base/credentials/ElasticsearchApi.credentials.ts @@ -1,4 +1,6 @@ import { + IAuthenticateGeneric, + ICredentialTestRequest, ICredentialType, INodeProperties, } from 'n8n-workflow'; @@ -31,5 +33,32 @@ export class ElasticsearchApi implements ICredentialType { placeholder: 'https://mydeployment.es.us-central1.gcp.cloud.es.io:9243', description: 'Referred to as Elasticsearch \'endpoint\' in the Elastic deployment dashboard', }, + { + displayName: 'Ignore SSL Issues', + name: 'ignoreSSLIssues', + type: 'boolean', + default: false, + }, ]; -} + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + auth: { + username: '={{$credentials.username}}', + password: '={{$credentials.password}}', + }, + skipSslCertificateValidation: '={{$credentials.ignoreSSLIssues}}', + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: '={{$credentials.baseUrl}}', + auth: { + username: '=${{credentias.username}}', + password: '=${{credentials.password}}', + }, + url: '', + }, + };} diff --git a/packages/nodes-base/credentials/FreshworksCrmApi.credentials.ts b/packages/nodes-base/credentials/FreshworksCrmApi.credentials.ts index 115fb998f449c..f81f46e7dc5d5 100644 --- a/packages/nodes-base/credentials/FreshworksCrmApi.credentials.ts +++ b/packages/nodes-base/credentials/FreshworksCrmApi.credentials.ts @@ -1,4 +1,6 @@ import { + IAuthenticateGeneric, + ICredentialTestRequest, ICredentialType, INodeProperties, } from 'n8n-workflow'; @@ -24,4 +26,19 @@ export class FreshworksCrmApi implements ICredentialType { description: 'Domain in the Freshworks CRM org URL. For example, in https://n8n-org.myfreshworks.com, the domain is n8n-org.', }, ]; + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + 'Authorization': '=Token token={{$credentials?.apiKey}}', + }, + }, + }; + test: ICredentialTestRequest = { + request: { + baseURL: '=https://{{$credentials?.domain}}.myfreshworks.com/crm/sales/api', + url: '/tasks', + method: 'GET', + }, + }; } diff --git a/packages/nodes-base/credentials/TelegramApi.credentials.ts b/packages/nodes-base/credentials/TelegramApi.credentials.ts index cc824580721da..6edcf45fe9f82 100644 --- a/packages/nodes-base/credentials/TelegramApi.credentials.ts +++ b/packages/nodes-base/credentials/TelegramApi.credentials.ts @@ -1,4 +1,5 @@ import { + ICredentialTestRequest, ICredentialType, INodeProperties, } from 'n8n-workflow'; @@ -17,4 +18,11 @@ export class TelegramApi implements ICredentialType { description: 'Chat with the bot father to obtain the access token', }, ]; + test: ICredentialTestRequest = { + request: { + baseURL: `=https://api.telegram.org/bot{{$credentials?.accessToken}}`, + url: '/getMe', + method: 'GET', + }, + }; } diff --git a/packages/nodes-base/nodes/Elastic/Elasticsearch/Elasticsearch.node.ts b/packages/nodes-base/nodes/Elastic/Elasticsearch/Elasticsearch.node.ts index a4ce582a2e1a4..e7aab680de91c 100644 --- a/packages/nodes-base/nodes/Elastic/Elasticsearch/Elasticsearch.node.ts +++ b/packages/nodes-base/nodes/Elastic/Elasticsearch/Elasticsearch.node.ts @@ -3,10 +3,14 @@ import { } from 'n8n-core'; import { + ICredentialsDecrypted, + ICredentialTestFunctions, IDataObject, + INodeCredentialTestResult, INodeExecutionData, INodeType, INodeTypeDescription, + JsonObject, } from 'n8n-workflow'; import { @@ -22,6 +26,7 @@ import { import { DocumentGetAllOptions, + ElasticsearchApiCredentials, FieldsUiValues, } from './types'; @@ -211,20 +216,23 @@ export class Elasticsearch implements INodeType { const qs = {} as IDataObject; const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const options = this.getNodeParameter('options', i, {}) as IDataObject; if (Object.keys(additionalFields).length) { Object.assign(qs, omit(additionalFields, ['documentId'])); } + Object.assign(qs, options); + const indexId = this.getNodeParameter('indexId', i); const { documentId } = additionalFields; if (documentId) { const endpoint = `/${indexId}/_doc/${documentId}`; - responseData = await elasticsearchApiRequest.call(this, 'PUT', endpoint, body); + responseData = await elasticsearchApiRequest.call(this, 'PUT', endpoint, body, qs); } else { const endpoint = `/${indexId}/_doc`; - responseData = await elasticsearchApiRequest.call(this, 'POST', endpoint, body); + responseData = await elasticsearchApiRequest.call(this, 'POST', endpoint, body, qs); } } else if (operation === 'update') { @@ -259,9 +267,14 @@ export class Elasticsearch implements INodeType { const indexId = this.getNodeParameter('indexId', i); const documentId = this.getNodeParameter('documentId', i); + const options = this.getNodeParameter('options', i, {}) as IDataObject; + + const qs = { + ...options, + }; const endpoint = `/${indexId}/_update/${documentId}`; - responseData = await elasticsearchApiRequest.call(this, 'POST', endpoint, body); + responseData = await elasticsearchApiRequest.call(this, 'POST', endpoint, body, qs); } diff --git a/packages/nodes-base/nodes/Elastic/Elasticsearch/GenericFunctions.ts b/packages/nodes-base/nodes/Elastic/Elasticsearch/GenericFunctions.ts index bfcd96faebb1a..9a14c7cbbe2d5 100644 --- a/packages/nodes-base/nodes/Elastic/Elasticsearch/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Elastic/Elasticsearch/GenericFunctions.ts @@ -23,23 +23,17 @@ export async function elasticsearchApiRequest( qs: IDataObject = {}, ) { const { - username, - password, baseUrl, + ignoreSSLIssues, } = await this.getCredentials('elasticsearchApi') as ElasticsearchApiCredentials; - const token = Buffer.from(`${username}:${password}`).toString('base64'); - const options: OptionsWithUri = { - headers: { - Authorization: `Basic ${token}`, - 'Content-Type': 'application/json', - }, method, body, qs, uri: `${baseUrl}${endpoint}`, json: true, + rejectUnauthorized: !ignoreSSLIssues, }; if (!Object.keys(body).length) { @@ -51,7 +45,7 @@ export async function elasticsearchApiRequest( } try { - return await this.helpers.request(options); + return await this.helpers.requestWithAuthentication.call(this, 'elasticsearchApi', options); } catch (error) { throw new NodeApiError(this.getNode(), error); } diff --git a/packages/nodes-base/nodes/Elastic/Elasticsearch/descriptions/DocumentDescription.ts b/packages/nodes-base/nodes/Elastic/Elasticsearch/descriptions/DocumentDescription.ts index f7c4321a77780..fbad4196ab54b 100644 --- a/packages/nodes-base/nodes/Elastic/Elasticsearch/descriptions/DocumentDescription.ts +++ b/packages/nodes-base/nodes/Elastic/Elasticsearch/descriptions/DocumentDescription.ts @@ -651,6 +651,56 @@ export const documentFields: INodeProperties[] = [ }, ], }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Pipeline ID', + name: 'pipeline', + description: 'ID of the pipeline to use to preprocess incoming documents', + type: 'string', + default: '', + }, + { + displayName: 'Refresh', + name: 'refresh', + description: 'If true, Elasticsearch refreshes the affected shards to make this operation visible to search,if wait_for then wait for a refresh to make this operation visible to search,if false do nothing with refreshes', + type: 'options', + default: 'false', + options: [ + { + name: 'True', + value: 'true', + description: 'Refreshes the affected shards to make this operation visible to search', + }, + { + name: 'Wait For', + value: 'wait_for', + description: 'Wait for a refresh to make this operation visible', + }, + { + name: 'False', + value: 'false', + description: 'Do nothing with refreshes', + }, + ], + }, + ], + }, // ---------------------------------------- // document: update @@ -785,4 +835,47 @@ export const documentFields: INodeProperties[] = [ }, ], }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Refresh', + name: 'refresh', + description: 'If true, Elasticsearch refreshes the affected shards to make this operation visible to search,if wait_for then wait for a refresh to make this operation visible to search,if false do nothing with refreshes', + type: 'options', + default: 'false', + options: [ + { + name: 'True', + value: 'true', + description: 'Refreshes the affected shards to make this operation visible to search', + }, + { + name: 'Wait For', + value: 'wait_for', + description: 'Wait for a refresh to make this operation visible', + }, + { + name: 'False', + value: 'false', + description: 'Do nothing with refreshes', + }, + ], + }, + ], + }, ]; diff --git a/packages/nodes-base/nodes/Elastic/Elasticsearch/types.d.ts b/packages/nodes-base/nodes/Elastic/Elasticsearch/types.d.ts index 105766301ebb1..05c6c58d30747 100644 --- a/packages/nodes-base/nodes/Elastic/Elasticsearch/types.d.ts +++ b/packages/nodes-base/nodes/Elastic/Elasticsearch/types.d.ts @@ -2,6 +2,7 @@ export type ElasticsearchApiCredentials = { username: string; password: string; baseUrl: string; + ignoreSSLIssues: boolean; }; export type DocumentGetAllOptions = Partial<{ diff --git a/packages/nodes-base/nodes/FreshworksCrm/FreshworksCrm.node.ts b/packages/nodes-base/nodes/FreshworksCrm/FreshworksCrm.node.ts index 9dbfd1fc6878a..2ad3fd4e37d64 100644 --- a/packages/nodes-base/nodes/FreshworksCrm/FreshworksCrm.node.ts +++ b/packages/nodes-base/nodes/FreshworksCrm/FreshworksCrm.node.ts @@ -34,6 +34,8 @@ import { noteOperations, salesActivityFields, salesActivityOperations, + searchFields, + searchOperations, taskFields, taskOperations, } from './descriptions'; @@ -100,6 +102,10 @@ export class FreshworksCrm implements INodeType { name: 'Sales Activity', value: 'salesActivity', }, + { + name: 'Search', + value: 'search', + }, { name: 'Task', value: 'task', @@ -119,6 +125,8 @@ export class FreshworksCrm implements INodeType { ...noteFields, ...salesActivityOperations, ...salesActivityFields, + ...searchOperations, + ...searchFields, ...taskOperations, ...taskFields, ], @@ -855,6 +863,58 @@ export class FreshworksCrm implements INodeType { } + } else if (resource === 'search') { + + // ********************************************************************** + // search + // ********************************************************************** + + if (operation === 'query') { + // https://developers.freshworks.com/crm/api/#search + const query = this.getNodeParameter('query', i) as string; + let entities = this.getNodeParameter('entities', i); + const returnAll = this.getNodeParameter('returnAll', 0, false); + + if (Array.isArray(entities)) { + entities = entities.join(','); + } + + const qs: IDataObject = { + q: query, + include: entities, + per_page: 100, + }; + + responseData = await freshworksCrmApiRequest.call(this, 'GET', '/search', {}, qs); + + if (!returnAll) { + const limit = this.getNodeParameter('limit', 0); + responseData = responseData.slice(0, limit); + } + } + + if (operation === 'lookup') { + // https://developers.freshworks.com/crm/api/#lookup_search + let searchField = this.getNodeParameter('searchField', i) as string; + let fieldValue = this.getNodeParameter('fieldValue', i, '') as string; + let entities = this.getNodeParameter('options.entities', i) as string; + if (Array.isArray(entities)) { + entities = entities.join(','); + } + + if (searchField === 'customField') { + searchField = this.getNodeParameter('customFieldName', i) as string; + fieldValue = this.getNodeParameter('customFieldValue', i) as string; + } + + const qs: IDataObject = { + q: fieldValue, + f: searchField, + entities, + }; + + responseData = await freshworksCrmApiRequest.call(this, 'GET', '/lookup', {}, qs); + } } else if (resource === 'task') { // ********************************************************************** diff --git a/packages/nodes-base/nodes/FreshworksCrm/GenericFunctions.ts b/packages/nodes-base/nodes/FreshworksCrm/GenericFunctions.ts index 25ec4f0d87a05..e1d41870fdb8c 100644 --- a/packages/nodes-base/nodes/FreshworksCrm/GenericFunctions.ts +++ b/packages/nodes-base/nodes/FreshworksCrm/GenericFunctions.ts @@ -31,12 +31,9 @@ export async function freshworksCrmApiRequest( body: IDataObject = {}, qs: IDataObject = {}, ) { - const { apiKey, domain } = await this.getCredentials('freshworksCrmApi') as FreshworksCrmApiCredentials; + const { domain } = await this.getCredentials('freshworksCrmApi') as FreshworksCrmApiCredentials; const options: OptionsWithUri = { - headers: { - Authorization: `Token token=${apiKey}`, - }, method, body, qs, @@ -52,7 +49,8 @@ export async function freshworksCrmApiRequest( delete options.qs; } try { - return await this.helpers.request!(options); + const credentialsType = 'freshworksCrmApi'; + return await this.helpers.requestWithAuthentication.call(this, credentialsType, options); } catch (error) { throw new NodeApiError(this.getNode(), error); } diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/SearchDescription.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/SearchDescription.ts new file mode 100644 index 0000000000000..fed7104aa95a5 --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/SearchDescription.ts @@ -0,0 +1,267 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const searchOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + 'search', + ], + }, + }, + options: [ + { + name: 'Query', + value: 'query', + description: 'Search for records by entering search queries of your choice', + }, + { + name: 'Lookup', + value: 'lookup', + description: 'Search for the name or email address of records', + }, + ], + default: 'query', + }, +]; + +export const searchFields: INodeProperties[] = [ + // ---------------------------------------- + // Search: query + // ---------------------------------------- + { + displayName: 'Search Term', + name: 'query', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'query', + ], + }, + }, + description: 'Enter a term that will be used for searching entities', + }, + { + displayName: 'Search on Entities', + name: 'entities', + type: 'multiOptions', + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Deal', + value: 'deal', + }, + { + name: 'Sales Account', + value: 'sales_account', + }, + { + name: 'User', + value: 'user', + }, + ], + required: true, + default: [], + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'query', + ], + }, + }, + description: 'Enter a term that will be used for searching entities', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'query', + ], + }, + }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 25, + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'query', + ], + returnAll: [ + false, + ], + }, + }, + description: 'Max number of results to return', + }, + // ---------------------------------------- + // Search: lookup + // ---------------------------------------- + { + displayName: 'Search Field', + name: 'searchField', + type: 'options', + options: [ + { + name: 'Email', + value: 'email', + }, + { + name: 'Name', + value: 'name', + }, + { + name: 'Custom Field', + value: 'customField', + description: 'Only allowed custom fields of type "Text field", "Number", "Dropdown" or "Radio button"', + }, + ], + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'lookup', + ], + }, + }, + description: 'Field against which the entities have to be searched', + }, + { + displayName: 'Custom Field Name', + name: 'customFieldName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'lookup', + ], + searchField: [ + 'customField', + ], + }, + }, + }, + { + displayName: 'Custom Field Value', + name: 'customFieldValue', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'lookup', + ], + searchField: [ + 'customField', + ], + }, + }, + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'lookup', + ], + searchField: [ + 'email', + 'name', + ], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'search', + ], + operation: [ + 'lookup', + ], + }, + }, + options: [ + { + displayName: 'Entities', + name: 'entities', + type: 'multiOptions', + default: [], + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Deal', + value: 'deal', + }, + { + name: 'Sales Account', + value: 'sales_account', + }, + ], + // eslint-disable-next-line n8n-nodes-base/node-param-description-unneeded-backticks + description: `Use 'entities' to query against related entities. You can include multiple entities at once, provided the field is available in both entities or else you'd receive an error response.`, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/index.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/index.ts index 70957a2c38453..06a000c96016b 100644 --- a/packages/nodes-base/nodes/FreshworksCrm/descriptions/index.ts +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/index.ts @@ -4,4 +4,5 @@ export * from './ContactDescription'; export * from './DealDescription'; export * from './NoteDescription'; export * from './SalesActivityDescription'; +export * from './SearchDescription'; export * from './TaskDescription'; diff --git a/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts b/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts index 99b48467251d2..bd928f308413a 100644 --- a/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts +++ b/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts @@ -4,10 +4,12 @@ import { } from 'n8n-core'; import { + ICredentialDataDecryptedObject, IDataObject, INodeType, INodeTypeDescription, IWebhookResponseData, + NodeOperationError, } from 'n8n-workflow'; import { @@ -55,6 +57,17 @@ export class JiraTrigger implements INodeType { }, }, }, + { + name: 'httpQueryAuth', + required: true, + displayOptions: { + show: { + incomingAuthentication: [ + 'queryAuth', + ], + }, + }, + }, ], webhooks: [ { @@ -81,6 +94,23 @@ export class JiraTrigger implements INodeType { ], default: 'cloud', }, + { + displayName: 'Incoming Authentication', + name: 'incomingAuthentication', + type: 'options', + options: [ + { + name: 'Query Auth', + value: 'queryAuth', + }, + { + name: 'None', + value: 'none', + }, + ], + default: 'none', + description: 'If authentication should be activated for the webhook (makes it more secure)', + }, { displayName: 'Events', name: 'events', @@ -379,6 +409,8 @@ export class JiraTrigger implements INodeType { const webhookData = this.getWorkflowStaticData('node'); + const incomingAuthentication = this.getNodeParameter('incomingAuthentication') as string; + if (events.includes('*')) { events = allEvents; } @@ -402,12 +434,30 @@ export class JiraTrigger implements INodeType { body.excludeBody = additionalFields.excludeBody as boolean; } + // tslint:disable-next-line: no-any + const parameters: any = {}; + + if (incomingAuthentication === 'queryAuth') { + let httpQueryAuth; + try{ + httpQueryAuth = await this.getCredentials('httpQueryAuth'); + } catch (e) { + throw new NodeOperationError(this.getNode(), `Could not retrieve HTTP Query Auth credentials: ${e}`); + } + if (!httpQueryAuth.name && !httpQueryAuth.value) { + throw new NodeOperationError(this.getNode(), `HTTP Query Auth credentials are empty`); + } + parameters[encodeURIComponent(httpQueryAuth.name as string)] = Buffer.from(httpQueryAuth.value as string).toString('base64'); + } + if (additionalFields.includeFields) { - // tslint:disable-next-line: no-any - const parameters: any = {}; + for (const field of additionalFields.includeFields as string[]) { parameters[field] = '${' + field + '}'; } + } + + if (Object.keys(parameters).length) { body.url = `${body.url}?${queryString.unescape(queryString.stringify(parameters))}`; } @@ -441,9 +491,46 @@ export class JiraTrigger implements INodeType { async webhook(this: IWebhookFunctions): Promise { const bodyData = this.getBodyData(); - const queryData = this.getQueryData(); + const queryData = this.getQueryData() as IDataObject; + const response = this.getResponseObject(); + + const incomingAuthentication = this.getNodeParameter('incomingAuthentication') as string; + + if (incomingAuthentication === 'queryAuth') { + let httpQueryAuth: ICredentialDataDecryptedObject | undefined; + + try { + httpQueryAuth = await this.getCredentials('httpQueryAuth'); + } catch (error) { } + + if (httpQueryAuth === undefined || !httpQueryAuth.name || !httpQueryAuth.value) { + + response.status(403).json({ message: 'Auth settings are not valid, some data are missing' }); + + return { + noWebhookResponse: true, + }; + } + + const paramName = httpQueryAuth.name as string; + const paramValue = Buffer.from(httpQueryAuth.value as string).toString('base64'); + + if (!queryData.hasOwnProperty(paramName) || queryData[paramName] !== paramValue) { + + response.status(403).json({ message: 'Provided authentication data is not valid' }); + + return { + noWebhookResponse: true, + }; + } + + delete queryData[paramName]; + + Object.assign(bodyData, queryData); - Object.assign(bodyData, queryData); + } else { + Object.assign(bodyData, queryData); + } return { workflowData: [ diff --git a/packages/nodes-base/nodes/Redis/Redis.node.ts b/packages/nodes-base/nodes/Redis/Redis.node.ts index 2d08f7e77ae83..5c3f474cf3c33 100644 --- a/packages/nodes-base/nodes/Redis/Redis.node.ts +++ b/packages/nodes-base/nodes/Redis/Redis.node.ts @@ -64,11 +64,21 @@ export class Redis implements INodeType { value: 'keys', description: 'Returns all the keys matching a pattern', }, + { + name: 'Pop', + value: 'pop', + description: 'Pop data from a redis list', + }, { name: 'Publish', value: 'publish', description: 'Publish message to redis channel', }, + { + name: 'Push', + value: 'push', + description: 'Push data to a redis list', + }, { name: 'Set', value: 'set', @@ -94,7 +104,7 @@ export class Redis implements INodeType { }, default: 'propertyName', required: true, - description: 'Name of the property to write received data to. Supports dot-notation. Example: "data.person[0].name"', + description: 'Name of the property to write received data to. Supports dot-notation. Example: "data.person[0].name".', }, { displayName: 'Key', @@ -265,7 +275,20 @@ export class Redis implements INodeType { required: true, description: 'The key pattern for the keys to return', }, - + { + displayName: 'Get Values', + name: 'getValues', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'keys', + ], + }, + }, + default: true, + description: 'Whether to get the value of matching keys', + }, // ---------------------------------- // set // ---------------------------------- @@ -411,6 +434,96 @@ export class Redis implements INodeType { required: true, description: 'Data to publish', }, + // ---------------------------------- + // push/pop + // ---------------------------------- + { + displayName: 'List', + name: 'list', + type: 'string', + displayOptions: { + show: { + operation: [ + 'push', + 'pop', + ], + }, + }, + default: '', + required: true, + description: 'Name of the list in Redis', + }, + { + displayName: 'Data', + name: 'messageData', + type: 'string', + displayOptions: { + show: { + operation: [ + 'push', + ], + }, + }, + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + required: true, + description: 'Data to push', + }, + { + displayName: 'Tail', + name: 'tail', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'push', + 'pop', + ], + }, + }, + default: false, + description: 'Whether to push or pop data from the end of the list', + }, + { + displayName: 'Name', + name: 'propertyName', + type: 'string', + displayOptions: { + show: { + operation: [ + 'pop', + ], + }, + }, + default: 'propertyName', + description: 'Optional name of the property to write received data to. Supports dot-notation. Example: "data.person[0].name".', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'pop', + ], + }, + }, + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Dot Notation', + name: 'dotNotation', + type: 'boolean', + default: true, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: '

By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.

If that is not intended this can be deactivated, it will then set { "a.b": value } instead.

.', + }, + ], + }, ], }; @@ -554,7 +667,7 @@ export class Redis implements INodeType { resolve(this.prepareOutputData([{ json: convertInfoToObject(result as unknown as string) }])); client.quit(); - } else if (['delete', 'get', 'keys', 'set', 'incr', 'publish'].includes(operation)) { + } else if (['delete', 'get', 'keys', 'set', 'incr', 'publish', 'push', 'pop'].includes(operation)) { const items = this.getInputData(); const returnItems: INodeExecutionData[] = []; @@ -587,10 +700,16 @@ export class Redis implements INodeType { returnItems.push(item); } else if (operation === 'keys') { const keyPattern = this.getNodeParameter('keyPattern', itemIndex) as string; + const getValues = this.getNodeParameter('getValues', itemIndex, true) as boolean; const clientKeys = util.promisify(client.keys).bind(client); const keys = await clientKeys(keyPattern); + if (!getValues) { + returnItems.push({json: {'keys': keys}}); + continue; + } + const promises: { [key: string]: GenericValue; } = {}; @@ -631,6 +750,37 @@ export class Redis implements INodeType { const clientPublish = util.promisify(client.publish).bind(client); await clientPublish(channel, messageData); returnItems.push(items[itemIndex]); + } else if (operation === 'push'){ + const redisList = this.getNodeParameter('list', itemIndex) as string; + const messageData = this.getNodeParameter('messageData', itemIndex) as string; + const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; + const action = tail ? client.RPUSH : client.LPUSH; + const clientPush = util.promisify(action).bind(client); + // @ts-ignore: typescript not understanding generic function signatures + await clientPush(redisList, messageData); + returnItems.push(items[itemIndex]); + } else if (operation === 'pop'){ + const redisList = this.getNodeParameter('list', itemIndex) as string; + const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; + const propertyName = this.getNodeParameter('propertyName', itemIndex, 'propertyName') as string; + + const action = tail ? client.rpop : client.lpop; + const clientPop = util.promisify(action).bind(client); + const value = await clientPop(redisList); + + let outputValue; + try { + outputValue = JSON.parse(value); + } catch { + outputValue = value; + } + const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject; + if (options.dotNotation === false) { + item.json[propertyName] = outputValue; + } else { + set(item.json, propertyName, outputValue); + } + returnItems.push(item); } } diff --git a/packages/nodes-base/nodes/SpreadsheetFile/SpreadsheetFile.node.ts b/packages/nodes-base/nodes/SpreadsheetFile/SpreadsheetFile.node.ts index c19bd922a93d4..04852eaa0ecce 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile/SpreadsheetFile.node.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile/SpreadsheetFile.node.ts @@ -9,6 +9,7 @@ import { } from 'n8n-workflow'; import { + JSON2SheetOpts, read as xlsxRead, Sheet2JSONOpts, utils as xlsxUtils, @@ -216,6 +217,7 @@ export class SpreadsheetFile implements INodeType { show: { '/operation': [ 'fromFile', + 'toFile', ], }, }, @@ -437,7 +439,10 @@ export class SpreadsheetFile implements INodeType { const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0) as string; const fileFormat = this.getNodeParameter('fileFormat', 0) as string; const options = this.getNodeParameter('options', 0, {}) as IDataObject; - + const sheetToJsonOptions: JSON2SheetOpts = {}; + if (options.headerRow === false) { + sheetToJsonOptions.skipHeader = true; + } // Get the json data of the items and flatten it let item: INodeExecutionData; const itemData: IDataObject[] = []; @@ -446,7 +451,7 @@ export class SpreadsheetFile implements INodeType { itemData.push(flattenObject(item.json)); } - const ws = xlsxUtils.json_to_sheet(itemData); + const ws = xlsxUtils.json_to_sheet(itemData, sheetToJsonOptions); const wopts: WritingOptions = { bookSST: false, diff --git a/packages/nodes-base/nodes/Telegram/Telegram.node.ts b/packages/nodes-base/nodes/Telegram/Telegram.node.ts index 2f23bdae55b8e..14c6702ab3e37 100644 --- a/packages/nodes-base/nodes/Telegram/Telegram.node.ts +++ b/packages/nodes-base/nodes/Telegram/Telegram.node.ts @@ -4,10 +4,7 @@ import { import { IBinaryData, - ICredentialsDecrypted, - ICredentialTestFunctions, IDataObject, - INodeCredentialTestResult, INodeExecutionData, INodeType, INodeTypeDescription, @@ -20,7 +17,6 @@ import { getPropertyName, } from './GenericFunctions'; - export class Telegram implements INodeType { description: INodeTypeDescription = { displayName: 'Telegram', @@ -39,7 +35,6 @@ export class Telegram implements INodeType { { name: 'telegramApi', required: true, - testedBy: 'telegramBotTest', }, ], properties: [ @@ -801,7 +796,6 @@ export class Telegram implements INodeType { placeholder: '', description: 'Name of the binary property that contains the data to upload', }, - { displayName: 'Message ID', name: 'messageId', @@ -1151,6 +1145,7 @@ export class Telegram implements INodeType { default: 'HTML', description: 'How to parse the text', }, + ], }, ], @@ -1686,6 +1681,31 @@ export class Telegram implements INodeType { default: 0, description: 'Duration of clip in seconds', }, + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + displayOptions: { + show: { + '/operation': [ + 'sendAnimation', + 'sendAudio', + 'sendDocument', + 'sendPhoto', + 'sendVideo', + 'sendSticker', + ], + '/resource': [ + 'message', + ], + '/binaryData': [ + true, + ], + }, + }, + placeholder: 'image.jpeg', + }, { displayName: 'Height', name: 'height', @@ -1819,39 +1839,6 @@ export class Telegram implements INodeType { ], }; - methods = { - credentialTest: { - async telegramBotTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise { - const credentials = credential.data; - const options = { - uri: `https://api.telegram.org/bot${credentials!.accessToken}/getMe`, - json: true, - }; - try { - const response = await this.helpers.request(options); - if (!response.ok) { - return { - status: 'Error', - message: 'Token is not valid.', - }; - } - } catch (err) { - return { - status: 'Error', - message: `Token is not valid; ${err.message}`, - }; - } - - return { - status: 'OK', - message: 'Authentication successful!', - }; - - }, - }, - }; - - async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; @@ -2186,6 +2173,16 @@ export class Telegram implements INodeType { const binaryData = items[i].binary![binaryPropertyName] as IBinaryData; const dataBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); const propertyName = getPropertyName(operation); + const fileName = this.getNodeParameter('additionalFields.fileName', 0, '') as string; + + const filename = fileName || binaryData.fileName?.toString(); + + if (!fileName && !binaryData.fileName) { + throw new NodeOperationError(this.getNode(), + `File name is needed to ${operation}. Make sure the property that holds the binary data + has the file name property set or set it manually in the node using the File Name parameter under + Additional Fields.`); + } body.disable_notification = body.disable_notification?.toString() || 'false'; @@ -2194,11 +2191,12 @@ export class Telegram implements INodeType { [propertyName]: { value: dataBuffer, options: { - filename: binaryData.fileName, + filename, contentType: binaryData.mimeType, }, }, }; + responseData = await apiRequest.call(this, requestMethod, endpoint, {}, qs, { formData }); } else { responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); @@ -2233,7 +2231,6 @@ export class Telegram implements INodeType { // chat: responseData.result[0].message.chat, // }; // } - returnData.push({ json: responseData }); } catch (error) { if (this.continueOnFail()) { 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..908179deb87bf 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -158,6 +158,7 @@ export interface IRequestOptionsSimplifiedAuth { body?: IDataObject; headers?: IDataObject; qs?: IDataObject; + skipSslCertificateValidation?: boolean | string; } export abstract class ICredentialsHelper { @@ -1476,6 +1477,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 +1525,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 }; }