feat: Add telemetry events for free AI credits feature (no-changelog) (#12459)

This commit is contained in:
Ricardo Espinoza 2025-01-07 08:42:19 -05:00 committed by GitHub
parent adcedd1c2b
commit 61993c3906
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 219 additions and 2 deletions

View file

@ -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<NodeTypes>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const projectRelationRepository = mock<ProjectRelationRepository>();
const credentialsRepository = mock<CredentialsRepository>();
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<CredentialsEntity>({ 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<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',
}),
);
});
});
});

View file

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

View file

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

View file

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { AI_CREDITS_EXPERIMENT } from '@/constants';
import { useCredentialsStore } from '@/stores/credentials.store';
@ -32,6 +33,7 @@ const credentialsStore = useCredentialsStore();
const usersStore = useUsersStore();
const ndvStore = useNDVStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const i18n = useI18n();
const toast = useToast();
@ -73,6 +75,8 @@ const onClaimCreditsClicked = async () => {
usersStore.currentUser.settings.userClaimedAiCredits = true;
}
telemetry.track('User claimed OpenAI credits');
showSuccessCallout.value = true;
} catch (e) {
toast.showError(

View file

@ -26,6 +26,7 @@ import type {
IDataObject,
IRunData,
ITaskData,
IRun,
} from './Interfaces';
import { getNodeParameters } from './NodeHelpers';
@ -470,3 +471,21 @@ export function generateNodesGraph(
return { nodeGraph, nameIndices, webhookNodeNames };
}
export function extractLastExecutedNodeCredentialData(
runData: IRun,
): null | { credentialId: string; credentialType: string } {
const nodeCredentials = runData?.data?.executionData?.nodeExecutionStack?.[0]?.node?.credentials;
if (!nodeCredentials) return null;
const credentialType = Object.keys(nodeCredentials)[0] ?? null;
if (!credentialType) return null;
const { id } = nodeCredentials[credentialType];
if (!id) return null;
return { credentialId: id, credentialType };
}

View file

@ -3,11 +3,12 @@ import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
import { STICKY_NODE_TYPE } from '@/Constants';
import { ApplicationError } from '@/errors';
import type { IRunData } from '@/Interfaces';
import type { IRun, IRunData } from '@/Interfaces';
import { NodeConnectionType, type IWorkflowBase } from '@/Interfaces';
import * as nodeHelpers from '@/NodeHelpers';
import {
ANONYMIZATION_CHARACTER as CHAR,
extractLastExecutedNodeCredentialData,
generateNodesGraph,
getDomainBase,
getDomainPath,
@ -885,6 +886,50 @@ describe('generateNodesGraph', () => {
});
});
describe('extractLastExecutedNodeCredentialData', () => {
const cases: Array<[string, IRun]> = [
['no data', mock<IRun>({ data: {} })],
['no executionData', mock<IRun>({ data: { executionData: undefined } })],
[
'no nodeExecutionStack',
mock<IRun>({ data: { executionData: { nodeExecutionStack: undefined } } }),
],
[
'no node',
mock<IRun>({
data: { executionData: { nodeExecutionStack: [{ node: undefined }] } },
}),
],
[
'no credentials',
mock<IRun>({
data: { executionData: { nodeExecutionStack: [{ node: { credentials: undefined } }] } },
}),
],
];
test.each(cases)(
'should return credentialId and credentialsType with null if %s',
(_, runData) => {
expect(extractLastExecutedNodeCredentialData(runData)).toBeNull();
},
);
it('should return correct credentialId and credentialsType when last node executed has credential', () => {
const runData = mock<IRun>({
data: {
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
},
});
expect(extractLastExecutedNodeCredentialData(runData)).toMatchObject(
expect.objectContaining({ credentialId: 'nhu-l8E4hX', credentialType: 'openAiApi' }),
);
});
});
function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();