diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 879d6df0553..ab2dba4ce99 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -318,6 +318,14 @@ export type FrontendModuleSettings = { systemRolesEnabled: boolean; }; + /** + * Client settings for the OpenTelemetry module. + */ + otel?: { + /** Whether OpenTelemetry tracing is enabled on this instance. */ + enabled: boolean; + }; + /** * Client settings for the agents module. */ diff --git a/packages/cli/src/modules/otel/__tests__/otel-workflow-tracing.integration.test.ts b/packages/cli/src/modules/otel/__tests__/otel-workflow-tracing.integration.test.ts index baefd031934..4816c256fc6 100644 --- a/packages/cli/src/modules/otel/__tests__/otel-workflow-tracing.integration.test.ts +++ b/packages/cli/src/modules/otel/__tests__/otel-workflow-tracing.integration.test.ts @@ -16,6 +16,8 @@ import type { OtelTestProvider } from './support/otel-test-provider'; import type { WorkflowRunner } from '@/workflow-runner'; import type { ExecutionRepository } from '@n8n/db'; import { createTeamProject, createWorkflow } from '@n8n/backend-test-utils'; +import { NodeConnectionTypes } from 'n8n-workflow'; +import { v4 as uuid } from 'uuid'; let otel: OtelTestProvider; let workflowRunner: WorkflowRunner; @@ -94,3 +96,151 @@ describe('OTEL Workflow Tracing Integration', () => { expect(workflowSpan.spanContext().traceId).toBe(inboundTraceId); }); }); + +describe('Custom Telemetry Tags', () => { + const createWorkflowWithCustomTagsFixture = () => ({ + nodes: [ + { + parameters: {}, + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0] as [number, number], + id: uuid(), + name: 'Trigger', + }, + { + parameters: { category: 'doNothing' }, + type: 'n8n-nodes-base.debugHelper', + typeVersion: 1, + position: [200, 0] as [number, number], + id: uuid(), + name: 'DebugHelper', + customTelemetryTags: { + tag: [ + { key: 'environment', value: 'production' }, + { key: 'team', value: 'backend' }, + { key: 'env', value: '={{ $json.env }}' }, + ], + }, + }, + ], + connections: { + Trigger: { + main: [ + [ + { + node: 'DebugHelper', + type: NodeConnectionTypes.Main, + index: 0, + }, + ], + ], + }, + }, + pinData: {}, + }); + + const createMultiNodeCustomTagsFixture = () => ({ + nodes: [ + { + parameters: {}, + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0] as [number, number], + id: uuid(), + name: 'Trigger', + }, + { + parameters: { category: 'doNothing' }, + type: 'n8n-nodes-base.debugHelper', + typeVersion: 1, + position: [200, 0] as [number, number], + id: uuid(), + name: 'HelperA', + customTelemetryTags: { + tag: [{ key: 'service', value: 'auth' }], + }, + }, + { + parameters: { category: 'doNothing' }, + type: 'n8n-nodes-base.debugHelper', + typeVersion: 1, + position: [400, 0] as [number, number], + id: uuid(), + name: 'HelperB', + customTelemetryTags: { + tag: [{ key: 'tier', value: 'premium' }], + }, + }, + ], + connections: { + Trigger: { + main: [ + [ + { + node: 'HelperA', + type: NodeConnectionTypes.Main, + index: 0, + }, + { + node: 'HelperB', + type: NodeConnectionTypes.Main, + index: 0, + }, + ], + ], + }, + }, + pinData: {}, + }); + + it('should attach static custom telemetry tags as node span attributes', async () => { + const project = await createTeamProject(); + const workflow = await createWorkflow(createWorkflowWithCustomTagsFixture(), project); + const executionId = await executeWorkflow(workflowRunner, workflow, project.id); + await waitForExecution(executionRepository, executionId); + + const nodeSpan = otel + .getFinishedSpans() + .find((s) => s.name === 'node.execute' && s.attributes['n8n.node.name'] === 'DebugHelper')!; + + expect(nodeSpan).toBeDefined(); + expect(nodeSpan.attributes['n8n.node.custom.environment']).toBe('production'); + expect(nodeSpan.attributes['n8n.node.custom.team']).toBe('backend'); + }); + + it('should evaluate expression-based custom telemetry tags', async () => { + const project = await createTeamProject(); + const workflow = await createWorkflow(createWorkflowWithCustomTagsFixture(), project); + const executionId = await executeWorkflow(workflowRunner, workflow, project.id, { + triggerData: { env: 'staging' }, + }); + await waitForExecution(executionRepository, executionId); + + const nodeSpan = otel + .getFinishedSpans() + .find((s) => s.name === 'node.execute' && s.attributes['n8n.node.name'] === 'DebugHelper')!; + + expect(nodeSpan).toBeDefined(); + expect(nodeSpan.attributes['n8n.node.custom.env']).toBe('staging'); + expect(nodeSpan.attributes['n8n.node.custom.environment']).toBe('production'); + }); + + it('should attach custom tags to the correct node spans in a multi-node workflow', async () => { + const project = await createTeamProject(); + const workflow = await createWorkflow(createMultiNodeCustomTagsFixture(), project); + const executionId = await executeWorkflow(workflowRunner, workflow, project.id); + await waitForExecution(executionRepository, executionId); + + const spans = otel.getFinishedSpans().filter((s) => s.name === 'node.execute'); + const helperA = spans.find((s) => s.attributes['n8n.node.name'] === 'HelperA')!; + const helperB = spans.find((s) => s.attributes['n8n.node.name'] === 'HelperB')!; + + expect(helperA).toBeDefined(); + expect(helperB).toBeDefined(); + expect(helperA.attributes['n8n.node.custom.service']).toBe('auth'); + expect(helperA.attributes['n8n.node.custom.tier']).toBeUndefined(); + expect(helperB.attributes['n8n.node.custom.tier']).toBe('premium'); + expect(helperB.attributes['n8n.node.custom.service']).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts b/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts index d40e6111887..5e0f35c5bef 100644 --- a/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts +++ b/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts @@ -9,7 +9,7 @@ import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger. import { TestNodeWithTracing } from './test-node-with-tracing'; import { createRunExecutionData } from 'n8n-workflow'; -import type { INodeType, INodeTypeData, NodeLoadingDetails } from 'n8n-workflow'; +import type { IDataObject, INodeType, INodeTypeData, NodeLoadingDetails } from 'n8n-workflow'; import { readFileSync } from 'fs'; import path from 'path'; @@ -103,16 +103,17 @@ export async function executeWorkflow( mode?: 'webhook' | 'trigger' | 'manual' | 'retry'; retryOf?: string; tracingContext?: { traceparent: string; tracestate?: string }; + triggerData?: IDataObject; } = {}, ): Promise { - const { mode = 'webhook', retryOf, tracingContext } = options; + const { mode = 'webhook', retryOf, tracingContext, triggerData } = options; const triggerNode = workflow.nodes.find((n) => n.type === 'n8n-nodes-base.manualTrigger')!; const executionData = createRunExecutionData({ executionData: { nodeExecutionStack: [ { node: triggerNode, - data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] }, + data: { main: [[{ json: triggerData ?? {}, pairedItem: { item: 0 } }]] }, source: null, }, ], diff --git a/packages/cli/src/modules/otel/execution-level-tracer.ts b/packages/cli/src/modules/otel/execution-level-tracer.ts index effa6e2524d..4294c4997aa 100644 --- a/packages/cli/src/modules/otel/execution-level-tracer.ts +++ b/packages/cli/src/modules/otel/execution-level-tracer.ts @@ -248,7 +248,7 @@ function buildNodeEndAttributes(params: EndNodeParams): Record { const { OtelConfig } = await import('./otel.config'); const config = Container.get(OtelConfig); diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute-run-node.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute-run-node.test.ts index f97373834bc..1b2a993ce3c 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute-run-node.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute-run-node.test.ts @@ -29,12 +29,16 @@ jest.mock('@/errors/error-reporter', () => ({ }, })); -jest.mock('../node-execution-context', () => ({ - ExecuteContext: jest.fn().mockImplementation(() => ({ - hints: [], - })), - PollContext: jest.fn().mockImplementation(() => ({})), -})); +jest.mock('../node-execution-context', () => { + const actual = jest.requireActual('../node-execution-context'); + return { + ...actual, + ExecuteContext: jest.fn().mockImplementation(() => ({ + hints: [], + })), + PollContext: jest.fn().mockImplementation(() => ({})), + }; +}); jest.mock('../triggers-and-pollers', () => ({ TriggersAndPollers: jest.fn(), @@ -1300,4 +1304,295 @@ describe('WorkflowExecute.runNode - Real Implementation', () => { expect(result).toEqual({ data: undefined }); }); }); + + describe('customTelemetryTags', () => { + let getParameterValue: jest.Mock; + + beforeEach(() => { + getParameterValue = jest.fn(); + mockWorkflow.expression = { + getParameterValue, + } as unknown as Workflow['expression']; + + mockAdditionalData.webhookWaitingBaseUrl = 'https://n8n.local/webhook-waiting'; + mockAdditionalData.formWaitingBaseUrl = 'https://n8n.local/form-waiting'; + mockAdditionalData.variables = {}; + + mockNodeType.execute = jest.fn().mockResolvedValue([[{ json: {} }]]); + const mockContextInstance = { hints: [] }; + mockExecuteContext.mockImplementation(() => mockContextInstance as unknown as ExecuteContext); + }); + + const makeTelemetryExecutionData = (overrides: Partial = {}): IExecuteData => ({ + ...mockExecutionData, + data: { main: [[{ json: { env: 'prod' } }]] }, + source: null, + ...overrides, + }); + + const runNodeForTelemetry = async (executionData: IExecuteData) => { + await workflowExecute.runNode( + mockWorkflow, + executionData, + mockRunExecutionData, + 0, + mockAdditionalData, + 'manual', + ); + }; + + it('evaluates tag expressions and writes them into metadata.tracing', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [ + { key: 'env', value: '={{ $json.env }}' }, + { key: 'static', value: 'foo' }, + ], + }, + }; + getParameterValue.mockImplementation((value: string) => + value === '={{ $json.env }}' ? 'prod' : value, + ); + + const executionData = makeTelemetryExecutionData({ node }); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata?.tracing).toEqual({ env: 'prod', static: 'foo' }); + }); + + it('preserves existing tracing entries on key collision', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [{ key: 'env', value: 'user-set' }], + }, + }; + getParameterValue.mockReturnValue('user-set'); + + const executionData = makeTelemetryExecutionData({ + node, + metadata: { tracing: { env: 'node-authored' } }, + }); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata?.tracing).toEqual({ env: 'node-authored' }); + }); + + it('skips tags with empty or whitespace keys', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [ + { key: ' ', value: 'ignored' }, + { key: 'kept', value: 'value' }, + ], + }, + }; + getParameterValue.mockImplementation((value: string) => value); + + const executionData = makeTelemetryExecutionData({ node }); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata?.tracing).toEqual({ kept: 'value' }); + }); + + it('preserves string, number, and boolean evaluated values', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [ + { key: 'count', value: '={{ 42 }}' }, + { key: 'enabled', value: '={{ true }}' }, + ], + }, + }; + getParameterValue.mockImplementation((value: string) => (value === '={{ 42 }}' ? 42 : true)); + + const executionData = makeTelemetryExecutionData({ node }); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata?.tracing).toEqual({ count: 42, enabled: true }); + }); + + it('skips tags when expression evaluates to a non-primitive value', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [ + { key: 'obj', value: '={{ $json }}' }, + { key: 'ok', value: 'still-here' }, + ], + }, + }; + getParameterValue.mockImplementation((value: string) => + value === '={{ $json }}' ? { nested: 1 } : value, + ); + + const executionData = makeTelemetryExecutionData({ node }); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata?.tracing).toEqual({ ok: 'still-here' }); + }); + + it('ignores tags whose expression evaluates to null or undefined', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [ + { key: 'maybe', value: '={{ $json.missing }}' }, + { key: 'definitely', value: 'value' }, + ], + }, + }; + getParameterValue.mockImplementation((value: string) => + value === '={{ $json.missing }}' ? undefined : value, + ); + + const executionData = makeTelemetryExecutionData({ node }); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata?.tracing).toEqual({ definitely: 'value' }); + }); + + it('does not modify metadata when customTelemetryTags is absent', async () => { + const executionData = makeTelemetryExecutionData(); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata).toBeUndefined(); + }); + + it('continues evaluating remaining tags after one expression throws', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [ + { key: 'broken', value: '={{ $json.missing.deep }}' }, + { key: 'ok', value: 'value' }, + ], + }, + }; + getParameterValue.mockImplementation((value: string) => { + if (value === '={{ $json.missing.deep }}') throw new Error('boom'); + return value; + }); + + const executionData = makeTelemetryExecutionData({ node }); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata?.tracing).toEqual({ ok: 'value' }); + }); + + it('writes tracing for trigger nodes', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [{ key: 'env', value: 'prod' }], + }, + }; + getParameterValue.mockReturnValue('prod'); + + mockNodeType.trigger = jest.fn(); + mockNodeType.execute = undefined; + mockNodeType.poll = undefined; + mockNodeType.webhook = undefined; + + const mockTriggersAndPollersInstance = { + runTrigger: jest.fn().mockResolvedValue({ + manualTriggerResponse: Promise.resolve([[{ json: { triggered: 'data' } }]]), + }), + }; + mockContainer.get.mockImplementation((token) => { + if (token === TriggersAndPollers) return mockTriggersAndPollersInstance; + return { sentry: { backendDsn: '' } }; + }); + + const executionData = makeTelemetryExecutionData({ node }); + + await runNodeForTelemetry(executionData); + + expect(executionData.metadata?.tracing).toEqual({ env: 'prod' }); + }); + + it('writes tracing for poll nodes in non-manual mode', async () => { + const node: INode = { + ...mockNode, + customTelemetryTags: { + tag: [{ key: 'env', value: 'staging' }], + }, + }; + getParameterValue.mockReturnValue('staging'); + + mockNodeType.poll = jest.fn(); + mockNodeType.execute = undefined; + + const executionData = makeTelemetryExecutionData({ node }); + + await workflowExecute.runNode( + mockWorkflow, + executionData, + mockRunExecutionData, + 0, + mockAdditionalData, + 'trigger', + ); + + expect(executionData.metadata?.tracing).toEqual({ env: 'staging' }); + }); + + it('writes tracing when execution uses a custom operation instead of execute()', async () => { + const mockData = [[{ json: { result: 'custom operation result' } }]]; + const mockCustomOperation = jest.fn().mockResolvedValue(mockData); + + const customOpNode: INode = { + ...mockNode, + parameters: { + resource: 'testResource', + operation: 'testOperation', + }, + customTelemetryTags: { + tag: [{ key: 'env', value: '={{ $json.env }}' }], + }, + }; + + const customOpNodeType = { + ...mockNodeType, + customOperations: { + testResource: { + testOperation: mockCustomOperation, + }, + }, + execute: undefined, + }; + + mockWorkflow.nodeTypes.getByNameAndVersion = jest.fn().mockReturnValue(customOpNodeType); + + getParameterValue.mockImplementation((value: string) => + value === '={{ $json.env }}' ? 'prod' : value, + ); + + const executionData = makeTelemetryExecutionData({ node: customOpNode }); + + const result = await workflowExecute.runNode( + mockWorkflow, + executionData, + mockRunExecutionData, + 0, + mockAdditionalData, + 'manual', + ); + + expect(mockCustomOperation).toHaveBeenCalled(); + expect(result).toEqual({ data: mockData, hints: [] }); + expect(executionData.metadata?.tracing).toEqual({ env: 'prod' }); + }); + }); }); diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index a7e44cb1885..56e81681b14 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -65,7 +65,12 @@ import { assertExecutionDataExists } from '@/utils/assertions'; import { establishExecutionContext } from './execution-context'; import type { ExecutionLifecycleHooks } from './execution-lifecycle-hooks'; -import { ExecuteContext, PollContext, resolveSourceOverwrite } from './node-execution-context'; +import { + ExecuteContext, + getAdditionalKeys, + PollContext, + resolveSourceOverwrite, +} from './node-execution-context'; import { DirectedGraph, findStartNodes, @@ -1089,6 +1094,71 @@ export class WorkflowExecute { return { data, hints: context.hints }; } + private buildCustomTelemetryTracing( + workflow: Workflow, + node: INode, + additionalData: IWorkflowExecuteAdditionalData, + mode: WorkflowExecuteMode, + runExecutionData: IRunExecutionData, + runIndex: number, + connectionInputData: INodeExecutionData[], + executionData: IExecuteData, + ): NonNullable | undefined { + const tags = node.customTelemetryTags?.tag; + if (!tags?.length) return; + + const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData); + const tracing: NonNullable = {}; + + for (const { key, value } of tags) { + const trimmedKey = key?.trim(); + if (!trimmedKey) continue; + + try { + const evaluated = workflow.expression.getParameterValue( + value, + runExecutionData, + runIndex, + 0, + node.name, + connectionInputData, + mode, + additionalKeys, + executionData, + false, + {}, + ); + if (evaluated === undefined || evaluated === null) continue; + if ( + typeof evaluated !== 'string' && + typeof evaluated !== 'number' && + typeof evaluated !== 'boolean' + ) { + Logger.warn( + 'customTelemetryTags expression resolved to a non-primitive value; skipping', + { + nodeName: node.name, + tagKey: trimmedKey, + }, + ); + continue; + } + tracing[trimmedKey] = evaluated; + } catch (error) { + // failing to evaluate a tag expression is not a critical error and should not block the execution + Logger.warn('Failed to evaluate customTelemetryTags expression', { + nodeName: node.name, + tagKey: trimmedKey, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (Object.keys(tracing).length === 0) return; + + return tracing; + } + /** * Executes a poll node */ @@ -1235,6 +1305,24 @@ export class WorkflowExecute { inputData = this.handleExecuteOnce(node, inputData); + const tracingFromTags = this.buildCustomTelemetryTracing( + workflow, + node, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + executionData, + ); + + if (tracingFromTags !== undefined) { + executionData.metadata = { + ...(executionData.metadata ?? {}), + tracing: { ...tracingFromTags, ...(executionData.metadata?.tracing ?? {}) }, + }; + } + if (nodeType.execute || customOperation) { return await this.executeNode( workflow, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c8e74e825b8..a2afc55e8a6 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2169,6 +2169,12 @@ "nodeSettings.alwaysOutputData.description": "If active, will output a single, empty item when the output would have been empty. Use to prevent the workflow finishing on this node.", "nodeSettings.alwaysOutputData.displayName": "Always Output Data", "nodeSettings.clickOnTheQuestionMarkIcon": "Click the '?' icon to open this node on n8n.io", + "nodeSettings.customTelemetryTags.displayName": "Custom Telemetry Tags", + "nodeSettings.customTelemetryTags.description": "Add custom tags that will be attached to this node's OpenTelemetry spans. Values support expressions.", + "nodeSettings.customTelemetryTags.placeholder": "Add Tag", + "nodeSettings.customTelemetryTags.tag.displayName": "Tag", + "nodeSettings.customTelemetryTags.tag.key.displayName": "Key", + "nodeSettings.customTelemetryTags.tag.value.displayName": "Value", "nodeSettings.onError.description": "Action to take when the node execution fails", "nodeSettings.onError.displayName": "On Error", "nodeSettings.onError.options.continueRegularOutput.description": "Pass error message as item in regular output", diff --git a/packages/frontend/editor-ui/src/app/stores/settings.store.test.ts b/packages/frontend/editor-ui/src/app/stores/settings.store.test.ts index ff03fa6c4bb..2e3a233a1c6 100644 --- a/packages/frontend/editor-ui/src/app/stores/settings.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/settings.store.test.ts @@ -238,4 +238,45 @@ describe('settings.store', () => { }); }); }); + + describe('isOtelEnabled', () => { + it('should return false when otel module is not active', async () => { + getSettings.mockResolvedValueOnce({ + ...mockSettings, + activeModules: [], + }); + + const settingsStore = useSettingsStore(); + await settingsStore.getSettings(); + settingsStore.moduleSettings = { otel: { enabled: true } }; + + expect(settingsStore.isOtelEnabled).toBe(false); + }); + + it('should return false when otel module is active but not enabled in moduleSettings', async () => { + getSettings.mockResolvedValueOnce({ + ...mockSettings, + activeModules: ['otel'], + }); + + const settingsStore = useSettingsStore(); + await settingsStore.getSettings(); + settingsStore.moduleSettings = { otel: { enabled: false } }; + + expect(settingsStore.isOtelEnabled).toBe(false); + }); + + it('should return true when otel module is active and enabled', async () => { + getSettings.mockResolvedValueOnce({ + ...mockSettings, + activeModules: ['otel'], + }); + + const settingsStore = useSettingsStore(); + await settingsStore.getSettings(); + settingsStore.moduleSettings = { otel: { enabled: true } }; + + expect(settingsStore.isOtelEnabled).toBe(true); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/app/stores/settings.store.ts b/packages/frontend/editor-ui/src/app/stores/settings.store.ts index 1d12f095355..e5627018a14 100644 --- a/packages/frontend/editor-ui/src/app/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/settings.store.ts @@ -170,6 +170,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { () => isModuleActive('chat-hub') && moduleSettings.value['chat-hub']?.enabled !== false, ); + const isOtelEnabled = computed( + () => isModuleActive('otel') === true && moduleSettings.value.otel?.enabled === true, + ); + // Opt-in flag: the `node-tools-searcher` token must be listed in the backend // `N8N_AGENTS_MODULES` env var for this to evaluate true. const isAgentsNodeToolsFeatureEnabled = computed(() => @@ -469,6 +473,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { isAgentModuleActive, isDataTableFeatureEnabled, isChatFeatureEnabled, + isOtelEnabled, isAgentsNodeToolsFeatureEnabled, isPublicChatTriggerDisabled, }; diff --git a/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettings.vue b/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettings.vue index 3a457ca07fd..c8aa3ef5e41 100644 --- a/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettings.vue +++ b/packages/frontend/editor-ui/src/features/ndv/settings/components/NodeSettings.vue @@ -158,7 +158,8 @@ const hiddenIssuesInputs = ref([]); const subConnections = ref | null>(null); const isDemoRoute = computed(() => route?.name === VIEWS.DEMO); -const { isPreviewMode } = useSettingsStore(); +const settingsStore = useSettingsStore(); +const { isPreviewMode } = settingsStore; const isDemoPreview = computed(() => isDemoRoute.value && isPreviewMode); const currentWorkflow = computed(() => workflowsListStore.getWorkflowById(workflowsStore.workflowId), @@ -434,6 +435,19 @@ const valueChanged = (parameterData: IUpdateInformation) => { _node, isToolNode.value, ); + } else if (parameterData.name.includes('.') || parameterData.name.includes('[')) { + // A nested property on the node itself changed (e.g. a fixedCollection setting + // like `customTelemetryTags.tag`). Update the nested path in `nodeValues`, + // then persist the whole top-level field back to the node. + const topLevelKey = parameterData.name.split(/[.[]/)[0]; + const valueForSetter = newValue === undefined ? null : newValue; + nodeSettingsParameters.setValue(nodeValues, parameterData.name, valueForSetter); + + workflowDocumentStore?.value?.setNodeValue({ + name: _node.name, + key: topLevelKey, + value: nodeValues.value[topLevelKey] as NodeParameterValue, + }); } else { // A property on the node itself changed @@ -488,7 +502,11 @@ const populateHiddenIssuesSet = () => { }; const nodeSettings = computed(() => - createCommonNodeSettings(isToolNode.value || isModelNode.value, i18n.baseText.bind(i18n)), + createCommonNodeSettings( + isToolNode.value || isModelNode.value, + i18n.baseText.bind(i18n), + settingsStore.isOtelEnabled, + ), ); const iconSource = useNodeIconSource(nodeType, node); diff --git a/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.test.ts b/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.test.ts index 49910a9a6f8..84fb4e3c75d 100644 --- a/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.test.ts @@ -18,6 +18,7 @@ import { setValue, shouldSkipParamValidation, createCommonNodeSettings, + collectSettings, } from './ndv.utils'; import { CUSTOM_API_CALL_KEY, SWITCH_NODE_TYPE } from '@/app/constants'; import type { INodeUi, IUpdateInformation } from '@/Interface'; @@ -595,6 +596,78 @@ describe('createCommonNodeSettings', () => { expect(names).toContain('notesInFlow'); } }); + + it('should not include customTelemetryTags when isOtelEnabled is false', () => { + const regularSettings = createCommonNodeSettings(false, mockT, false); + const toolSettings = createCommonNodeSettings(true, mockT, false); + + expect(regularSettings.map((s) => s.name)).not.toContain('customTelemetryTags'); + expect(toolSettings.map((s) => s.name)).not.toContain('customTelemetryTags'); + }); + + it('should not include customTelemetryTags when isOtelEnabled is omitted', () => { + const settings = createCommonNodeSettings(false, mockT); + expect(settings.map((s) => s.name)).not.toContain('customTelemetryTags'); + }); + + it('should include customTelemetryTags as the last setting when isOtelEnabled is true', () => { + const regularSettings = createCommonNodeSettings(false, mockT, true); + const toolSettings = createCommonNodeSettings(true, mockT, true); + + for (const settings of [regularSettings, toolSettings]) { + expect(settings[settings.length - 1].name).toBe('customTelemetryTags'); + } + }); + + it('should configure customTelemetryTags with non-expression key and expression-capable value', () => { + const settings = createCommonNodeSettings(false, mockT, true); + const tagsSetting = settings.find((s) => s.name === 'customTelemetryTags'); + + expect(tagsSetting).toBeDefined(); + expect(tagsSetting?.type).toBe('fixedCollection'); + expect(tagsSetting?.typeOptions).toEqual({ multipleValues: true, sortable: true }); + expect(tagsSetting?.isNodeSetting).toBe(true); + + const tagOption = (tagsSetting?.options as INodeProperties[] | undefined)?.[0] as + | { name: string; values: INodeProperties[] } + | undefined; + expect(tagOption?.name).toBe('tag'); + + const keyField = tagOption?.values.find((v) => v.name === 'key'); + const valueField = tagOption?.values.find((v) => v.name === 'value'); + + expect(keyField?.noDataExpression).toBe(true); + expect(valueField?.noDataExpression).toBeUndefined(); + }); +}); + +describe('collectSettings', () => { + const customTelemetryTags = { + tag: [ + { key: 'environment', value: 'production' }, + { key: 'team', value: '={{ $json.team }}' }, + ], + }; + + it('should round-trip customTelemetryTags from the node object', () => { + const node = { customTelemetryTags } as INodeUi; + + const result = collectSettings(node, []); + + expect(result.customTelemetryTags).toEqual(customTelemetryTags); + expect(result.customTelemetryTags).not.toBe(customTelemetryTags); + expect((result.customTelemetryTags as typeof customTelemetryTags).tag).not.toBe( + customTelemetryTags.tag, + ); + }); + + it('should fall back to an empty customTelemetryTags object when the node has none', () => { + const node = mock({ customTelemetryTags: undefined }); + + const result = collectSettings(node, []); + + expect(result.customTelemetryTags).toEqual({}); + }); }); describe('setValue', () => { @@ -639,4 +712,26 @@ describe('setValue', () => { expect(nodeValues.value.newProperty).toBe('newValue'); }); + + it('deletes array items from nested values', () => { + nodeValues.value = { + parameters: {}, + customTelemetryTags: { + tag: [ + { key: 'environment', value: 'production' }, + { key: 'team', value: 'engineering' }, + ], + }, + }; + + setValue(nodeValues, 'customTelemetryTags.tag[0]', null); + + expect(nodeValues.value.customTelemetryTags).toEqual({ + tag: [{ key: 'team', value: 'engineering' }], + }); + + setValue(nodeValues, 'customTelemetryTags.tag[0]', null); + + expect(nodeValues.value.customTelemetryTags).toEqual({}); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.ts b/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.ts index 917c5d4b5c8..a57c94ab144 100644 --- a/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.ts +++ b/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.ts @@ -49,6 +49,7 @@ export function getNodeSettingsInitialValues(): INodeParameters { maxTries: 3, waitBetweenTries: 1000, notes: '', + customTelemetryTags: {}, parameters: {}, }; } @@ -91,12 +92,14 @@ export function setValue( // Data is on lower level if (value === null) { // Property should be deleted - let tempValue = get(nodeValues.value, nameParts.join('.')) as - | INodeParameters - | INodeParameters[]; + const path = nameParts.join('.'); + let tempValue = get(nodeValues.value, path) as INodeParameters | INodeParameters[]; - if (lastNamePart && !Array.isArray(tempValue)) { - tempValue = omitKey(tempValue, lastNamePart); + if (isArray && Array.isArray(tempValue) && lastNamePart !== undefined) { + tempValue.splice(parseInt(lastNamePart, 10), 1); + set(nodeValues.value, path, tempValue); + } else if (lastNamePart && tempValue && !Array.isArray(tempValue)) { + set(nodeValues.value, path, omitKey(tempValue, lastNamePart)); } if (isArray && Array.isArray(tempValue) && tempValue.length === 0) { @@ -105,7 +108,11 @@ export function setValue( lastNamePart = nameParts.pop(); tempValue = get(nodeValues.value, nameParts.join('.')) as INodeParameters; if (lastNamePart) { - tempValue = omitKey(tempValue, lastNamePart); + if (nameParts.length === 0) { + nodeValues.value = omitKey(nodeValues.value, lastNamePart); + } else { + set(nodeValues.value, nameParts.join('.'), omitKey(tempValue, lastNamePart)); + } } } } else { @@ -451,6 +458,7 @@ export function shouldSkipParamValidation( export function createCommonNodeSettings( isToolOrModelNode: boolean, t: (key: BaseTextKey) => string, + isOtelEnabled = false, ) { const ret: INodeProperties[] = []; @@ -572,6 +580,42 @@ export function createCommonNodeSettings( }, ); + if (isOtelEnabled) { + ret.push({ + displayName: t('nodeSettings.customTelemetryTags.displayName'), + name: 'customTelemetryTags', + type: 'fixedCollection', + typeOptions: { multipleValues: true, sortable: true }, + placeholder: t('nodeSettings.customTelemetryTags.placeholder'), + default: {}, + description: t('nodeSettings.customTelemetryTags.description'), + isNodeSetting: true, + options: [ + { + name: 'tag', + displayName: t('nodeSettings.customTelemetryTags.tag.displayName'), + values: [ + { + displayName: t('nodeSettings.customTelemetryTags.tag.key.displayName'), + name: 'key', + type: 'string', + default: '', + noDataExpression: true, + isNodeSetting: true, + }, + { + displayName: t('nodeSettings.customTelemetryTags.tag.value.displayName'), + name: 'value', + type: 'string', + default: '', + isNodeSetting: true, + }, + ], + }, + ], + }); + } + return ret; } @@ -660,6 +704,14 @@ export function collectSettings(node: INodeUi, nodeSettings: INodeProperties[]): }; } + if (node.customTelemetryTags) { + foundNodeSettings.push('customTelemetryTags'); + ret = { + ...ret, + customTelemetryTags: deepCopy(node.customTelemetryTags), + }; + } + // Set default node settings for (const nodeSetting of nodeSettings) { if (!foundNodeSettings.includes(nodeSetting.name)) { diff --git a/packages/frontend/editor-ui/src/features/shared/toolConfig/NodeToolSettingsContent.vue b/packages/frontend/editor-ui/src/features/shared/toolConfig/NodeToolSettingsContent.vue index 0cc19cd09e0..5a0632136be 100644 --- a/packages/frontend/editor-ui/src/features/shared/toolConfig/NodeToolSettingsContent.vue +++ b/packages/frontend/editor-ui/src/features/shared/toolConfig/NodeToolSettingsContent.vue @@ -33,6 +33,7 @@ import { } from '@/app/constants'; import type { ExpressionLocalResolveContext } from '@/app/types/expressions'; import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store'; +import { useSettingsStore } from '@/app/stores/settings.store'; import { createWorkflowDocumentId, useWorkflowDocumentStore, @@ -56,6 +57,7 @@ const credentialsStore = useCredentialsStore(); const projectsStore = useProjectsStore(); const nodeHelpers = useNodeHelpers(); const environmentsStore = useEnvironmentsStore(); +const settingsStore = useSettingsStore(); const node = shallowRef(props.initialNode); const userEditedName = ref(false); @@ -92,7 +94,7 @@ const tabOptions = computed>>(() => { }); const nodeSettings = computed(() => - createCommonNodeSettings(true, i18n.baseText.bind(i18n)).filter( + createCommonNodeSettings(true, i18n.baseText.bind(i18n), settingsStore.isOtelEnabled).filter( (s) => s.name !== 'notes' && s.name !== 'notesInFlow', ), ); @@ -101,6 +103,7 @@ const settingsNodeValues = computed(() => { if (!node.value) return { parameters: {} }; return { parameters: deepCopy(node.value.parameters), + customTelemetryTags: deepCopy(node.value.customTelemetryTags ?? {}), }; }); @@ -199,6 +202,15 @@ function handleChangeSettingsValue(updateData: IUpdateInformation) { ...node.value, parameters: newParameters, }; + } else if (updateData.name.includes('.') || updateData.name.includes('[')) { + const newNode = deepCopy(node.value); + setParameterValue(newNode as unknown as INodeParameters, updateData.name, updateData.value); + + if (newNode.customTelemetryTags?.tag?.length === 0) { + newNode.customTelemetryTags = {}; + } + + node.value = newNode; } else { node.value = { ...node.value, [updateData.name]: updateData.value }; } diff --git a/packages/frontend/editor-ui/src/features/shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts b/packages/frontend/editor-ui/src/features/shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts index d9adf9191db..8fd425df006 100644 --- a/packages/frontend/editor-ui/src/features/shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts @@ -6,6 +6,7 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store'; +import { useSettingsStore } from '@/app/stores/settings.store'; import NodeToolSettingsContent from '../NodeToolSettingsContent.vue'; import { NodeHelpers, type INode, type INodeTypeDescription } from 'n8n-workflow'; import { waitFor } from '@testing-library/vue'; @@ -136,6 +137,7 @@ describe('NodeToolSettingsContent', () => { let credentialsStore: ReturnType>; let projectsStore: ReturnType>; let environmentsStore: ReturnType>; + let settingsStore: ReturnType>; beforeEach(() => { vi.clearAllMocks(); @@ -146,6 +148,7 @@ describe('NodeToolSettingsContent', () => { credentialsStore = mockedStore(useCredentialsStore); projectsStore = mockedStore(useProjectsStore); environmentsStore = mockedStore(useEnvironmentsStore); + settingsStore = mockedStore(useSettingsStore); nodeTypesStore.getNodeType = vi.fn().mockReturnValue(MOCK_NODE_TYPE); environmentsStore.variablesAsObject = {}; @@ -421,6 +424,28 @@ describe('NodeToolSettingsContent', () => { }); }); + describe('customTelemetryTags', () => { + it('should show settings tab when isOtelEnabled is true', () => { + settingsStore.isOtelEnabled = true; + + const { getByText } = renderComponent({ + props: { initialNode: createMockNode() }, + }); + + expect(getByText('nodeSettings.settings')).toBeTruthy(); + }); + + it('should not show settings tab from otel alone when isOtelEnabled is false', () => { + settingsStore.isOtelEnabled = false; + + const { queryByText } = renderComponent({ + props: { initialNode: createMockNode() }, + }); + + expect(queryByText('nodeSettings.settings')).toBeFalsy(); + }); + }); + describe('initialNode watcher', () => { it('should initialize parameters with defaults from node type', () => { const getNodeParametersSpy = vi.spyOn(NodeHelpers, 'getNodeParameters').mockReturnValue({ diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 2c82f744a14..bffca1bbabc 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1380,6 +1380,9 @@ export interface INode { executeOnce?: boolean; onError?: OnError; continueOnFail?: boolean; + customTelemetryTags?: { + tag?: Array<{ key: string; value: string }>; + }; parameters: INodeParameters; credentials?: INodeCredentials; webhookId?: string;