diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 9b4d8aecd2..c41ea773dd 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -5,7 +5,9 @@ import type { INode, INodesGraphResult } from 'n8n-workflow'; import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow'; import { N8N_VERSION } from '@/constants'; +import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import type { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; @@ -52,6 +54,7 @@ describe('TelemetryEventRelay', () => { const nodeTypes = mock(); const sharedWorkflowRepository = mock(); const projectRelationRepository = mock(); + const credentialsRepository = mock(); const eventService = new EventService(); let telemetryEventRelay: TelemetryEventRelay; @@ -67,6 +70,7 @@ describe('TelemetryEventRelay', () => { nodeTypes, sharedWorkflowRepository, projectRelationRepository, + credentialsRepository, ); await telemetryEventRelay.init(); @@ -90,6 +94,7 @@ describe('TelemetryEventRelay', () => { nodeTypes, sharedWorkflowRepository, projectRelationRepository, + credentialsRepository, ); // @ts-expect-error Private method const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners'); @@ -112,6 +117,7 @@ describe('TelemetryEventRelay', () => { nodeTypes, sharedWorkflowRepository, projectRelationRepository, + credentialsRepository, ); // @ts-expect-error Private method const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners'); @@ -1197,6 +1203,9 @@ describe('TelemetryEventRelay', () => { it('should call telemetry.track when manual node execution finished', async () => { sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor'); + credentialsRepository.findOneBy.mockResolvedValue( + mock({ type: 'openAiApi', isManaged: false }), + ); const runData = { status: 'error', @@ -1276,6 +1285,8 @@ describe('TelemetryEventRelay', () => { 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), }), ); @@ -1498,5 +1509,118 @@ describe('TelemetryEventRelay', () => { }), ); }); + + 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({ 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', + }), + ); + }); }); }); diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 221449bbab..d2bc61c733 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -9,6 +9,7 @@ import { get as pslGet } from 'psl'; import config from '@/config'; import { N8N_VERSION } from '@/constants'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; @@ -34,6 +35,7 @@ export class TelemetryEventRelay extends EventRelay { private readonly nodeTypes: NodeTypes, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly projectRelationRepository: ProjectRelationRepository, + private readonly credentialsRepository: CredentialsRepository, ) { super(eventService); } @@ -693,6 +695,8 @@ export class TelemetryEventRelay extends EventRelay { error_node_id: telemetryProperties.error_node_id as string, webhook_domain: null, sharing_role: userRole, + credential_type: null, + is_managed: false, }; if (!manualExecEventProperties.node_graph_string) { @@ -703,7 +707,18 @@ export class TelemetryEventRelay extends EventRelay { } if (runData.data.startData?.destinationNode) { - const telemetryPayload = { + const credentialsData = TelemetryHelpers.extractLastExecutedNodeCredentialData(runData); + if (credentialsData) { + manualExecEventProperties.credential_type = credentialsData.credentialType; + const credential = await this.credentialsRepository.findOneBy({ + id: credentialsData.credentialId, + }); + if (credential) { + manualExecEventProperties.is_managed = credential.isManaged; + } + } + + const telemetryPayload: ITelemetryTrackProperties = { ...manualExecEventProperties, node_type: TelemetryHelpers.getNodeTypeForName( workflow, diff --git a/packages/editor-ui/src/components/FreeAiCreditsCallout.test.ts b/packages/editor-ui/src/components/FreeAiCreditsCallout.test.ts index 950169394c..23c1495a30 100644 --- a/packages/editor-ui/src/components/FreeAiCreditsCallout.test.ts +++ b/packages/editor-ui/src/components/FreeAiCreditsCallout.test.ts @@ -11,11 +11,16 @@ import { useRootStore } from '@/stores/root.store'; import { useToast } from '@/composables/useToast'; import { renderComponent } from '@/__tests__/render'; import { mockedStore } from '@/__tests__/utils'; +import { useTelemetry } from '@/composables/useTelemetry'; vi.mock('@/composables/useToast', () => ({ useToast: vi.fn(), })); +vi.mock('@/composables/useTelemetry', () => ({ + useTelemetry: vi.fn(), +})); + vi.mock('@/stores/settings.store', () => ({ useSettingsStore: vi.fn(), })); @@ -100,6 +105,10 @@ describe('FreeAiCreditsCallout', () => { (useToast as any).mockReturnValue({ showError: vi.fn(), }); + + (useTelemetry as any).mockReturnValue({ + track: vi.fn(), + }); }); it('should shows the claim callout when the user can claim credits', () => { @@ -120,6 +129,7 @@ describe('FreeAiCreditsCallout', () => { await fireEvent.click(claimButton); expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id'); + expect(useTelemetry().track).toHaveBeenCalledWith('User claimed OpenAI credits'); assertUserClaimedCredits(); }); diff --git a/packages/editor-ui/src/components/FreeAiCreditsCallout.vue b/packages/editor-ui/src/components/FreeAiCreditsCallout.vue index 4762b4acee..9f2030679c 100644 --- a/packages/editor-ui/src/components/FreeAiCreditsCallout.vue +++ b/packages/editor-ui/src/components/FreeAiCreditsCallout.vue @@ -1,5 +1,6 @@