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 };
}