Files
n8n-enterprise-unlocked/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts

1693 lines
45 KiB
TypeScript

import type { GlobalConfig } from '@n8n/config';
import type { CredentialsEntity } from '@n8n/db';
import type { WorkflowEntity } from '@n8n/db';
import type { IWorkflowDb } from '@n8n/db';
import type { CredentialsRepository } from '@n8n/db';
import type { ProjectRelationRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import { type BinaryDataConfig, InstanceSettings } from 'n8n-core';
import type { INode, INodesGraphResult } from 'n8n-workflow';
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
import { N8N_VERSION } from '@/constants';
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
import type { License } from '@/license';
import type { NodeTypes } from '@/node-types';
import type { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking';
const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve));
describe('TelemetryEventRelay', () => {
const telemetry = mock<Telemetry>();
const license = mock<License>();
const globalConfig = mock<GlobalConfig>({
userManagement: {
emails: {
mode: 'smtp',
},
},
diagnostics: {
enabled: true,
},
endpoints: {
metrics: {
enable: true,
includeDefaultMetrics: true,
includeApiEndpoints: false,
includeCacheMetrics: false,
includeMessageEventBusMetrics: false,
includeQueueMetrics: false,
},
},
logging: {
level: 'info',
outputs: ['console'],
},
});
const binaryDataConfig = mock<BinaryDataConfig>({
mode: 'default',
availableModes: ['default', 'filesystem', 's3'],
});
const instanceSettings = mockInstance(InstanceSettings, { isDocker: false, n8nFolder: '/test' });
const workflowRepository = mock<WorkflowRepository>();
const nodeTypes = mock<NodeTypes>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const projectRelationRepository = mock<ProjectRelationRepository>();
const credentialsRepository = mock<CredentialsRepository>();
const eventService = new EventService();
let telemetryEventRelay: TelemetryEventRelay;
beforeAll(async () => {
telemetryEventRelay = new TelemetryEventRelay(
eventService,
telemetry,
license,
globalConfig,
instanceSettings,
binaryDataConfig,
workflowRepository,
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
await telemetryEventRelay.init();
});
beforeEach(() => {
jest.clearAllMocks();
globalConfig.diagnostics.enabled = true;
});
describe('init', () => {
it('with diagnostics enabled, should init telemetry and register listeners', async () => {
globalConfig.diagnostics.enabled = true;
const telemetryEventRelay = new TelemetryEventRelay(
eventService,
telemetry,
license,
globalConfig,
instanceSettings,
binaryDataConfig,
workflowRepository,
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
// @ts-expect-error Private method
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
await telemetryEventRelay.init();
expect(telemetry.init).toHaveBeenCalled();
expect(setupListenersSpy).toHaveBeenCalled();
});
it('with diagnostics disabled, should neither init telemetry nor register listeners', async () => {
globalConfig.diagnostics.enabled = false;
const telemetryEventRelay = new TelemetryEventRelay(
eventService,
telemetry,
license,
globalConfig,
instanceSettings,
binaryDataConfig,
workflowRepository,
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
// @ts-expect-error Private method
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
await telemetryEventRelay.init();
expect(telemetry.init).not.toHaveBeenCalled();
expect(setupListenersSpy).not.toHaveBeenCalled();
});
});
describe('project events', () => {
it('should track on `team-project-updated` event', () => {
const event: RelayEventMap['team-project-updated'] = {
userId: 'user123',
role: 'global:owner',
members: [
{ userId: 'user456', role: 'project:admin' },
{ userId: 'user789', role: 'project:editor' },
],
projectId: 'project123',
};
eventService.emit('team-project-updated', event);
expect(telemetry.track).toHaveBeenCalledWith('Project settings updated', {
user_id: 'user123',
role: 'global:owner',
members: [
{ user_id: 'user456', role: 'project:admin' },
{ user_id: 'user789', role: 'project:editor' },
],
project_id: 'project123',
});
});
it('should track on `team-project-deleted` event', () => {
const event: RelayEventMap['team-project-deleted'] = {
userId: 'user123',
role: 'global:owner',
projectId: 'project123',
removalType: 'delete',
};
eventService.emit('team-project-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith('User deleted project', {
user_id: 'user123',
role: 'global:owner',
project_id: 'project123',
removal_type: 'delete',
target_project_id: undefined,
});
});
it('should track on `team-project-created` event', () => {
const event: RelayEventMap['team-project-created'] = {
userId: 'user123',
role: 'global:owner',
};
eventService.emit('team-project-created', event);
expect(telemetry.track).toHaveBeenCalledWith('User created project', {
user_id: 'user123',
role: 'global:owner',
});
});
});
describe('source control events', () => {
it('should track on `source-control-settings-updated` event', () => {
const event: RelayEventMap['source-control-settings-updated'] = {
branchName: 'main',
readOnlyInstance: false,
repoType: 'github',
connected: true,
};
eventService.emit('source-control-settings-updated', event);
expect(telemetry.track).toHaveBeenCalledWith('User updated source control settings', {
branch_name: 'main',
read_only_instance: false,
repo_type: 'github',
connected: true,
});
});
it('should track on `source-control-user-started-pull-ui` event', () => {
const event: RelayEventMap['source-control-user-started-pull-ui'] = {
workflowUpdates: 5,
workflowConflicts: 2,
credConflicts: 1,
};
eventService.emit('source-control-user-started-pull-ui', event);
expect(telemetry.track).toHaveBeenCalledWith('User started pull via UI', {
workflow_updates: 5,
workflow_conflicts: 2,
cred_conflicts: 1,
});
});
it('should track on `source-control-user-finished-pull-ui` event', () => {
const event: RelayEventMap['source-control-user-finished-pull-ui'] = {
userId: 'userId',
workflowUpdates: 3,
};
eventService.emit('source-control-user-finished-pull-ui', event);
expect(telemetry.track).toHaveBeenCalledWith('User finished pull via UI', {
user_id: 'userId',
workflow_updates: 3,
});
});
it('should track on `source-control-user-pulled-api` event', () => {
const event: RelayEventMap['source-control-user-pulled-api'] = {
workflowUpdates: 2,
forced: false,
};
eventService.emit('source-control-user-pulled-api', event);
expect(telemetry.track).toHaveBeenCalledWith('User pulled via API', {
workflow_updates: 2,
forced: false,
});
});
it('should track on `source-control-user-started-push-ui` event', () => {
const event: RelayEventMap['source-control-user-started-push-ui'] = {
userId: 'userId',
workflowsEligible: 10,
workflowsEligibleWithConflicts: 2,
credsEligible: 5,
credsEligibleWithConflicts: 1,
variablesEligible: 3,
};
eventService.emit('source-control-user-started-push-ui', event);
expect(telemetry.track).toHaveBeenCalledWith('User started push via UI', {
user_id: 'userId',
workflows_eligible: 10,
workflows_eligible_with_conflicts: 2,
creds_eligible: 5,
creds_eligible_with_conflicts: 1,
variables_eligible: 3,
});
});
it('should track on `source-control-user-finished-push-ui` event', () => {
const event: RelayEventMap['source-control-user-finished-push-ui'] = {
userId: 'userId',
workflowsEligible: 10,
workflowsPushed: 8,
credsPushed: 5,
variablesPushed: 3,
};
eventService.emit('source-control-user-finished-push-ui', event);
expect(telemetry.track).toHaveBeenCalledWith('User finished push via UI', {
user_id: 'userId',
workflows_eligible: 10,
workflows_pushed: 8,
creds_pushed: 5,
variables_pushed: 3,
});
});
});
describe('license events', () => {
it('should track on `license-renewal-attempted` event', () => {
const event: RelayEventMap['license-renewal-attempted'] = {
success: true,
};
eventService.emit('license-renewal-attempted', event);
expect(telemetry.track).toHaveBeenCalledWith('Instance attempted to refresh license', {
success: true,
});
});
});
describe('variable events', () => {
it('should track on `variable-created` event', () => {
eventService.emit('variable-created', {});
expect(telemetry.track).toHaveBeenCalledWith('User created variable');
});
});
describe('external secrets events', () => {
it('should track on `external-secrets-provider-settings-saved` event', () => {
const event: RelayEventMap['external-secrets-provider-settings-saved'] = {
userId: 'user123',
vaultType: 'aws',
isValid: true,
isNew: false,
};
eventService.emit('external-secrets-provider-settings-saved', event);
expect(telemetry.track).toHaveBeenCalledWith('User updated external secrets settings', {
user_id: 'user123',
vault_type: 'aws',
is_valid: true,
is_new: false,
error_message: undefined,
});
});
});
describe('public API events', () => {
it('should track on `public-api-invoked` event', () => {
const event: RelayEventMap['public-api-invoked'] = {
userId: 'user123',
path: '/api/v1/workflows',
method: 'GET',
apiVersion: 'v1',
};
eventService.emit('public-api-invoked', event);
expect(telemetry.track).toHaveBeenCalledWith('User invoked API', {
user_id: 'user123',
path: '/api/v1/workflows',
method: 'GET',
api_version: 'v1',
});
});
it('should track on `public-api-key-created` event', () => {
const event: RelayEventMap['public-api-key-created'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
publicApi: true,
};
eventService.emit('public-api-key-created', event);
expect(telemetry.track).toHaveBeenCalledWith('API key created', {
user_id: 'user123',
public_api: true,
});
});
it('should track on `public-api-key-deleted` event', () => {
const event: RelayEventMap['public-api-key-deleted'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
publicApi: true,
};
eventService.emit('public-api-key-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith('API key deleted', {
user_id: 'user123',
public_api: true,
});
});
});
describe('community package events', () => {
it('should track on `community-package-installed` event', () => {
const event: RelayEventMap['community-package-installed'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
inputString: 'n8n-nodes-package',
packageName: 'n8n-nodes-package',
success: true,
packageVersion: '1.0.0',
packageNodeNames: ['CustomNode1', 'CustomNode2'],
packageAuthor: 'John Smith',
packageAuthorEmail: 'john@example.com',
};
eventService.emit('community-package-installed', event);
expect(telemetry.track).toHaveBeenCalledWith('cnr package install finished', {
user_id: 'user123',
input_string: 'n8n-nodes-package',
package_name: 'n8n-nodes-package',
success: true,
package_version: '1.0.0',
package_node_names: ['CustomNode1', 'CustomNode2'],
package_author: 'John Smith',
package_author_email: 'john@example.com',
failure_reason: undefined,
});
});
it('should track on `community-package-updated` event', () => {
const event: RelayEventMap['community-package-updated'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
packageName: 'n8n-nodes-package',
packageVersionCurrent: '1.0.0',
packageVersionNew: '1.1.0',
packageNodeNames: ['CustomNode1', 'CustomNode2'],
packageAuthor: 'John Smith',
packageAuthorEmail: 'john@example.com',
};
eventService.emit('community-package-updated', event);
expect(telemetry.track).toHaveBeenCalledWith('cnr package updated', {
user_id: 'user123',
package_name: 'n8n-nodes-package',
package_version_current: '1.0.0',
package_version_new: '1.1.0',
package_node_names: ['CustomNode1', 'CustomNode2'],
package_author: 'John Smith',
package_author_email: 'john@example.com',
});
});
it('should track on `community-package-deleted` event', () => {
const event: RelayEventMap['community-package-deleted'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
packageName: 'n8n-nodes-package',
packageVersion: '1.0.0',
packageNodeNames: ['CustomNode1', 'CustomNode2'],
packageAuthor: 'John Smith',
packageAuthorEmail: 'john@example.com',
};
eventService.emit('community-package-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith('cnr package deleted', {
user_id: 'user123',
package_name: 'n8n-nodes-package',
package_version: '1.0.0',
package_node_names: ['CustomNode1', 'CustomNode2'],
package_author: 'John Smith',
package_author_email: 'john@example.com',
});
});
});
describe('credentials events', () => {
it('should track on `credentials-created` event', () => {
const event: RelayEventMap['credentials-created'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
credentialType: 'github',
credentialId: 'cred123',
publicApi: false,
projectId: 'project123',
projectType: 'personal',
};
eventService.emit('credentials-created', event);
expect(telemetry.track).toHaveBeenCalledWith('User created credentials', {
user_id: 'user123',
credential_type: 'github',
credential_id: 'cred123',
project_id: 'project123',
project_type: 'personal',
});
});
it('should track on `credentials-shared` event', () => {
const event: RelayEventMap['credentials-shared'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
credentialType: 'github',
credentialId: 'cred123',
userIdSharer: 'user123',
userIdsShareesAdded: ['user456', 'user789'],
shareesRemoved: 1,
};
eventService.emit('credentials-shared', event);
expect(telemetry.track).toHaveBeenCalledWith('User updated cred sharing', {
user_id: 'user123',
credential_type: 'github',
credential_id: 'cred123',
user_id_sharer: 'user123',
user_ids_sharees_added: ['user456', 'user789'],
sharees_removed: 1,
});
});
it('should track on `credentials-updated` event', () => {
const event: RelayEventMap['credentials-updated'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
credentialId: 'cred123',
credentialType: 'github',
};
eventService.emit('credentials-updated', event);
expect(telemetry.track).toHaveBeenCalledWith('User updated credentials', {
user_id: 'user123',
credential_type: 'github',
credential_id: 'cred123',
});
});
it('should track on `credentials-deleted` event', () => {
const event: RelayEventMap['credentials-deleted'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
credentialId: 'cred123',
credentialType: 'github',
};
eventService.emit('credentials-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith('User deleted credentials', {
user_id: 'user123',
credential_type: 'github',
credential_id: 'cred123',
});
});
});
describe('LDAP events', () => {
it('should track on `ldap-general-sync-finished` event', () => {
const event: RelayEventMap['ldap-general-sync-finished'] = {
type: 'full',
succeeded: true,
usersSynced: 10,
error: '',
};
eventService.emit('ldap-general-sync-finished', event);
expect(telemetry.track).toHaveBeenCalledWith('Ldap general sync finished', {
type: 'full',
succeeded: true,
users_synced: 10,
error: '',
});
});
it('should track on `ldap-settings-updated` event', () => {
const event: RelayEventMap['ldap-settings-updated'] = {
userId: 'user123',
loginIdAttribute: 'uid',
firstNameAttribute: 'givenName',
lastNameAttribute: 'sn',
emailAttribute: 'mail',
ldapIdAttribute: 'entryUUID',
searchPageSize: 100,
searchTimeout: 60,
synchronizationEnabled: true,
synchronizationInterval: 60,
loginLabel: 'LDAP Login',
loginEnabled: true,
};
eventService.emit('ldap-settings-updated', {
...event,
});
const { userId: _, ...rest } = event;
expect(telemetry.track).toHaveBeenCalledWith('User updated Ldap settings', {
user_id: 'user123',
...rest,
});
});
it('should track on `ldap-login-sync-failed` event', () => {
const event: RelayEventMap['ldap-login-sync-failed'] = {
error: 'Connection failed',
};
eventService.emit('ldap-login-sync-failed', event);
expect(telemetry.track).toHaveBeenCalledWith('Ldap login sync failed', {
error: 'Connection failed',
});
});
it('should track on `login-failed-due-to-ldap-disabled` event', () => {
const event: RelayEventMap['login-failed-due-to-ldap-disabled'] = {
userId: 'user123',
};
eventService.emit('login-failed-due-to-ldap-disabled', event);
expect(telemetry.track).toHaveBeenCalledWith('User login failed since ldap disabled', {
user_ud: 'user123',
});
});
});
describe('workflow events', () => {
it('should track on `workflow-created` event', async () => {
const event: RelayEventMap['workflow-created'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
workflow: mock<IWorkflowBase>({ id: 'workflow123', name: 'Test Workflow', nodes: [] }),
publicApi: false,
projectId: 'project123',
projectType: 'personal',
};
eventService.emit('workflow-created', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith('User created workflow', {
user_id: 'user123',
workflow_id: 'workflow123',
node_graph_string: expect.any(String),
public_api: false,
project_id: 'project123',
project_type: 'personal',
});
});
it('should track on `workflow-deleted` event', () => {
const event: RelayEventMap['workflow-deleted'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
workflowId: 'workflow123',
publicApi: false,
};
eventService.emit('workflow-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith('User deleted workflow', {
user_id: 'user123',
workflow_id: 'workflow123',
public_api: false,
});
});
it('should track on `workflow-post-execute` event', async () => {
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mock<IWorkflowDb>({
id: 'workflow123',
name: 'Test Workflow',
nodes: [],
}),
userId: 'user123',
executionId: 'execution123',
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith({
is_manual: false,
success: false,
user_id: 'user123',
version_cli: N8N_VERSION,
workflow_id: 'workflow123',
});
});
it('should track on `workflow-saved` event', async () => {
const event: RelayEventMap['workflow-saved'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
workflow: mock<IWorkflowDb>({ id: 'workflow123', name: 'Test Workflow', nodes: [] }),
publicApi: false,
};
eventService.emit('workflow-saved', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith('User saved workflow', {
user_id: 'user123',
workflow_id: 'workflow123',
node_graph_string: expect.any(String),
notes_count_overlapping: 0,
notes_count_non_overlapping: 0,
version_cli: expect.any(String),
num_tags: 0,
public_api: false,
sharing_role: undefined,
});
});
it('should track on `workflow-sharing-updated` event', () => {
const event: RelayEventMap['workflow-sharing-updated'] = {
workflowId: 'workflow123',
userIdSharer: 'user123',
userIdList: ['user456', 'user789'],
};
eventService.emit('workflow-sharing-updated', event);
expect(telemetry.track).toHaveBeenCalledWith('User updated workflow sharing', {
workflow_id: 'workflow123',
user_id_sharer: 'user123',
user_id_list: ['user456', 'user789'],
});
});
});
describe('user events', () => {
it('should track on `user-updated` event', () => {
const event: RelayEventMap['user-updated'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
fieldsChanged: ['firstName', 'lastName'],
};
eventService.emit('user-updated', event);
expect(telemetry.track).toHaveBeenCalledWith('User changed personal settings', {
user_id: 'user123',
fields_changed: ['firstName', 'lastName'],
});
});
it('should track on `user-deleted` event', () => {
const event: RelayEventMap['user-deleted'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
publicApi: false,
targetUserOldStatus: 'active',
migrationStrategy: 'transfer_data',
targetUserId: 'user456',
migrationUserId: 'user789',
};
eventService.emit('user-deleted', event);
expect(telemetry.track).toHaveBeenCalledWith('User deleted user', {
user_id: 'user123',
public_api: false,
target_user_old_status: 'active',
migration_strategy: 'transfer_data',
target_user_id: 'user456',
migration_user_id: 'user789',
});
});
it('should track on `user-invited` event', () => {
const event: RelayEventMap['user-invited'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
targetUserId: ['user456'],
publicApi: false,
emailSent: true,
inviteeRole: 'global:member',
};
eventService.emit('user-invited', event);
expect(telemetry.track).toHaveBeenCalledWith('User invited new user', {
user_id: 'user123',
target_user_id: ['user456'],
public_api: false,
email_sent: true,
invitee_role: 'global:member',
});
});
it('should track on `user-signed-up` event', () => {
const event: RelayEventMap['user-signed-up'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
userType: 'email',
wasDisabledLdapUser: false,
};
eventService.emit('user-signed-up', event);
expect(telemetry.track).toHaveBeenCalledWith('User signed up', {
user_id: 'user123',
user_type: 'email',
was_disabled_ldap_user: false,
});
});
it('should track on `user-submitted-personalization-survey` event', () => {
const event: RelayEventMap['user-submitted-personalization-survey'] = {
userId: 'user123',
answers: {
version: 'v4',
personalization_survey_n8n_version: '1.0.0',
personalization_survey_submitted_at: '2021-10-01T00:00:00.000Z',
companySize: '1-10',
},
};
eventService.emit('user-submitted-personalization-survey', event);
expect(telemetry.track).toHaveBeenCalledWith('User responded to personalization questions', {
user_id: 'user123',
version: 'v4',
personalization_survey_n8n_version: '1.0.0',
personalization_survey_submitted_at: '2021-10-01T00:00:00.000Z',
company_size: '1-10',
});
});
it('should track on `user-changed-role` event', () => {
const event: RelayEventMap['user-changed-role'] = {
userId: 'user123',
targetUserId: 'user456',
targetUserNewRole: 'global:member',
publicApi: false,
};
eventService.emit('user-changed-role', event);
expect(telemetry.track).toHaveBeenCalledWith('User changed role', {
user_id: 'user123',
target_user_id: 'user456',
target_user_new_role: 'global:member',
public_api: false,
});
});
it('should track on `user-retrieved-user` event', () => {
const event: RelayEventMap['user-retrieved-user'] = {
userId: 'user123',
publicApi: false,
};
eventService.emit('user-retrieved-user', event);
expect(telemetry.track).toHaveBeenCalledWith('User retrieved user', {
user_id: 'user123',
public_api: false,
});
});
it('should track on `user-retrieved-all-users` event', () => {
const event: RelayEventMap['user-retrieved-all-users'] = {
userId: 'user123',
publicApi: false,
};
eventService.emit('user-retrieved-all-users', event);
expect(telemetry.track).toHaveBeenCalledWith('User retrieved all users', {
user_id: 'user123',
public_api: false,
});
});
});
describe('lifecycle events', () => {
it('should track on `server-started` event', async () => {
const firstWorkflow = mock<WorkflowEntity>({ createdAt: new Date() });
workflowRepository.findOne.mockResolvedValue(firstWorkflow);
eventService.emit('server-started');
await flushPromises();
expect(telemetry.identify).toHaveBeenCalledWith(
expect.objectContaining({
version_cli: N8N_VERSION,
metrics: {
metrics_category_cache: false,
metrics_category_default: true,
metrics_category_logs: false,
metrics_category_queue: false,
metrics_category_routes: false,
metrics_enabled: true,
},
n8n_binary_data_mode: 'default',
n8n_deployment_type: 'default',
saml_enabled: false,
smtp_set_up: true,
system_info: {
is_docker: false,
cpus: expect.objectContaining({
count: expect.any(Number),
model: expect.any(String),
speed: expect.any(Number),
}),
memory: expect.any(Number),
os: expect.objectContaining({
type: expect.any(String),
version: expect.any(String),
}),
},
}),
);
expect(telemetry.track).toHaveBeenCalledWith(
'Instance started',
expect.objectContaining({
earliest_workflow_created: firstWorkflow.createdAt,
metrics: {
metrics_enabled: true,
metrics_category_default: true,
metrics_category_routes: false,
metrics_category_cache: false,
metrics_category_logs: false,
metrics_category_queue: false,
},
}),
);
});
it('should track on `session-started` event', () => {
const event: RelayEventMap['session-started'] = {
pushRef: 'ref123',
};
eventService.emit('session-started', event);
expect(telemetry.track).toHaveBeenCalledWith('Session started', {
session_id: 'ref123',
});
});
it('should track on `instance-stopped` event', () => {
eventService.emit('instance-stopped', {});
expect(telemetry.track).toHaveBeenCalledWith('User instance stopped');
});
it('should track on `instance-owner-setup` event', () => {
const event: RelayEventMap['instance-owner-setup'] = {
userId: 'user123',
};
eventService.emit('instance-owner-setup', event);
expect(telemetry.track).toHaveBeenCalledWith('Owner finished instance setup', {
user_id: 'user123',
});
});
});
describe('workflow execution events', () => {
it('should track on `first-production-workflow-succeeded` event', () => {
const event: RelayEventMap['first-production-workflow-succeeded'] = {
projectId: 'project123',
workflowId: 'workflow123',
userId: 'user123',
};
eventService.emit('first-production-workflow-succeeded', event);
expect(telemetry.track).toHaveBeenCalledWith('Workflow first prod success', {
project_id: 'project123',
workflow_id: 'workflow123',
user_id: 'user123',
});
});
it('should track on `first-workflow-data-loaded` event', () => {
const event: RelayEventMap['first-workflow-data-loaded'] = {
userId: 'user123',
workflowId: 'workflow123',
nodeType: 'http',
nodeId: 'node123',
credentialType: 'oAuth2',
credentialId: 'cred123',
};
eventService.emit('first-workflow-data-loaded', event);
expect(telemetry.track).toHaveBeenCalledWith('Workflow first data fetched', {
user_id: 'user123',
workflow_id: 'workflow123',
node_type: 'http',
node_id: 'node123',
credential_type: 'oAuth2',
credential_id: 'cred123',
});
});
});
describe('email events', () => {
it('should track on `email-failed` event', () => {
const event: RelayEventMap['email-failed'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: 'global:owner',
},
messageType: 'New user invite',
publicApi: false,
};
eventService.emit('email-failed', event);
expect(telemetry.track).toHaveBeenCalledWith(
'Instance failed to send transactional email to user',
{
user_id: 'user123',
message_type: 'New user invite',
public_api: false,
},
);
});
});
describe('Community+ registered', () => {
it('should track `license-community-plus-registered` event', () => {
const event: RelayEventMap['license-community-plus-registered'] = {
userId: 'user123',
email: 'user@example.com',
licenseKey: 'license123',
};
eventService.emit('license-community-plus-registered', event);
expect(telemetry.track).toHaveBeenCalledWith('User registered for license community plus', {
user_id: 'user123',
email: 'user@example.com',
licenseKey: 'license123',
});
});
});
describe('workflow post execute events', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockWorkflowBase = mock<IWorkflowBase>({
id: 'workflow123',
name: 'Test Workflow',
active: true,
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
parameters: {},
typeVersion: 1,
position: [100, 200],
},
],
connections: {},
createdAt: new Date(),
updatedAt: new Date(),
staticData: {},
settings: {},
});
it('should not track when workflow has no id', async () => {
const event: RelayEventMap['workflow-post-execute'] = {
workflow: { ...mockWorkflowBase, id: '' },
executionId: 'execution123',
userId: 'user123',
};
eventService.emit('workflow-post-execute', event);
expect(telemetry.trackWorkflowExecution).not.toHaveBeenCalled();
});
it('should track successful workflow execution', async () => {
const runData = mock<IRun>({
finished: true,
status: 'success',
mode: 'manual',
data: { resultData: {} },
});
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData: runData as unknown as IRun,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
user_id: 'user123',
success: true,
is_manual: true,
execution_mode: 'manual',
}),
);
});
it('should call telemetry.track when manual node execution finished', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: false }),
);
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as IRun;
const nodeGraph: INodesGraphResult = {
nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'Manual node exec finished',
expect.objectContaining({
webhook_domain: null,
user_id: 'user123',
workflow_id: 'workflow123',
status: 'error',
executionStatus: 'error',
sharing_role: 'sharee',
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
is_managed: false,
credential_type: null,
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
it('should call telemetry.track when manual node execution finished with canceled error message', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:owner');
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message canceled',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as IRun;
const nodeGraph: INodesGraphResult = {
nodeGraph: { node_types: [], node_connections: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'Manual node exec finished',
expect.objectContaining({
webhook_domain: null,
user_id: 'user123',
workflow_id: 'workflow123',
status: 'canceled',
executionStatus: 'canceled',
sharing_role: 'owner',
error_message: 'Error message canceled',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message canceled',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
it('should call telemetry.track when manual workflow execution finished', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:owner');
const runData = {
status: 'error',
mode: 'manual',
data: {
startData: {
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {
Jira: [
{
data: { main: [[{ json: { headers: { origin: 'https://www.test.com' } } }]] },
},
],
},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as unknown as IRun;
const nodeGraph: INodesGraphResult = {
webhookNodeNames: ['Jira'],
nodeGraph: { node_types: [], node_connections: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'Manual workflow exec finished',
expect.objectContaining({
webhook_domain: 'test.com',
user_id: 'user123',
workflow_id: 'workflow123',
status: 'error',
executionStatus: 'error',
sharing_role: 'owner',
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
it('should call telemetry.track when manual node execution finished with is_managed and credential_type properties', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
);
const runData = {
status: 'error',
mode: 'manual',
data: {
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as unknown as IRun;
const nodeGraph: INodesGraphResult = {
nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
id: 'nhu-l8E4hX',
});
expect(telemetry.track).toHaveBeenCalledWith(
'Manual node exec finished',
expect.objectContaining({
webhook_domain: null,
user_id: 'user123',
workflow_id: 'workflow123',
status: 'error',
executionStatus: 'error',
sharing_role: 'sharee',
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
is_managed: true,
credential_type: 'openAiApi',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
it('should call telemetry.track when user ran out of free AI credits', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
);
const runData = {
status: 'error',
mode: 'trigger',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
parameters: {},
position: [100, 200],
},
{
message: `400 - ${JSON.stringify({
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
})}`,
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
},
{
httpCode: '400',
},
),
},
},
} as unknown as IRun;
jest
.spyOn(TelemetryHelpers, 'userInInstanceRanOutOfFreeAiCredits')
.mockImplementation(() => true);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith('User ran out of free AI credits');
});
});
});