Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,10 @@ describe('TelemetryEventRelay', () => {
num_tags: 0,
public_api: false,
sharing_role: undefined,
meta: undefined, // workflow.meta is undefined in mock
workflow_edited_no_pos: false,
credential_edited: false,
ai_builder_assisted: false,
});
});

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/events/maps/relay.event-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export type RelayEventMap = {
user: UserLike;
workflow: IWorkflowDb;
publicApi: boolean;
previousWorkflow?: IWorkflowDb;
aiBuilderAssisted?: boolean;
};

'workflow-activated': {
Expand Down
39 changes: 32 additions & 7 deletions packages/cli/src/events/relays/telemetry.event-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { snakeCase } from 'change-case';
import { BinaryDataConfig, InstanceSettings } from 'n8n-core';
import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow';
import { TelemetryHelpers } from 'n8n-workflow';
import { hasCredentialChanges, hasNonPositionalChanges, TelemetryHelpers } from 'n8n-workflow';
import os from 'node:os';
import { get as pslGet } from 'psl';

Expand Down Expand Up @@ -621,7 +621,13 @@ export class TelemetryEventRelay extends EventRelay {
});
}

private async workflowSaved({ user, workflow, publicApi }: RelayEventMap['workflow-saved']) {
private async workflowSaved({
user,
workflow,
publicApi,
previousWorkflow,
aiBuilderAssisted,
}: RelayEventMap['workflow-saved']) {
const isCloudDeployment = this.globalConfig.deployment.type === 'cloud';

const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, {
Expand Down Expand Up @@ -654,6 +660,18 @@ export class TelemetryEventRelay extends EventRelay {
(note) => note.overlapping,
).length;

let workflowEditedNoPos = false;
let credentialEdited = false;
if (previousWorkflow) {
workflowEditedNoPos = hasNonPositionalChanges(
previousWorkflow.nodes,
workflow.nodes,
previousWorkflow.connections,
workflow.connections,
);
credentialEdited = hasCredentialChanges(previousWorkflow.nodes, workflow.nodes);
}

this.telemetry.track('User saved workflow', {
user_id: user.id,
workflow_id: workflow.id,
Expand All @@ -665,6 +683,9 @@ export class TelemetryEventRelay extends EventRelay {
public_api: publicApi,
sharing_role: userRole,
meta: JSON.stringify(workflow.meta),
workflow_edited_no_pos: workflowEditedNoPos,
credential_edited: credentialEdited,
ai_builder_assisted: aiBuilderAssisted ?? false,
});
}

Expand Down Expand Up @@ -813,13 +834,17 @@ export class TelemetryEventRelay extends EventRelay {
manualExecEventProperties.is_managed = credential.isManaged;
}
}
const destinationNodeName = runData.data.startData?.destinationNode.nodeName;
const telemetryPayload: ITelemetryTrackProperties = {
...manualExecEventProperties,
node_type: TelemetryHelpers.getNodeTypeForName(
workflow,
runData.data.startData?.destinationNode.nodeName,
)?.type,
node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode.nodeName],
node_type: TelemetryHelpers.getNodeTypeForName(workflow, destinationNodeName)?.type,
node_id: nodeGraphResult.nameIndices[destinationNodeName],
node_role: TelemetryHelpers.getNodeRole(
destinationNodeName,
workflow.connections,
this.nodeTypes,
workflow.nodes,
),
};

this.telemetry.track('Manual node exec finished', telemetryPayload);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/workflows/workflow.request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export declare namespace WorkflowRequest {
projectId: string;
parentFolderId?: string;
uiContext?: string;
aiBuilderAssisted?: boolean;
}>;

// TODO: Use a discriminator when CAT-1809 lands
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/workflows/workflow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export class WorkflowService {
forceSave?: boolean;
publicApi?: boolean;
publishIfActive?: boolean;
aiBuilderAssisted?: boolean;
} = {},
): Promise<WorkflowEntity> {
const {
Expand All @@ -234,6 +235,7 @@ export class WorkflowService {
forceSave = false,
publicApi = false,
publishIfActive = false,
aiBuilderAssisted = false,
} = options;
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
Expand Down Expand Up @@ -414,6 +416,8 @@ export class WorkflowService {
user,
workflow: updatedWorkflow,
publicApi,
previousWorkflow: workflow,
aiBuilderAssisted,
});

// Activate workflow if requested, or
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/workflows/workflows.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ export class WorkflowsController {
const forceSave = req.query.forceSave === 'true';

let updateData = new WorkflowEntity();
const { tags, parentFolderId, ...rest } = req.body;
const { tags, parentFolderId, aiBuilderAssisted, ...rest } = req.body;

// TODO: Add zod validation for entire `rest` object before assigning to `updateData`
if (
Expand All @@ -445,6 +445,7 @@ export class WorkflowsController {
tagIds: tags,
parentFolderId,
forceSave: isSharingEnabled ? forceSave : true,
aiBuilderAssisted,
});

const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface WorkflowDataUpdate {
meta?: WorkflowMetadata;
parentFolderId?: string;
uiContext?: string;
aiBuilderAssisted?: boolean;
}

export interface WorkflowDataCreate extends WorkflowDataUpdate {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ vi.mock('@/app/stores/workflows.store', () => {
nodesIssuesExist: false,
executionWaitingForWebhook: false,
workflowObject: { id: '123' } as Workflow,
workflowValidationIssues: [],
workflow: {
nodes: [],
id: '',
name: '',
active: false,
isArchived: false,
createdAt: '',
updatedAt: '',
connections: {},
versionId: '',
activeVersionId: null,
},
getNodeByName: vi
.fn()
.mockImplementation((name) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { useTemplatesStore } from '@/features/workflows/templates/templates.stor
import { useFocusPanelStore } from '@/app/stores/focusPanel.store';
import { injectWorkflowState, type WorkflowState } from '@/app/composables/useWorkflowState';
import { getResourcePermissions } from '@n8n/permissions';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';

export function useWorkflowSaving({
router,
Expand All @@ -47,6 +48,7 @@ export function useWorkflowSaving({
const telemetry = useTelemetry();
const nodeHelpers = useNodeHelpers();
const templatesStore = useTemplatesStore();
const builderStore = useBuilderStore();
const { getWorkflowDataToSave, checkConflictingWebhooks, getWorkflowProjectRole } =
useWorkflowHelpers();

Expand Down Expand Up @@ -220,6 +222,9 @@ export function useWorkflowSaving({

workflowDataRequest.versionId = workflowsStore.workflowVersionId;

// Check if AI Builder made edits since last save (consumes and resets the flag)
workflowDataRequest.aiBuilderAssisted = builderStore.consumeAiBuilderMadeEdits();

const deactivateReason = await getWorkflowDeactivationInfo(
currentWorkflow,
workflowDataRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,20 @@ describe('AI Builder store', () => {
builderStore.resetBuilderChat();
expect(builderStore.chatMessages).toEqual([]);
expect(builderStore.builderThinkingMessage).toBeUndefined();

// Verify last_user_message_id is reset (tracked via trackWorkflowBuilderJourney)
track.mockClear();
builderStore.trackWorkflowBuilderJourney('user_clicked_todo');
expect(track).toHaveBeenCalledWith('Workflow builder journey', {
workflow_id: 'test-workflow-id',
session_id: expect.any(String),
event_type: 'user_clicked_todo',
});
// Should NOT have last_user_message_id after reset
expect(track).not.toHaveBeenCalledWith(
'Workflow builder journey',
expect.objectContaining({ last_user_message_id: expect.any(String) }),
);
});

describe('isAIBuilderEnabled computed property', () => {
Expand Down Expand Up @@ -1709,6 +1723,151 @@ describe('AI Builder store', () => {
});
});

describe('trackWorkflowBuilderJourney', () => {
it('tracks event with workflow_id, session_id, and event_type (without last_user_message_id when no message sent)', () => {
const builderStore = useBuilderStore();

builderStore.trackWorkflowBuilderJourney('user_clicked_todo');

expect(track).toHaveBeenCalledWith('Workflow builder journey', {
workflow_id: 'test-workflow-id',
session_id: expect.any(String),
event_type: 'user_clicked_todo',
});
});

it('includes last_user_message_id after user sends a message', async () => {
const builderStore = useBuilderStore();

apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [{ type: 'message', role: 'assistant', text: 'Hello!' }],
sessionId: 'test-session',
});
onDone();
});

builderStore.sendChatMessage({ text: 'test' });
await vi.waitFor(() => expect(builderStore.streaming).toBe(false));

track.mockClear();
builderStore.trackWorkflowBuilderJourney('user_clicked_todo');

expect(track).toHaveBeenCalledWith('Workflow builder journey', {
workflow_id: 'test-workflow-id',
session_id: expect.any(String),
event_type: 'user_clicked_todo',
last_user_message_id: expect.any(String),
});
});

it('includes event_properties when provided', () => {
const builderStore = useBuilderStore();

builderStore.trackWorkflowBuilderJourney('user_clicked_todo', {
node_type: 'n8n-nodes-base.httpRequest',
type: 'parameters',
});

expect(track).toHaveBeenCalledWith('Workflow builder journey', {
workflow_id: 'test-workflow-id',
session_id: expect.any(String),
event_type: 'user_clicked_todo',
event_properties: {
node_type: 'n8n-nodes-base.httpRequest',
type: 'parameters',
},
});
});

it('omits event_properties when empty object provided', () => {
const builderStore = useBuilderStore();

builderStore.trackWorkflowBuilderJourney('field_focus_placeholder_in_ndv', {});

expect(track).toHaveBeenCalledWith('Workflow builder journey', {
workflow_id: 'test-workflow-id',
session_id: expect.any(String),
event_type: 'field_focus_placeholder_in_ndv',
});
});

it('omits event_properties when not provided', () => {
const builderStore = useBuilderStore();

builderStore.trackWorkflowBuilderJourney('no_placeholder_values_left');

expect(track).toHaveBeenCalledWith('Workflow builder journey', {
workflow_id: 'test-workflow-id',
session_id: expect.any(String),
event_type: 'no_placeholder_values_left',
});
});

it('includes both event_properties and last_user_message_id when both are present', async () => {
const builderStore = useBuilderStore();

apiSpy.mockImplementationOnce((_ctx, _payload, onMessage, onDone) => {
onMessage({
messages: [{ type: 'message', role: 'assistant', text: 'Hello!' }],
sessionId: 'test-session',
});
onDone();
});

builderStore.sendChatMessage({ text: 'test' });
await vi.waitFor(() => expect(builderStore.streaming).toBe(false));

track.mockClear();
builderStore.trackWorkflowBuilderJourney('user_clicked_todo', {
node_type: 'n8n-nodes-base.httpRequest',
type: 'parameters',
});

expect(track).toHaveBeenCalledWith('Workflow builder journey', {
workflow_id: 'test-workflow-id',
session_id: expect.any(String),
event_type: 'user_clicked_todo',
event_properties: {
node_type: 'n8n-nodes-base.httpRequest',
type: 'parameters',
},
last_user_message_id: expect.any(String),
});
});
});

describe('isPlaceholderValue', () => {
it('returns true for placeholder values', () => {
const builderStore = useBuilderStore();

expect(builderStore.isPlaceholderValue('<__PLACEHOLDER_VALUE__API endpoint URL__>')).toBe(
true,
);
expect(builderStore.isPlaceholderValue('<__PLACEHOLDER_VALUE__label__>')).toBe(true);
expect(builderStore.isPlaceholderValue('<__PLACEHOLDER_VALUE____>')).toBe(true);
});

it('returns false for non-placeholder strings', () => {
const builderStore = useBuilderStore();

expect(builderStore.isPlaceholderValue('regular string')).toBe(false);
expect(builderStore.isPlaceholderValue('')).toBe(false);
expect(builderStore.isPlaceholderValue('https://api.example.com')).toBe(false);
expect(builderStore.isPlaceholderValue('={{ $json.field }}')).toBe(false);
});

it('returns false for non-string values', () => {
const builderStore = useBuilderStore();

expect(builderStore.isPlaceholderValue(123)).toBe(false);
expect(builderStore.isPlaceholderValue(null)).toBe(false);
expect(builderStore.isPlaceholderValue(undefined)).toBe(false);
expect(builderStore.isPlaceholderValue({ key: 'value' })).toBe(false);
expect(builderStore.isPlaceholderValue(['array'])).toBe(false);
expect(builderStore.isPlaceholderValue(true)).toBe(false);
});
});
describe('abortStreaming telemetry', () => {
it('tracks end of response with aborted flag when aborting', () => {
const builderStore = useBuilderStore();
Expand Down
Loading
Loading