refactor: Telemetry updates (#3529)

* Init unit tests for telemetry

* Update telemetry tests

* Test Workflow execution errored event

* Add new tracking logic in pulse

* cleanup

* interfaces

* Add event_version for Workflow execution count event

* add version_cli in all events

* add user saved credentials event

* update manual wf exec finished, fixes

* improve typings, lint

* add node_graph_string in User clicked execute workflow button event

* add User set node operation or mode event

* Add instance started event in FE

* Add User clicked retry execution button event

* add expression editor event

* add input node type to add node event

* add User stopped workflow execution wvent

* add error message in saved credential event

* update stop execution event

* add execution preflight event

* Remove instance started even tfrom FE, add session started to FE,BE

* improve typing

* remove node_graph as property from all events

* move back from default export

* move psl npm package to cli package

* cr

* update webhook node domain logic

* fix is_valid for User saved credentials event

* fix Expression Editor variable selector event

* add caused_by_credential in preflight event

* undo webhook_domain

* change node_type to full type

* add webhook_domain property in manual execution event (#3680)

* add webhook_domain property in manual execution event

* lint fix
This commit is contained in:
Ahsan Virani 2022-07-10 08:53:04 +02:00 committed by GitHub
parent 32c68eb126
commit 6b2db8e4f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 719 additions and 139 deletions

16
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<string, { id: string }>;
// ----------------------------------
// telemetry
// ----------------------------------
export interface IExecutionTrackProperties extends ITelemetryTrackProperties {
workflow_id: string;
success: boolean;
error_node_type?: string;
is_manual: boolean;
}

View file

@ -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<void> {
return this.telemetry.track('Session started', { session_id: sessionId });
}
async onPersonalizationSurveySubmitted(
userId: string,
answers: Record<string, string>,
@ -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<void> {
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),
);

View file

@ -2856,6 +2856,10 @@ class App {
`/${this.restEndpoint}/settings`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<IN8nUISettings> => {
void InternalHooksManager.getInstance().onFrontendSettingsAPI(
req.headers.sessionid as string,
);
return this.getSettingsForFrontend();
},
),

View file

@ -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.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<unknown> {
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<void> {
async trackWorkflowExecution(properties: IExecutionTrackProperties): Promise<void> {
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,
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 (!this.executionCountsBuffer[workflowId][key]) {
this.executionCountsBuffer[workflowId][key] = {
count: 1,
first: execTime,
};
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.executionCountsBuffer[workflowId][key]!.count++;
}
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
if (!properties.success && properties.error_node_type?.startsWith('n8n-nodes-base')) {
void this.track('Workflow execution errored', properties);
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';
} else {
countKey = 'prod_success_count';
firstExecKey = 'first_prod_success';
}
if (
!this.executionCountsBuffer.firstExecutions[firstExecKey] &&
this.executionCountsBuffer.counts[workflowId][countKey] === 0
) {
this.executionCountsBuffer.firstExecutions[firstExecKey] = new Date();
}
this.executionCountsBuffer.counts[workflowId][countKey]++;
}
}
@ -165,7 +130,9 @@ export class Telemetry {
});
}
async identify(traits?: IDataObject): Promise<void> {
async identify(traits?: {
[key: string]: string | number | boolean | object | undefined | null;
}): Promise<void> {
return new Promise<void>((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<void> {
async track(eventName: string, properties: ITelemetryTrackProperties = {}): Promise<void> {
return new Promise<void>((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;
}
}

View file

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

View file

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

View file

@ -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[] = [];

View file

@ -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<Array<{ node: string }>> = 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: {

View file

@ -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') {

View file

@ -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<void> => { },
@ -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;

View file

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

View file

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

View file

@ -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

View file

@ -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": {

View file

@ -1476,6 +1476,11 @@ export type PropertiesOf<M extends { resource: string; operation: string }> = Ar
// Telemetry
export interface ITelemetryTrackProperties {
user_id?: string;
[key: string]: GenericValue;
}
export interface INodesGraph {
node_types: string[];
node_connections: IDataObject[];
@ -1519,6 +1524,7 @@ export interface INodeNameIndex {
export interface INodesGraphResult {
nodeGraph: INodesGraph;
nameIndices: INodeNameIndex;
webhookNodeNames: string[];
}
export interface ITelemetryClientConfig {

View file

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