fix(editor): Reduce telemetry events for builder workflow changes (no-changelog) (#19310)

This commit is contained in:
Mutasem Aldmour
2025-09-09 10:57:50 +02:00
committed by GitHub
parent e87344d97d
commit 99293ea400
7 changed files with 471 additions and 62 deletions

View File

@@ -1355,4 +1355,203 @@ describe('AskAssistantBuild', () => {
expect(chatInput).not.toHaveAttribute('disabled');
});
});
it('should handle multiple canvas generations correctly', async () => {
const originalWorkflow = {
nodes: [],
connections: {},
};
builderStore.getWorkflowSnapshot.mockReturnValue(JSON.stringify(originalWorkflow));
const intermediaryWorkflow = {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
};
const finalWorkflow = {
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
{
id: 'node2',
name: 'HttpReuqest',
type: 'n8n-nodes-base.httpRequest',
position: [0, 0],
typeVersion: 1,
parameters: {},
} as INodeUi,
],
connections: {},
};
workflowsStore.$patch({
workflow: originalWorkflow,
});
renderComponent();
builderStore.$patch({ streaming: true });
await flushPromises();
// Trigger the watcher by updating workflowMessages
builderStore.workflowMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: JSON.stringify(intermediaryWorkflow),
},
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: JSON.stringify(finalWorkflow),
},
];
const toolCallId1_1 = '1234';
const toolCallId1_2 = '3333';
const toolCallId2 = '4567';
const toolCallId3 = '8901';
builderStore.toolMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'tool' as const,
toolName: 'first-tool',
toolCallId: toolCallId1_1,
status: 'completed',
updates: [],
},
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'tool' as const,
toolName: 'first-tool',
toolCallId: toolCallId1_2,
status: 'completed',
updates: [],
},
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'tool' as const,
toolName: 'second-tool',
toolCallId: toolCallId2,
status: 'running',
updates: [],
},
];
builderStore.$patch({ streaming: false });
await flushPromises();
expect(trackMock).toHaveBeenCalledOnce();
expect(trackMock).toHaveBeenCalledWith('Workflow modified by builder', {
end_workflow_json: JSON.stringify(finalWorkflow),
session_id: 'app_session_id',
start_workflow_json: JSON.stringify(originalWorkflow),
// first-tool is added once, even though it completed twice
// second-tool is ignored because it's running
tools_called: ['first-tool'],
workflow_id: 'abc123',
});
trackMock.mockClear();
builderStore.$patch({ streaming: true });
await flushPromises();
// second run after new messages
const updatedWorkflow2 = {
...finalWorkflow,
nodes: [
...finalWorkflow.nodes,
{
id: 'node1',
name: 'Updated',
type: 'n8n-nodes-base.updated',
position: [0, 0],
typeVersion: 1,
parameters: {},
},
],
};
builderStore.workflowMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'workflow-updated' as const,
codeSnippet: JSON.stringify(updatedWorkflow2),
},
];
builderStore.toolMessages = [
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'tool' as const,
toolName: 'first-tool',
toolCallId: toolCallId1_1,
status: 'completed',
updates: [],
},
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'tool' as const,
toolName: 'first-tool',
toolCallId: toolCallId1_2,
status: 'completed',
updates: [],
},
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'tool' as const,
toolName: 'second-tool',
toolCallId: toolCallId2,
status: 'completed',
updates: [],
},
{
id: faker.string.uuid(),
role: 'assistant' as const,
type: 'tool' as const,
toolName: 'third-tool',
toolCallId: toolCallId3,
status: 'completed',
updates: [],
},
];
builderStore.$patch({ streaming: false });
await flushPromises();
expect(trackMock).toHaveBeenCalledOnce();
expect(trackMock).toHaveBeenCalledWith('Workflow modified by builder', {
end_workflow_json: JSON.stringify(updatedWorkflow2),
session_id: 'app_session_id',
start_workflow_json: JSON.stringify(originalWorkflow),
// first-tool is ignored, because it was tracked in first run (same tool call id)
tools_called: ['second-tool', 'third-tool'],
workflow_id: 'abc123',
});
});
});

View File

@@ -33,6 +33,7 @@ const processedWorkflowUpdates = ref(new Set<string>());
const trackedTools = ref(new Set<string>());
const planStatus = ref<'pending' | 'approved' | 'rejected'>();
const assistantChatRef = ref<InstanceType<typeof AskAssistantChat> | null>(null);
const workflowUpdated = ref<{ start: string; end: string } | undefined>();
const user = computed(() => ({
firstName: usersStore.currentUser?.firstName ?? '',
@@ -60,6 +61,7 @@ function onNewWorkflow() {
builderStore.resetBuilderChat();
processedWorkflowUpdates.value.clear();
trackedTools.value.clear();
workflowUpdated.value = undefined;
}
function onFeedback(feedback: RatingFeedback) {
@@ -113,6 +115,33 @@ function shouldShowPlanControls(message: NodesPlanMessageType) {
return planMessageIndex === builderStore.chatMessages.length - 1;
}
function dedupeToolNames(toolNames: string[]): string[] {
return [...new Set(toolNames)];
}
function trackWorkflowModifications() {
if (workflowUpdated.value) {
// Track tool usage for telemetry
const newToolMessages = builderStore.toolMessages.filter(
(toolMsg) =>
toolMsg.status !== 'running' &&
toolMsg.toolCallId &&
!trackedTools.value.has(toolMsg.toolCallId),
);
newToolMessages.forEach((toolMsg) => trackedTools.value.add(toolMsg.toolCallId ?? ''));
telemetry.track('Workflow modified by builder', {
tools_called: dedupeToolNames(newToolMessages.map((toolMsg) => toolMsg.toolName)),
session_id: builderStore.trackingSessionId,
start_workflow_json: workflowUpdated.value.start,
end_workflow_json: workflowUpdated.value.end,
workflow_id: workflowsStore.workflowId,
});
workflowUpdated.value = undefined;
}
}
// Watch for workflow updates and apply them
watch(
() => builderStore.workflowMessages,
@@ -125,7 +154,8 @@ watch(
if (msg.id && isWorkflowUpdatedMessage(msg)) {
processedWorkflowUpdates.value.add(msg.id);
const currentWorkflowJson = builderStore.getWorkflowSnapshot();
const originalWorkflowJson =
workflowUpdated.value?.start ?? builderStore.getWorkflowSnapshot();
const result = builderStore.applyWorkflowUpdate(msg.codeSnippet);
if (result.success) {
@@ -135,25 +165,13 @@ watch(
tidyUp: true,
nodesIdsToTidyUp: result.newNodeIds,
regenerateIds: false,
trackEvents: false,
});
// Track tool usage for telemetry
const newToolMessages = builderStore.toolMessages.filter(
(toolMsg) =>
toolMsg.status !== 'running' &&
toolMsg.toolCallId &&
!trackedTools.value.has(toolMsg.toolCallId),
);
newToolMessages.forEach((toolMsg) => trackedTools.value.add(toolMsg.toolCallId ?? ''));
telemetry.track('Workflow modified by builder', {
tools_called: newToolMessages.map((toolMsg) => toolMsg.toolName),
session_id: builderStore.trackingSessionId,
start_workflow_json: currentWorkflowJson,
end_workflow_json: msg.codeSnippet,
workflow_id: workflowsStore.workflowId,
});
workflowUpdated.value = {
start: originalWorkflowJson,
end: msg.codeSnippet,
};
}
}
});
@@ -171,10 +189,14 @@ watch(
// we want to save the workflow
watch(
() => builderStore.streaming,
async () => {
async (isStreaming) => {
if (!isStreaming) {
trackWorkflowModifications();
}
if (
builderStore.initialGeneration &&
!builderStore.streaming &&
!isStreaming &&
workflowsStore.workflow.nodes.length > 0
) {
// Check if the generation completed successfully (no error or cancellation)

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import ContextMenu from '@/components/ContextMenu/ContextMenu.vue';
import type { CanvasLayoutEvent, CanvasLayoutSource } from '@/composables/useCanvasLayout';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useCanvasLayout } from '@/composables/useCanvasLayout';
import { useCanvasNodeHover } from '@/composables/useCanvasNodeHover';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
@@ -103,7 +103,7 @@ const emit = defineEmits<{
'save:workflow': [];
'create:workflow': [];
'drag-and-drop': [position: XYPosition, event: DragEvent];
'tidy-up': [CanvasLayoutEvent];
'tidy-up': [CanvasLayoutEvent, { trackEvents?: boolean }];
'toggle:focus-panel': [];
'viewport:change': [viewport: ViewportTransform, dimensions: Dimensions];
'selection:end': [position: XYPosition];
@@ -757,7 +757,7 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[])
}
}
async function onTidyUp(payload: { source: CanvasLayoutSource; nodeIdsFilter?: string[] }) {
async function onTidyUp(payload: CanvasEventBusEvents['tidyUp']) {
if (payload.nodeIdsFilter && payload.nodeIdsFilter.length > 0) {
clearSelectedNodes();
addSelectedNodes(payload.nodeIdsFilter.map(findNode).filter(isPresent));
@@ -766,7 +766,7 @@ async function onTidyUp(payload: { source: CanvasLayoutSource; nodeIdsFilter?: s
const target = applyOnSelection ? 'selection' : 'all';
const result = layout(target);
emit('tidy-up', { result, target, source: payload.source });
emit('tidy-up', { result, target, source: payload.source }, { trackEvents: payload.trackEvents });
if (!applyOnSelection) {
await nextTick();