From 8a9a2e346f161ee3bb11c60889bf5fd3a362757e Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sun, 5 Jan 2025 20:41:11 -0500 Subject: [PATCH 1/3] Add telemetry events for free AI credits feature --- .../__tests__/telemetry-event-relay.test.ts | 124 ++++++++++++++++++ .../events/relays/telemetry.event-relay.ts | 16 ++- .../components/FreeAiCreditsCallout.test.ts | 10 ++ .../src/components/FreeAiCreditsCallout.vue | 4 + packages/workflow/src/TelemetryHelpers.ts | 16 +++ .../workflow/test/TelemetryHelpers.test.ts | 49 ++++++- 6 files changed, 217 insertions(+), 2 deletions(-) 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 9b4d8aecd2072..cc695a107e357 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -6,6 +6,7 @@ import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n import { N8N_VERSION } from '@/constants'; 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'; @@ -17,6 +18,8 @@ import type { License } from '@/license'; import type { NodeTypes } from '@/node-types'; import type { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; +import { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import { isMagnetURI } from 'class-validator'; const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve)); @@ -52,6 +55,7 @@ describe('TelemetryEventRelay', () => { const nodeTypes = mock(); const sharedWorkflowRepository = mock(); const projectRelationRepository = mock(); + const credentialsRepository = mock(); const eventService = new EventService(); let telemetryEventRelay: TelemetryEventRelay; @@ -67,6 +71,7 @@ describe('TelemetryEventRelay', () => { nodeTypes, sharedWorkflowRepository, projectRelationRepository, + credentialsRepository, ); await telemetryEventRelay.init(); @@ -90,6 +95,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 a34646f100f5f..02b967cc05b07 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 { Service } from 'typedi'; 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,17 @@ export class TelemetryEventRelay extends EventRelay { } if (runData.data.startData?.destinationNode) { - const telemetryPayload = { + const { credentialId, credentialType } = + TelemetryHelpers.extractLastExecutedNodeCredentialData(runData); + if (credentialId && credentialType) { + manualExecEventProperties.credential_type = credentialType; + const credential = await this.credentialsRepository.findOneBy({ id: credentialId }); + if (credential) { + manualExecEventProperties.is_managed = credential.isManaged; + } + } + + const telemetryPayload: { [key: string]: any } = { ...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 667b3e3efd42f..e48a2c730410e 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 29f99a0e48c37..54c5b62958e37 100644 --- a/packages/editor-ui/src/components/FreeAiCreditsCallout.vue +++ b/packages/editor-ui/src/components/FreeAiCreditsCallout.vue @@ -1,5 +1,6 @@