chore(editor): Add telemetry for canvas experiment (#18871)

This commit is contained in:
Suguru Inoue
2025-08-29 12:56:25 +02:00
committed by GitHub
parent e663858c9d
commit de87f67c57
37 changed files with 330 additions and 95 deletions

View File

@@ -273,7 +273,7 @@ describe('NodeErrorView.vue', () => {
await userEvent.click(button);
expect(window.open).not.toHaveBeenCalled();
expect(mockNDVStore.activeNodeName).toBe('ErrorCode');
expect(mockNDVStore.setActiveNodeName).toHaveBeenCalledWith('ErrorCode', expect.any(String));
});
it('sets active node name when error has no workflow/execution IDs', async () => {
@@ -293,7 +293,7 @@ describe('NodeErrorView.vue', () => {
await userEvent.click(button);
expect(window.open).not.toHaveBeenCalled();
expect(mockNDVStore.activeNodeName).toBe('ErrorCode');
expect(mockNDVStore.setActiveNodeName).toHaveBeenCalledWith('ErrorCode', expect.any(String));
});
});
});

View File

@@ -416,7 +416,7 @@ const onOpenErrorNodeDetailClick = () => {
});
window.open(link.href, '_blank');
} else {
ndvStore.activeNodeName = props.error.node.name;
ndvStore.setActiveNodeName(props.error.node.name, 'other');
}
};

View File

@@ -2,7 +2,7 @@
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { N8nText, N8nInput, N8nResizeWrapper, N8nInfoTip } from '@n8n/design-system';
import { computed, nextTick, ref, watch, toRef } from 'vue';
import { computed, nextTick, ref, watch, toRef, useTemplateRef } from 'vue';
import { useI18n } from '@n8n/i18n';
import {
formatAsExpression,
@@ -28,7 +28,7 @@ import { htmlEditorEventBus } from '@/event-bus';
import { hasFocusOnInput, isFocusableEl } from '@/utils/typesUtils';
import type { INodeUi, ResizeData, TargetNodeParameterContext } from '@/Interface';
import { useTelemetry } from '@/composables/useTelemetry';
import { useThrottleFn } from '@vueuse/core';
import { useActiveElement, useThrottleFn } from '@vueuse/core';
import { useExecutionData } from '@/composables/useExecutionData';
import { useWorkflowsStore } from '@/stores/workflows.store';
import ExperimentalNodeDetailsDrawer from '@/components/canvas/experimental/components/ExperimentalNodeDetailsDrawer.vue';
@@ -36,6 +36,7 @@ import { useExperimentalNdvStore } from '@/components/canvas/experimental/experi
import { useNDVStore } from '@/stores/ndv.store';
import { useVueFlow } from '@vue-flow/core';
import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
defineOptions({ name: 'FocusPanel' });
@@ -51,6 +52,7 @@ const emit = defineEmits<{
// ESLint: false positive
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
const inputField = ref<InstanceType<typeof N8nInput> | HTMLElement>();
const wrapperRef = useTemplateRef('wrapper');
const locale = useI18n();
const nodeHelpers = useNodeHelpers();
@@ -64,13 +66,11 @@ const experimentalNdvStore = useExperimentalNdvStore();
const ndvStore = useNDVStore();
const deviceSupport = useDeviceSupport();
const vueFlow = useVueFlow(workflowsStore.workflowId);
const activeElement = useActiveElement();
const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]);
const resolvedParameter = computed(() =>
focusedNodeParameter.value && focusPanelStore.isRichParameter(focusedNodeParameter.value)
? focusedNodeParameter.value
: undefined,
);
useTelemetryContext({ view_shown: 'focus_panel' });
const resolvedParameter = computed(() => focusPanelStore.resolvedParameter);
const inputValue = ref<string>('');
@@ -288,6 +288,12 @@ function optionSelected(command: string) {
function closeFocusPanel() {
if (experimentalNdvStore.isNdvInFocusPanelEnabled && resolvedParameter.value) {
focusPanelStore.unsetParameters();
telemetry.track('User removed focused param', {
source: 'closeIcon',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
return;
}
@@ -365,6 +371,24 @@ watch(
{ immediate: true },
);
watch(activeElement, (active) => {
if (!node.value || !active || !wrapperRef.value?.contains(active)) {
return;
}
const path = active.closest('.parameter-input')?.getAttribute('data-parameter-path');
if (!path) {
return;
}
telemetry.track('User focused focus panel', {
node_id: node.value.id,
node_type: node.value.type,
parameter_path: path,
});
});
function onResize(event: ResizeData) {
focusPanelStore.updateWidth(event.width);
}
@@ -373,13 +397,13 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
function onOpenNdv() {
if (node.value) {
ndvStore.setActiveNodeName(node.value.name);
ndvStore.setActiveNodeName(node.value.name, 'focus_panel');
}
}
</script>
<template>
<div v-if="focusPanelActive" :class="$style.wrapper" @keydown.stop>
<div v-if="focusPanelActive" ref="wrapper" :class="$style.wrapper" @keydown.stop>
<N8nResizeWrapper
:width="focusPanelWidth"
:supported-directions="['left']"

View File

@@ -85,10 +85,13 @@ function nodeTypeSelected(value: NodeTypeSelectedPayload[]) {
function toggleFocusPanel() {
focusPanelStore.toggleFocusPanel();
telemetry.track(`User ${focusPanelStore.focusPanelActive ? 'opened' : 'closed'} focus panel`, {
source: 'canvasButton',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
telemetry.track(
focusPanelStore.focusPanelActive ? 'User opened focus panel' : 'User closed focus panel',
{
source: 'canvasButton',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
},
);
}
function onAskAssistantButtonClick() {

View File

@@ -48,7 +48,7 @@ async function createPiniaStore(isActiveNode: boolean) {
workflowsStore.nodeMetadata[node.name] = { pristine: true };
if (isActiveNode) {
ndvStore.activeNodeName = node.name;
ndvStore.setActiveNodeName(node.name, 'other');
}
await useSettingsStore().getSettings();
@@ -173,7 +173,7 @@ describe('NodeDetailsView', () => {
pinia,
});
ndvStore.activeNodeName = nodeName;
ndvStore.setActiveNodeName(nodeName, 'other');
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
await waitFor(() => expect(queryByTestId('ndv-modal')).toBeInTheDocument());

View File

@@ -39,6 +39,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@n8n/i18n';
import { storeToRefs } from 'pinia';
import { useStyles } from '@/composables/useStyles';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const emit = defineEmits<{
saveKeyboardShortcut: [event: KeyboardEvent];
@@ -75,6 +76,7 @@ const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext({ view_shown: 'ndv' });
const i18n = useI18n();
const message = useMessage();
const { APP_Z_INDEXES } = useStyles();
@@ -371,7 +373,7 @@ const onInputTableMounted = (e: { avgRowHeight: number }) => {
};
const onWorkflowActivate = () => {
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
setTimeout(() => {
void workflowActivate.activateCurrentWorkflow('ndv');
}, 1000);
@@ -517,7 +519,7 @@ const close = async () => {
workflow_id: workflowsStore.workflowId,
});
triggerWaitingWarningEnabled.value = false;
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
ndvStore.resetNDVPushRef();
};
@@ -649,6 +651,7 @@ watch(
data_pinning_tooltip_presented: pinDataDiscoveryTooltipVisible.value,
input_displayed_row_height_avg: avgInputRowHeight.value,
output_displayed_row_height_avg: avgOutputRowHeight.value,
source: telemetryContext.ndv_source?.value ?? 'other',
});
}
}, 2000); // wait for RunData to mount and present pindata discovery tooltip

View File

@@ -51,7 +51,11 @@ async function createPiniaStore(
{},
);
ndvStore.activeNodeName = activeNodeName;
if (activeNodeName) {
ndvStore.setActiveNodeName(activeNodeName, 'other');
} else {
ndvStore.unsetActiveNodeName();
}
await useSettingsStore().getSettings();
await useUsersStore().loginWithCookie();
@@ -201,7 +205,7 @@ describe('NodeDetailsViewV2', () => {
pinia,
});
ndvStore.activeNodeName = 'Manual Trigger';
ndvStore.setActiveNodeName('Manual Trigger', 'other');
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());

View File

@@ -44,6 +44,7 @@ import InputPanel from './InputPanel.vue';
import OutputPanel from './OutputPanel.vue';
import PanelDragButtonV2 from './PanelDragButtonV2.vue';
import TriggerPanel from './TriggerPanel.vue';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const emit = defineEmits<{
saveKeyboardShortcut: [event: KeyboardEvent];
@@ -77,6 +78,7 @@ const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext({ view_shown: 'ndv' });
const i18n = useI18n();
const message = useMessage();
const { APP_Z_INDEXES } = useStyles();
@@ -370,7 +372,7 @@ const onInputTableMounted = (e: { avgRowHeight: number }) => {
};
const onWorkflowActivate = () => {
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
setTimeout(() => {
void workflowActivate.activateCurrentWorkflow('ndv');
}, 1000);
@@ -491,7 +493,7 @@ const close = async () => {
workflow_id: workflowsStore.workflowId,
});
triggerWaitingWarningEnabled.value = false;
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
ndvStore.resetNDVPushRef();
};
@@ -625,6 +627,7 @@ watch(
data_pinning_tooltip_presented: pinDataDiscoveryTooltipVisible.value,
input_displayed_row_height_avg: avgInputRowHeight.value,
output_displayed_row_height_avg: avgOutputRowHeight.value,
source: telemetryContext.ndv_source?.value ?? 'other',
});
}
}, 2000); // wait for RunData to mount and present pindata discovery tooltip

View File

@@ -316,7 +316,7 @@ describe('NodeExecuteButton', () => {
await userEvent.click(getByRole('button'));
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null);
expect(ndvStore.unsetActiveNodeName).toHaveBeenCalled();
expect(workflowsStore.chatPartialExecutionDestinationNode).toBe(node.name);
expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat');
});
@@ -330,7 +330,7 @@ describe('NodeExecuteButton', () => {
await userEvent.click(getByRole('button'));
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null);
expect(ndvStore.unsetActiveNodeName).toHaveBeenCalled();
expect(workflowsStore.chatPartialExecutionDestinationNode).toBe(node.name);
expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat');
});

View File

@@ -332,7 +332,7 @@ async function onClick() {
}
if (isChatNode.value || (isChatChild.value && ndvStore.isInputPanelEmpty)) {
ndvStore.setActiveNodeName(null);
ndvStore.unsetActiveNodeName();
workflowsStore.chatPartialExecutionDestinationNode = props.nodeName;
nodeViewEventBus.emit('openChat');
} else if (isListeningForEvents.value) {

View File

@@ -86,6 +86,7 @@ import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/exp
import CssEditor from './CssEditor/CssEditor.vue';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import ExperimentalEmbeddedNdvMapper from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvMapper.vue';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
type Picker = { $emit: (arg0: string, arg1: Date) => void };
@@ -150,6 +151,7 @@ const settingsStore = useSettingsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore();
const experimentalNdvStore = useExperimentalNdvStore();
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
@@ -1034,10 +1036,19 @@ async function optionSelected(command: string) {
case 'focus':
nodeSettingsParameters.handleFocus(node.value, props.path, props.parameter);
telemetry.track('User opened focus panel', {
source: 'parameterButton',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
if (experimentalNdvStore.isNdvInFocusPanelEnabled) {
telemetry.track('User added focused param', {
source: 'parameterButton',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
} else {
telemetry.track('User opened focus panel', {
source: 'parameterButton',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
}
return;
}
@@ -1241,6 +1252,7 @@ onClickOutside(wrapper, onBlur);
},
]"
:style="parameterInputWrapperStyle"
:data-parameter-path="path"
>
<ResourceLocator
v-if="parameter.type === 'resourceLocator'"

View File

@@ -15,6 +15,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
import TextWithHighlights from './TextWithHighlights.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useElementSize } from '@vueuse/core';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const LazyRunDataJsonActions = defineAsyncComponent(
async () => await import('@/components/RunDataJsonActions.vue'),
@@ -44,6 +45,7 @@ const ndvStore = useNDVStore();
const externalHooks = useExternalHooks();
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext();
const selectedJsonPath = ref(nonExistingJsonPath);
const draggingPath = ref<null | string>(null);
@@ -103,6 +105,7 @@ const onDragEnd = (el: HTMLElement) => {
src_view: 'json',
src_element: el,
success: false,
view_shown: telemetryContext.view_shown,
...mappingTelemetry,
};

View File

@@ -115,7 +115,7 @@ async function createPiniaWithActiveNode() {
},
};
ndvStore.activeNodeName = node.name;
ndvStore.setActiveNodeName(node.name, 'other');
return {
pinia,

View File

@@ -17,6 +17,7 @@ import { N8nIconButton, N8nInfoTip, N8nTooltip, N8nTree } from '@n8n/design-syst
import { storeToRefs } from 'pinia';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { I18nT } from 'vue-i18n';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const MAX_COLUMNS_LIMIT = 40;
@@ -78,6 +79,7 @@ const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext();
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
const {
@@ -323,6 +325,7 @@ function onDragEnd(column: string, src: string, depth = '0') {
src_view: 'table',
src_element: src,
success: false,
view_shown: telemetryContext.view_shown,
...mappingTelemetry,
};

View File

@@ -183,7 +183,7 @@ function onLabelChange(value: string) {
function setupNode(options: MessageEventBusDestinationOptions) {
workflowsStore.removeNode(node.value);
ndvStore.activeNodeName = options.id ?? 'thisshouldnothappen';
ndvStore.setActiveNodeName(options.id ?? 'thisshouldnothappen', 'other');
workflowsStore.addNode(destinationToFakeINodeUi(options));
nodeParameters.value = options as INodeParameters;
logStreamingStore.items[destination.id!].destination = options;
@@ -294,7 +294,7 @@ function onModalClose() {
logStreamingStore.removeDestination(nodeParameters.value.id.toString());
}
}
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
callEventBus('closing', destination.id);
uiStore.stateIsDirty = false;
}

View File

@@ -350,7 +350,7 @@ const onLinkClick = (e: MouseEvent) => {
pane: 'input',
type: 'open-executions-log',
});
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
void router.push({
name: VIEWS.EXECUTIONS,
});

View File

@@ -154,7 +154,7 @@ async function setupStore() {
}),
]);
workflowsStore.workflow = workflow as IWorkflowDb;
ndvStore.activeNodeName = 'Test Node Name';
ndvStore.setActiveNodeName('Test Node Name', 'other');
return pinia;
}

View File

@@ -46,6 +46,7 @@ import pick from 'lodash/pick';
import { DateTime } from 'luxon';
import NodeExecuteButton from './NodeExecuteButton.vue';
import { I18nT } from 'vue-i18n';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
type Props = {
nodes?: IConnectedNode[];
@@ -72,6 +73,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext();
const i18n = useI18n();
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
@@ -416,6 +418,7 @@ const onDragEnd = (el: HTMLElement) => {
src_has_credential: hasCredential,
src_element: el,
success: false,
view_shown: telemetryContext.view_shown,
...mappingTelemetry,
};

View File

@@ -17,6 +17,10 @@ const { nodeId, isReadOnly, subTitle, isEmbeddedInCanvas } = defineProps<{
defineSlots<{ actions?: {} }>();
const emit = defineEmits<{
dblclickHeader: [MouseEvent];
}>();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const { renameNode } = useCanvasOperations();
@@ -37,12 +41,6 @@ function handleValueChanged(parameterData: IUpdateInformation) {
}
}
function handleDoubleClickHeader() {
if (activeNode.value) {
ndvStore.setActiveNodeName(activeNode.value.name);
}
}
function handleCaptureWheelEvent(event: WheelEvent) {
if (event.ctrlKey) {
// If the event is pinch, let it propagate and zoom canvas
@@ -86,7 +84,7 @@ function handleCaptureWheelEvent(event: WheelEvent) {
hide-sub-connections
@value-changed="handleValueChanged"
@capture-wheel-body="handleCaptureWheelEvent"
@dblclick-header="handleDoubleClickHeader"
@dblclick-header="emit('dblclickHeader', $event)"
>
<template #actions>
<slot name="actions" />

View File

@@ -15,6 +15,7 @@ import { getNodeSubTitleText } from '@/components/canvas/experimental/experiment
import ExperimentalEmbeddedNdvActions from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue';
import { useCanvas } from '@/composables/useCanvas';
import { useExpressionResolveCtx } from '@/components/canvas/experimental/composables/useExpressionResolveCtx';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const { nodeId, isReadOnly } = defineProps<{
nodeId: string;
@@ -27,6 +28,9 @@ const experimentalNdvStore = useExperimentalNdvStore();
const isExpanded = computed(() => !experimentalNdvStore.collapsedNodes[nodeId]);
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
useTelemetryContext({ view_shown: 'zoomed_view' });
const node = computed(() => workflowsStore.getNodeById(nodeId) ?? null);
const nodeType = computed(() => {
if (node.value) {
@@ -65,7 +69,7 @@ function handleToggleExpand() {
function handleOpenNdv() {
if (node.value) {
ndvStore.setActiveNodeName(node.value.name);
ndvStore.setActiveNodeName(node.value.name, 'canvas_zoomed_view');
}
}
@@ -98,6 +102,7 @@ watchOnce(isVisible, (visible) => {
:sub-title="subTitle"
:input-node-name="expressionResolveCtx?.inputNode?.name"
is-embedded-in-canvas
@dblclick-header="handleOpenNdv"
>
<template #actions>
<ExperimentalEmbeddedNdvActions