refactor(core): Decouple workflow created, saved, deleted events from internal hooks (no-changelog) (#10264)

This commit is contained in:
Iván Ovejero 2024-08-01 13:44:23 +02:00 committed by GitHub
parent efb71dd9ad
commit d8688bd463
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 125 additions and 103 deletions

View file

@ -10,22 +10,15 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { TelemetryHelpers } from 'n8n-workflow'; import { TelemetryHelpers } from 'n8n-workflow';
import config from '@/config';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import type { AuthProviderType } from '@db/entities/AuthIdentity'; import type { AuthProviderType } from '@db/entities/AuthIdentity';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions';
import type { import type { ITelemetryUserDeletionData, IExecutionTrackProperties } from '@/Interfaces';
ITelemetryUserDeletionData,
IWorkflowDb,
IExecutionTrackProperties,
} from '@/Interfaces';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { NodeTypes } from '@/NodeTypes'; import { NodeTypes } from '@/NodeTypes';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import type { Project } from '@db/entities/Project';
import { ProjectRelationRepository } from './databases/repositories/projectRelation.repository';
import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus'; import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus';
/** /**
@ -40,7 +33,6 @@ export class InternalHooks {
private readonly nodeTypes: NodeTypes, private readonly nodeTypes: NodeTypes,
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository,
workflowStatisticsService: WorkflowStatisticsService, workflowStatisticsService: WorkflowStatisticsService,
private readonly projectRelationRepository: ProjectRelationRepository,
// Can't use @ts-expect-error because only dev time tsconfig considers this as an error, but not build time // Can't use @ts-expect-error because only dev time tsconfig considers this as an error, but not build time
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - needed until we decouple telemetry // @ts-ignore - needed until we decouple telemetry
@ -72,78 +64,6 @@ export class InternalHooks {
this.telemetry.track('User responded to personalization questions', personalizationSurveyData); this.telemetry.track('User responded to personalization questions', personalizationSurveyData);
} }
onWorkflowCreated(
user: User,
workflow: IWorkflowBase,
project: Project,
publicApi: boolean,
): void {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
this.telemetry.track('User created workflow', {
user_id: user.id,
workflow_id: workflow.id,
node_graph_string: JSON.stringify(nodeGraph),
public_api: publicApi,
project_id: project.id,
project_type: project.type,
});
}
onWorkflowDeleted(user: User, workflowId: string, publicApi: boolean): void {
this.telemetry.track('User deleted workflow', {
user_id: user.id,
workflow_id: workflowId,
public_api: publicApi,
});
}
async onWorkflowSaved(user: User, workflow: IWorkflowDb, publicApi: boolean): Promise<void> {
const isCloudDeployment = config.getEnv('deployment.type') === 'cloud';
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, {
isCloudDeployment,
});
let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined;
const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id);
if (role) {
userRole = role === 'workflow:owner' ? 'owner' : 'sharee';
} else {
const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject(
workflow.id,
);
if (workflowOwner) {
const projectRole = await this.projectRelationRepository.findProjectRole({
userId: user.id,
projectId: workflowOwner.id,
});
if (projectRole && projectRole !== 'project:personalOwner') {
userRole = 'member';
}
}
}
const notesCount = Object.keys(nodeGraph.notes).length;
const overlappingCount = Object.values(nodeGraph.notes).filter(
(note) => note.overlapping,
).length;
this.telemetry.track('User saved workflow', {
user_id: user.id,
workflow_id: workflow.id,
node_graph_string: JSON.stringify(nodeGraph),
notes_count_overlapping: overlappingCount,
notes_count_non_overlapping: notesCount - overlappingCount,
version_cli: N8N_VERSION,
num_tags: workflow.tags?.length ?? 0,
public_api: publicApi,
sharing_role: userRole,
});
}
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
async onWorkflowPostExecute( async onWorkflowPostExecute(
_executionId: string, _executionId: string,

View file

@ -60,10 +60,12 @@ export = {
); );
await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]);
Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, project, true);
Container.get(EventService).emit('workflow-created', { Container.get(EventService).emit('workflow-created', {
workflow: createdWorkflow, workflow: createdWorkflow,
user: req.user, user: req.user,
publicApi: true,
projectId: project.id,
projectType: project.type,
}); });
return res.json(createdWorkflow); return res.json(createdWorkflow);
@ -259,11 +261,10 @@ export = {
} }
await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]);
void Container.get(InternalHooks).onWorkflowSaved(req.user, updateData, true);
Container.get(EventService).emit('workflow-saved', { Container.get(EventService).emit('workflow-saved', {
user: req.user, user: req.user,
workflowId: updateData.id, workflow: updateData,
workflowName: updateData.name, publicApi: true,
}); });
return res.json(updatedWorkflow); return res.json(updatedWorkflow);

View file

@ -4,6 +4,7 @@ import type { MessageEventBus } from '../MessageEventBus/MessageEventBus';
import type { Event } from '../event.types'; import type { Event } from '../event.types';
import { EventService } from '../event.service'; import { EventService } from '../event.service';
import type { INode, IRun, IWorkflowBase } from 'n8n-workflow'; import type { INode, IRun, IWorkflowBase } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interfaces';
describe('AuditEventRelay', () => { describe('AuditEventRelay', () => {
const eventBus = mock<MessageEventBus>(); const eventBus = mock<MessageEventBus>();
@ -29,6 +30,9 @@ describe('AuditEventRelay', () => {
id: 'wf123', id: 'wf123',
name: 'Test Workflow', name: 'Test Workflow',
}), }),
publicApi: false,
projectId: 'proj123',
projectType: 'personal',
}; };
eventService.emit('workflow-created', event); eventService.emit('workflow-created', event);
@ -57,6 +61,7 @@ describe('AuditEventRelay', () => {
role: 'user', role: 'user',
}, },
workflowId: 'wf789', workflowId: 'wf789',
publicApi: false,
}; };
eventService.emit('workflow-deleted', event); eventService.emit('workflow-deleted', event);
@ -83,8 +88,8 @@ describe('AuditEventRelay', () => {
lastName: 'Johnson', lastName: 'Johnson',
role: 'editor', role: 'editor',
}, },
workflowId: 'wf101', workflow: mock<IWorkflowDb>({ id: 'wf101', name: 'Updated Workflow' }),
workflowName: 'Updated Workflow', publicApi: false,
}; };
eventService.emit('workflow-saved', event); eventService.emit('workflow-saved', event);

View file

@ -86,13 +86,13 @@ export class AuditEventRelay {
} }
@Redactable() @Redactable()
private workflowSaved({ user, workflowId, workflowName }: Event['workflow-saved']) { private workflowSaved({ user, workflow }: Event['workflow-saved']) {
void this.eventBus.sendAuditEvent({ void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.workflow.updated', eventName: 'n8n.audit.workflow.updated',
payload: { payload: {
...user, ...user,
workflowId, workflowId: workflow.id,
workflowName, workflowName: workflow.name,
}, },
}); });
} }
@ -272,7 +272,7 @@ export class AuditEventRelay {
} }
/** /**
* API key * Public API
*/ */
@Redactable() @Redactable()

View file

@ -1,5 +1,5 @@
import type { AuthenticationMethod, IRun, IWorkflowBase } from 'n8n-workflow'; import type { AuthenticationMethod, IRun, IWorkflowBase } from 'n8n-workflow';
import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces';
import type { ProjectRole } from '@/databases/entities/ProjectRelation'; import type { ProjectRole } from '@/databases/entities/ProjectRelation';
import type { GlobalRole } from '@/databases/entities/User'; import type { GlobalRole } from '@/databases/entities/User';
@ -20,17 +20,21 @@ export type Event = {
'workflow-created': { 'workflow-created': {
user: UserLike; user: UserLike;
workflow: IWorkflowBase; workflow: IWorkflowBase;
publicApi: boolean;
projectId: string;
projectType: string;
}; };
'workflow-deleted': { 'workflow-deleted': {
user: UserLike; user: UserLike;
workflowId: string; workflowId: string;
publicApi: boolean;
}; };
'workflow-saved': { 'workflow-saved': {
user: UserLike; user: UserLike;
workflowId: string; workflow: IWorkflowDb;
workflowName: string; publicApi: boolean;
}; };
'workflow-pre-execute': { 'workflow-pre-execute': {

View file

@ -8,6 +8,10 @@ import { License } from '@/License';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { TelemetryHelpers } from 'n8n-workflow';
import { NodeTypes } from '@/NodeTypes';
import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
@Service() @Service()
export class TelemetryEventRelay { export class TelemetryEventRelay {
@ -17,6 +21,9 @@ export class TelemetryEventRelay {
private readonly license: License, private readonly license: License,
private readonly globalConfig: GlobalConfig, private readonly globalConfig: GlobalConfig,
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly nodeTypes: NodeTypes,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly projectRelationRepository: ProjectRelationRepository,
) {} ) {}
async init() { async init() {
@ -101,6 +108,16 @@ export class TelemetryEventRelay {
this.eventService.on('login-failed-due-to-ldap-disabled', (event) => { this.eventService.on('login-failed-due-to-ldap-disabled', (event) => {
this.loginFailedDueToLdapDisabled(event); this.loginFailedDueToLdapDisabled(event);
}); });
this.eventService.on('workflow-created', (event) => {
this.workflowCreated(event);
});
this.eventService.on('workflow-deleted', (event) => {
this.workflowDeleted(event);
});
this.eventService.on('workflow-saved', async (event) => {
await this.workflowSaved(event);
});
} }
private teamProjectUpdated({ userId, role, members, projectId }: Event['team-project-updated']) { private teamProjectUpdated({ userId, role, members, projectId }: Event['team-project-updated']) {
@ -431,6 +448,79 @@ export class TelemetryEventRelay {
this.telemetry.track('User login failed since ldap disabled', { user_ud: userId }); this.telemetry.track('User login failed since ldap disabled', { user_ud: userId });
} }
private workflowCreated({
user,
workflow,
publicApi,
projectId,
projectType,
}: Event['workflow-created']) {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
this.telemetry.track('User created workflow', {
user_id: user.id,
workflow_id: workflow.id,
node_graph_string: JSON.stringify(nodeGraph),
public_api: publicApi,
project_id: projectId,
project_type: projectType,
});
}
private workflowDeleted({ user, workflowId, publicApi }: Event['workflow-deleted']) {
this.telemetry.track('User deleted workflow', {
user_id: user.id,
workflow_id: workflowId,
public_api: publicApi,
});
}
private async workflowSaved({ user, workflow, publicApi }: Event['workflow-saved']) {
const isCloudDeployment = config.getEnv('deployment.type') === 'cloud';
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, {
isCloudDeployment,
});
let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined;
const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id);
if (role) {
userRole = role === 'workflow:owner' ? 'owner' : 'sharee';
} else {
const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject(
workflow.id,
);
if (workflowOwner) {
const projectRole = await this.projectRelationRepository.findProjectRole({
userId: user.id,
projectId: workflowOwner.id,
});
if (projectRole && projectRole !== 'project:personalOwner') {
userRole = 'member';
}
}
}
const notesCount = Object.keys(nodeGraph.notes).length;
const overlappingCount = Object.values(nodeGraph.notes).filter(
(note) => note.overlapping,
).length;
this.telemetry.track('User saved workflow', {
user_id: user.id,
workflow_id: workflow.id,
node_graph_string: JSON.stringify(nodeGraph),
notes_count_overlapping: overlappingCount,
notes_count_non_overlapping: notesCount - overlappingCount,
version_cli: N8N_VERSION,
num_tags: workflow.tags?.length ?? 0,
public_api: publicApi,
sharing_role: userRole,
});
}
private async serverStarted() { private async serverStarted() {
const cpus = os.cpus(); const cpus = os.cpus();
const binaryDataConfig = config.getEnv('binaryDataManager'); const binaryDataConfig = config.getEnv('binaryDataManager');

View file

@ -1,4 +1,4 @@
import Container, { Service } from 'typedi'; import { Service } from 'typedi';
import { NodeApiError } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
@ -17,7 +17,6 @@ import { validateEntity } from '@/GenericHelpers';
import { ExternalHooks } from '@/ExternalHooks'; import { ExternalHooks } from '@/ExternalHooks';
import { hasSharing, type ListQuery } from '@/requests'; import { hasSharing, type ListQuery } from '@/requests';
import { TagService } from '@/services/tag.service'; import { TagService } from '@/services/tag.service';
import { InternalHooks } from '@/InternalHooks';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
@ -219,11 +218,10 @@ export class WorkflowService {
} }
await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
void Container.get(InternalHooks).onWorkflowSaved(user, updatedWorkflow, false);
this.eventService.emit('workflow-saved', { this.eventService.emit('workflow-saved', {
user, user,
workflowId: updatedWorkflow.id, workflow: updatedWorkflow,
workflowName: updatedWorkflow.name, publicApi: false,
}); });
if (updatedWorkflow.active) { if (updatedWorkflow.active) {
@ -282,8 +280,7 @@ export class WorkflowService {
await this.workflowRepository.delete(workflowId); await this.workflowRepository.delete(workflowId);
await this.binaryDataService.deleteMany(idsForDeletion); await this.binaryDataService.deleteMany(idsForDeletion);
Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false); this.eventService.emit('workflow-deleted', { user, workflowId, publicApi: false });
this.eventService.emit('workflow-deleted', { user, workflowId });
await this.externalHooks.run('workflow.afterDelete', [workflowId]); await this.externalHooks.run('workflow.afterDelete', [workflowId]);
return workflow; return workflow;

View file

@ -179,8 +179,13 @@ export class WorkflowsController {
delete savedWorkflowWithMetaData.shared; delete savedWorkflowWithMetaData.shared;
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
this.internalHooks.onWorkflowCreated(req.user, newWorkflow, project!, false); this.eventService.emit('workflow-created', {
this.eventService.emit('workflow-created', { user: req.user, workflow: newWorkflow }); user: req.user,
workflow: newWorkflow,
publicApi: false,
projectId: project!.id,
projectType: project!.type,
});
const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id); const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id);