From dbe395202b403bafa9b4a8544bb9667ec323ef5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ir=C3=A9n=C3=A9e?= Date: Mon, 1 Jun 2026 17:08:46 +0100 Subject: [PATCH] feat: Add workflow-level telemetry tags (#30948) --- .../__tests__/create-workflow.dto.test.ts | 118 +++++ .../__tests__/update-workflow.dto.test.ts | 90 ++++ .../src/dto/workflows/base-workflow.dto.ts | 36 +- .../__tests__/execution-level-tracer.test.ts | 92 ++++ .../__tests__/otel-lifecycle-handler.test.ts | 88 +++- .../otel-workflow-tracing.integration.test.ts | 59 ++- .../modules/otel/execution-level-tracer.ts | 17 +- .../otel/execution-level-tracer.types.ts | 11 +- .../modules/otel/otel-lifecycle-handler.ts | 46 +- .../cli/src/modules/otel/otel.constants.ts | 1 + .../spec/schemas/workflowSettings.yml | 13 + .../integration/public-api/workflows.test.ts | 8 + .../frontend/@n8n/i18n/src/locales/en.json | 15 + .../editor-ui/src/app/components/Modals.vue | 2 +- .../WorkflowCustomTelemetryTags.test.ts | 407 +++++++++++++++ .../WorkflowCustomTelemetryTags.vue | 466 ++++++++++++++++++ .../WorkflowSettings.test.ts | 163 +++++- .../WorkflowSettings.vue | 36 +- .../src/app/stores/workflowDocument.store.ts | 4 +- .../src/app/stores/workflows.store.ts | 2 +- .../src/features/ndv/shared/ndv.utils.ts | 2 +- packages/workflow/src/interfaces.ts | 8 +- 22 files changed, 1619 insertions(+), 65 deletions(-) create mode 100644 packages/frontend/editor-ui/src/app/components/WorkflowSettings/WorkflowCustomTelemetryTags.test.ts create mode 100644 packages/frontend/editor-ui/src/app/components/WorkflowSettings/WorkflowCustomTelemetryTags.vue rename packages/frontend/editor-ui/src/app/components/{ => WorkflowSettings}/WorkflowSettings.test.ts (91%) rename packages/frontend/editor-ui/src/app/components/{ => WorkflowSettings}/WorkflowSettings.vue (98%) diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/create-workflow.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/create-workflow.dto.test.ts index eef51e3377d..52b5c1660d7 100644 --- a/packages/@n8n/api-types/src/dto/workflows/__tests__/create-workflow.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/create-workflow.dto.test.ts @@ -107,6 +107,44 @@ describe('CreateWorkflowDto', () => { expect(result.success).toBe(true); expect(result.data?.tags).toEqual(['tag1', 'tag2']); }); + + test('should preserve workflow custom telemetry tag settings', () => { + const settings = { + customTelemetryTags: [ + { key: 'env', value: 'production' }, + { key: 'workflow_name', value: 'Workflow Name' }, + ], + }; + + const result = CreateWorkflowDto.safeParse({ + name: 'Test', + nodes: [], + connections: {}, + settings, + }); + + expect(result.success).toBe(true); + expect(result.data?.settings).toEqual(settings); + }); + + test('should preserve workflow custom telemetry tag settings with keys that are unique after trim', () => { + const settings = { + customTelemetryTags: [ + { key: ' env ', value: 'production' }, + { key: 'team', value: 'backend' }, + ], + }; + + const result = CreateWorkflowDto.safeParse({ + name: 'Test', + nodes: [], + connections: {}, + settings, + }); + + expect(result.success).toBe(true); + expect(result.data?.settings).toEqual(settings); + }); }); describe('Invalid requests', () => { @@ -146,6 +184,86 @@ describe('CreateWorkflowDto', () => { request: { name: 'Test', nodes: [], connections: {}, settings: [] }, expectedErrorPath: ['settings'], }, + { + name: 'workflow custom telemetry tags as fixed collection object', + request: { + name: 'Test', + nodes: [], + connections: {}, + settings: { + customTelemetryTags: { + tag: [{ key: 'env', value: 'production' }], + }, + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags'], + }, + { + name: 'workflow custom telemetry tag with extra field', + request: { + name: 'Test', + nodes: [], + connections: {}, + settings: { + customTelemetryTags: [{ key: 'env', value: 'production', extra: 'field' }], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags', 0], + }, + { + name: 'duplicate workflow custom telemetry tag keys', + request: { + name: 'Test', + nodes: [], + connections: {}, + settings: { + customTelemetryTags: [ + { key: 'env', value: 'production' }, + { key: 'env', value: 'staging' }, + ], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags'], + }, + { + name: 'duplicate workflow custom telemetry tag keys after trim', + request: { + name: 'Test', + nodes: [], + connections: {}, + settings: { + customTelemetryTags: [ + { key: ' env ', value: 'production' }, + { key: 'env', value: 'staging' }, + ], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags'], + }, + { + name: 'empty workflow custom telemetry tag key', + request: { + name: 'Test', + nodes: [], + connections: {}, + settings: { + customTelemetryTags: [{ key: '', value: 'production' }], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags', 0, 'key'], + }, + { + name: 'whitespace-only workflow custom telemetry tag key', + request: { + name: 'Test', + nodes: [], + connections: {}, + settings: { + customTelemetryTags: [{ key: ' ', value: 'production' }], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags', 0, 'key'], + }, { name: 'staticData as array', request: { name: 'Test', nodes: [], connections: {}, staticData: [] }, diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/update-workflow.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/update-workflow.dto.test.ts index 5586a708fc8..dee2c7b0bda 100644 --- a/packages/@n8n/api-types/src/dto/workflows/__tests__/update-workflow.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/update-workflow.dto.test.ts @@ -84,6 +84,34 @@ describe('UpdateWorkflowDto', () => { expect(result.success).toBe(true); expect(result.data?.tags).toEqual(['tag1', 'tag2']); }); + + test('should preserve workflow custom telemetry tag settings', () => { + const settings = { + customTelemetryTags: [ + { key: 'env', value: 'production' }, + { key: 'workflow_name', value: 'Workflow Name' }, + ], + }; + + const result = UpdateWorkflowDto.safeParse({ settings }); + + expect(result.success).toBe(true); + expect(result.data?.settings).toEqual(settings); + }); + + test('should preserve workflow custom telemetry tag settings with keys that are unique after trim', () => { + const settings = { + customTelemetryTags: [ + { key: ' env ', value: 'production' }, + { key: 'team', value: 'backend' }, + ], + }; + + const result = UpdateWorkflowDto.safeParse({ settings }); + + expect(result.success).toBe(true); + expect(result.data?.settings).toEqual(settings); + }); }); describe('Invalid requests', () => { @@ -118,6 +146,68 @@ describe('UpdateWorkflowDto', () => { request: { settings: [] }, expectedErrorPath: ['settings'], }, + { + name: 'workflow custom telemetry tags as fixed collection object', + request: { + settings: { + customTelemetryTags: { + tag: [{ key: 'env', value: 'production' }], + }, + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags'], + }, + { + name: 'workflow custom telemetry tag with extra field', + request: { + settings: { + customTelemetryTags: [{ key: 'env', value: 'production', extra: 'field' }], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags', 0], + }, + { + name: 'duplicate workflow custom telemetry tag keys', + request: { + settings: { + customTelemetryTags: [ + { key: 'env', value: 'production' }, + { key: 'env', value: 'staging' }, + ], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags'], + }, + { + name: 'duplicate workflow custom telemetry tag keys after trim', + request: { + settings: { + customTelemetryTags: [ + { key: ' env ', value: 'production' }, + { key: 'env', value: 'staging' }, + ], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags'], + }, + { + name: 'empty workflow custom telemetry tag key', + request: { + settings: { + customTelemetryTags: [{ key: '', value: 'production' }], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags', 0, 'key'], + }, + { + name: 'whitespace-only workflow custom telemetry tag key', + request: { + settings: { + customTelemetryTags: [{ key: ' ', value: 'production' }], + }, + }, + expectedErrorPath: ['settings', 'customTelemetryTags', 0, 'key'], + }, { name: 'staticData as array', request: { staticData: [] }, diff --git a/packages/@n8n/api-types/src/dto/workflows/base-workflow.dto.ts b/packages/@n8n/api-types/src/dto/workflows/base-workflow.dto.ts index 69af6161220..89daf8c514d 100644 --- a/packages/@n8n/api-types/src/dto/workflows/base-workflow.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflows/base-workflow.dto.ts @@ -33,12 +33,36 @@ export const workflowConnectionsSchema = z.custom( }, ); -export const workflowSettingsSchema = z.custom( - (val) => val === null || (typeof val === 'object' && val !== null && !Array.isArray(val)), - { - message: 'Settings must be an object or null', - }, -); +const customTelemetryTagSchema = z + .object( + { + key: z + .string({ invalid_type_error: 'Key must be a string' }) + .refine((key) => key.trim().length > 0, { message: 'Key must not be empty' }), + value: z.string({ invalid_type_error: 'Value must be a string' }), + }, + { invalid_type_error: 'Custom telemetry tag must be an object' }, + ) + .strict({ message: 'Custom telemetry tag must only include key and value' }); + +const customTelemetryTagsSchema = z + .array(customTelemetryTagSchema, { + invalid_type_error: 'Custom telemetry tags must be an array', + }) + .refine( + (tags) => { + const trimmedKeys = tags.map((tag) => tag.key.trim()); + return trimmedKeys.length === new Set(trimmedKeys).size; + }, + { message: 'Duplicate keys are not allowed in customTelemetryTags' }, + ); + +export const workflowSettingsSchema: z.ZodType = z + .object({ + customTelemetryTags: customTelemetryTagsSchema.optional(), + }) + .passthrough() + .nullable(); export const workflowStaticDataSchema = z.preprocess( (val) => { diff --git a/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts b/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts index cc4a61472a9..9e37d727e48 100644 --- a/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts +++ b/packages/cli/src/modules/otel/__tests__/execution-level-tracer.test.ts @@ -173,6 +173,32 @@ describe('ExecutionLevelTracer', () => { expect(span.attributes['n8n.execution.retry_of']).toBe('exec-original'); }); + it('should add custom workflow attributes as string values', () => { + tracer.startWorkflow({ + executionId: 'exec-workflow-custom', + tracingContext: inboundTracingContext, + workflow: { + ...defaultWorkflow, + customAttributes: { + environment: 'production', + retryCount: '3', + isCritical: 'true', + }, + }, + }); + tracer.endWorkflow({ + executionId: 'exec-workflow-custom', + status: 'success', + mode: 'manual', + isRetry: false, + }); + + const span = otel.getFinishedSpans()[0]; + expect(span.attributes['n8n.workflow.custom.environment']).toBe('production'); + expect(span.attributes['n8n.workflow.custom.retryCount']).toBe('3'); + expect(span.attributes['n8n.workflow.custom.isCritical']).toBe('true'); + }); + it('should use inbound traceparent as parent context', () => { tracer.startWorkflow({ executionId: 'exec-4', @@ -413,6 +439,72 @@ describe('ExecutionLevelTracer', () => { expect(nodeSpan.attributes['n8n.node.custom.llm.tokens']).toBe('500'); }); + it('should not apply workflow custom attributes to node spans', () => { + tracer.startWorkflow({ + executionId: 'exec-workflow-tags-on-node', + tracingContext: inboundTracingContext, + workflow: { + ...defaultWorkflow, + customAttributes: { env: 'prod', retryCount: '3', isCritical: 'true' }, + }, + }); + const node = { id: 'n1', name: 'Node1', type: 'test', typeVersion: 1 }; + tracer.startNode({ + executionId: 'exec-workflow-tags-on-node', + node, + }); + tracer.endNode({ + executionId: 'exec-workflow-tags-on-node', + node, + inputItemCount: 1, + outputItemCount: 1, + }); + tracer.endWorkflow({ + executionId: 'exec-workflow-tags-on-node', + status: 'success', + mode: 'manual', + isRetry: false, + }); + + const nodeSpan = otel.getFinishedSpans().find((s) => s.name === 'node.execute')!; + expect(nodeSpan.attributes['n8n.workflow.custom.env']).toBeUndefined(); + expect(nodeSpan.attributes['n8n.workflow.custom.retryCount']).toBeUndefined(); + expect(nodeSpan.attributes['n8n.workflow.custom.isCritical']).toBeUndefined(); + }); + + it('should keep workflow and node custom attributes under separate prefixes', () => { + tracer.startWorkflow({ + executionId: 'exec-workflow-node-tag-collision', + tracingContext: inboundTracingContext, + workflow: { + ...defaultWorkflow, + customAttributes: { env: 'workflow' }, + }, + }); + const node = { id: 'n1', name: 'Node1', type: 'test', typeVersion: 1 }; + tracer.startNode({ + executionId: 'exec-workflow-node-tag-collision', + node, + }); + tracer.endNode({ + executionId: 'exec-workflow-node-tag-collision', + node, + inputItemCount: 1, + outputItemCount: 1, + customAttributes: { env: 'node' }, + }); + tracer.endWorkflow({ + executionId: 'exec-workflow-node-tag-collision', + status: 'success', + mode: 'manual', + isRetry: false, + }); + + const nodeSpan = otel.getFinishedSpans().find((s) => s.name === 'node.execute')!; + expect(nodeSpan.attributes['n8n.workflow.custom.env']).toBeUndefined(); + expect(nodeSpan.attributes['n8n.node.custom.env']).toBe('node'); + }); + it('should preserve agent tracing custom attributes on node.execute when the node errors', () => { tracer.startWorkflow({ executionId: 'exec-agent-meta-err', diff --git a/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts b/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts index bf5b9f251cc..b09535b32bd 100644 --- a/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts +++ b/packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts @@ -6,7 +6,8 @@ import type { WorkflowExecuteBeforeContext, } from '@n8n/decorators'; import { mock } from 'jest-mock-extended'; -import type { IRun, IRunExecutionData } from 'n8n-workflow'; +import { Workflow } from 'n8n-workflow'; +import type { INodeTypes, IRun, IRunExecutionData } from 'n8n-workflow'; import type { OwnershipService } from '@/services/ownership.service'; @@ -20,6 +21,19 @@ const emptyExecutionData = { executionData: undefined, } as unknown as IRunExecutionData; +const nodeTypes = mock(); + +function createWorkflowInstance() { + return new Workflow({ + id: 'wf-1', + name: 'Test', + active: false, + nodes: [], + connections: {}, + nodeTypes, + }); +} + function makeOtelConfig(overrides: Partial = {}): OtelConfig { return Object.assign(new OtelConfig(), overrides); } @@ -55,7 +69,7 @@ describe('OtelLifecycleHandler', () => { updatedAt: new Date(), activeVersionId: null, }, - workflowInstance: undefined as never, + workflowInstance: createWorkflowInstance(), executionId: 'exec-sub', }; @@ -198,6 +212,74 @@ describe('OtelLifecycleHandler', () => { expect(traceContextService.persist).toHaveBeenCalledWith('exec-sub', generatedSpanContext); }); + + it('should pass literal workflow custom telemetry tags to the tracer', async () => { + await handler.onWorkflowStart({ + ...baseCtx, + workflow: { + ...baseCtx.workflow, + settings: { + customTelemetryTags: [ + { key: ' environment ', value: 'production' }, + { key: 'workflowName', value: 'Workflow Name' }, + { key: 'mode', value: 'manual' }, + ], + }, + }, + }); + + expect(tracer.startWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflow: expect.objectContaining({ + customAttributes: { + environment: 'production', + workflowName: 'Workflow Name', + mode: 'manual', + }, + }), + }), + ); + }); + + it('should skip workflow custom telemetry tags with empty keys', async () => { + await handler.onWorkflowStart({ + ...baseCtx, + workflow: { + ...baseCtx.workflow, + settings: { + customTelemetryTags: [ + { key: ' ', value: 'empty-key' }, + { key: 'status', value: 'undefined' }, + { key: 'objectValue', value: 'nested true' }, + { key: 'fallback', value: 'missing value' }, + ], + }, + }, + }); + + expect(tracer.startWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflow: expect.objectContaining({ + customAttributes: { + status: 'undefined', + objectValue: 'nested true', + fallback: 'missing value', + }, + }), + }), + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should omit customAttributes when workflow custom telemetry tags are absent', async () => { + await handler.onWorkflowStart(baseCtx); + + expect(tracer.startWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workflow: expect.objectContaining({ customAttributes: undefined }), + }), + ); + }); }); describe('onWorkflowResume', () => { @@ -302,7 +384,7 @@ describe('OtelLifecycleHandler', () => { await handler.onWorkflowResume({ type: 'workflowExecuteResume', workflow: { id: 'wf-1', name: 'Test', versionId: 'v1', nodes: [], connections: {} }, - workflowInstance: undefined as never, + workflowInstance: createWorkflowInstance(), executionData: undefined as never, executionId: 'exec-resume', } as never); 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 8dd74b86cb6..278ad21ace2 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 @@ -148,7 +148,6 @@ describe('Custom Telemetry Tags', () => { tag: [ { key: 'environment', value: 'production' }, { key: 'team', value: 'backend' }, - { key: 'env', value: '={{ $json.env }}' }, ], }, }, @@ -186,9 +185,7 @@ describe('Custom Telemetry Tags', () => { position: [200, 0] as [number, number], id: uuid(), name: 'HelperA', - customTelemetryTags: { - tag: [{ key: 'service', value: 'auth' }], - }, + customTelemetryTags: { tag: [{ key: 'service', value: 'auth' }] }, }, { parameters: { category: 'doNothing' }, @@ -197,9 +194,7 @@ describe('Custom Telemetry Tags', () => { position: [400, 0] as [number, number], id: uuid(), name: 'HelperB', - customTelemetryTags: { - tag: [{ key: 'tier', value: 'premium' }], - }, + customTelemetryTags: { tag: [{ key: 'tier', value: 'premium' }] }, }, ], connections: { @@ -238,23 +233,6 @@ describe('Custom Telemetry Tags', () => { 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); @@ -272,4 +250,37 @@ describe('Custom Telemetry Tags', () => { expect(helperB.attributes['n8n.node.custom.tier']).toBe('premium'); expect(helperB.attributes['n8n.node.custom.service']).toBeUndefined(); }); + + it('should attach workflow custom telemetry tags only to the workflow span', async () => { + const project = await createTeamProject(); + const workflow = await createWorkflow( + { + ...createMultiNodeWorkflowFixture(), + settings: { + customTelemetryTags: [ + { key: 'environment', value: 'production' }, + { key: 'workflowName', value: 'Custom Tags Workflow' }, + { key: 'retryCount', value: '3' }, + { key: 'isCritical', value: 'true' }, + ], + }, + }, + project, + ); + const executionId = await executeWorkflow(workflowRunner, workflow, project.id); + await waitForExecution(executionRepository, executionId); + + const spans = otel.getFinishedSpans(); + const workflowSpan = spans.find((s) => s.name === 'workflow.execute')!; + const nodeSpan = spans.find((s) => s.name === 'node.execute')!; + + expect(workflowSpan.attributes['n8n.workflow.custom.environment']).toBe('production'); + expect(workflowSpan.attributes['n8n.workflow.custom.workflowName']).toBe( + 'Custom Tags Workflow', + ); + expect(workflowSpan.attributes['n8n.workflow.custom.retryCount']).toBe('3'); + expect(workflowSpan.attributes['n8n.workflow.custom.isCritical']).toBe('true'); + expect(nodeSpan.attributes['n8n.workflow.custom.environment']).toBeUndefined(); + expect(nodeSpan.attributes['n8n.workflow.custom.workflowName']).toBeUndefined(); + }); }); diff --git a/packages/cli/src/modules/otel/execution-level-tracer.ts b/packages/cli/src/modules/otel/execution-level-tracer.ts index 45cd5d4eade..92c78adb493 100644 --- a/packages/cli/src/modules/otel/execution-level-tracer.ts +++ b/packages/cli/src/modules/otel/execution-level-tracer.ts @@ -37,6 +37,7 @@ export class ExecutionLevelTracer { try { const parentCtx = this.parseTraceParentHeaders(params.tracingContext); const links = this.buildContinuationLinks(params.linkTo); + const span = this.tracer.startSpan( 'workflow.execute', { @@ -47,6 +48,10 @@ export class ExecutionLevelTracer { [ATTR.WORKFLOW_NODE_COUNT]: params.workflow.nodeCount, [ATTR.EXECUTION_ID]: params.executionId, ...(params.project?.id && { [ATTR.PROJECT_ID]: params.project.id }), + ...buildCustomAttributes( + ATTR.WORKFLOW_CUSTOM_PREFIX, + params.workflow?.customAttributes, + ), ...buildCustomAttributes(ATTR.PROJECT_CUSTOM_PREFIX, params.project?.customAttributes), }, links, @@ -54,7 +59,9 @@ export class ExecutionLevelTracer { parentCtx, ); - this.activeWorkflowSpans.set(params.executionId, { span }); + this.activeWorkflowSpans.set(params.executionId, { + span, + }); return toTracingParentContext(span); } catch (error) { this.logger.warn('Failed to start workflow span', { @@ -258,14 +265,8 @@ function buildNodeEndAttributes(params: EndNodeParams): Record = { [ATTR.NODE_ITEMS_INPUT]: params.inputItemCount, [ATTR.NODE_ITEMS_OUTPUT]: params.outputItemCount, + ...buildCustomAttributes(ATTR.NODE_CUSTOM_PREFIX, params.customAttributes), }; - - if (params.customAttributes) { - for (const [key, value] of Object.entries(params.customAttributes)) { - attrs[`${ATTR.NODE_CUSTOM_PREFIX}${key}`] = value; - } - } - return attrs; } diff --git a/packages/cli/src/modules/otel/execution-level-tracer.types.ts b/packages/cli/src/modules/otel/execution-level-tracer.types.ts index ce796f2cbdf..0cac8671cad 100644 --- a/packages/cli/src/modules/otel/execution-level-tracer.types.ts +++ b/packages/cli/src/modules/otel/execution-level-tracer.types.ts @@ -2,11 +2,18 @@ import type { ExecutionStatus, WorkflowExecuteMode, INode } from 'n8n-workflow'; import type { TracingContext } from './tracing-context'; +export type CustomAttributes = Record; type ProjectContext = { id: string; - customAttributes?: Record; + customAttributes?: CustomAttributes; +}; +type WorkflowContext = { + id: string; + name: string; + versionId?: string; + nodeCount: number; + customAttributes?: CustomAttributes; }; -type WorkflowContext = { id: string; name: string; versionId?: string; nodeCount: number }; export type StartWorkflowParams = { executionId: string; diff --git a/packages/cli/src/modules/otel/otel-lifecycle-handler.ts b/packages/cli/src/modules/otel/otel-lifecycle-handler.ts index 89376286d23..089a3cc86c6 100644 --- a/packages/cli/src/modules/otel/otel-lifecycle-handler.ts +++ b/packages/cli/src/modules/otel/otel-lifecycle-handler.ts @@ -1,4 +1,3 @@ -import { Logger } from '@n8n/backend-common'; import { OnLifecycleEvent } from '@n8n/decorators'; import type { WorkflowExecuteBeforeContext, @@ -7,14 +6,35 @@ import type { NodeExecuteBeforeContext, NodeExecuteAfterContext, } from '@n8n/decorators'; +import { Logger } from '@n8n/backend-common'; import { Service } from '@n8n/di'; -import type { IWorkflowBase } from 'n8n-workflow'; +import type { ICustomTelemetryTag, IWorkflowBase } from 'n8n-workflow'; import { ExecutionLevelTracer } from './execution-level-tracer'; +import type { CustomAttributes } from './execution-level-tracer.types'; import { OtelConfig } from './otel.config'; import { TraceContextService } from './tracing-context'; import { OwnershipService } from '../../services/ownership.service'; +const isCustomTelemetryTag = (value: unknown): value is ICustomTelemetryTag => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + 'key' in value && + 'value' in value && + typeof value.key === 'string' && + typeof value.value === 'string'; + +const getCustomTelemetryTags = (value: unknown): ICustomTelemetryTag[] | undefined => { + if (Array.isArray(value)) return value.filter(isCustomTelemetryTag); + if (typeof value !== 'object' || value === null || !('tag' in value)) { + return undefined; + } + + const { tag } = value; + return Array.isArray(tag) ? tag.filter(isCustomTelemetryTag) : undefined; +}; + @Service() export class OtelLifecycleHandler { constructor( @@ -65,6 +85,7 @@ export class OtelLifecycleHandler { name: ctx.workflow.name, versionId: ctx.workflow.versionId, nodeCount: ctx.workflow.nodes.length, + customAttributes: this.buildWorkflowCustomAttributes(ctx), }, }); @@ -104,6 +125,7 @@ export class OtelLifecycleHandler { name: ctx.workflow.name, versionId: ctx.workflow.versionId, nodeCount: ctx.workflow.nodes.length, + customAttributes: this.buildWorkflowCustomAttributes(ctx), }, }); } @@ -159,6 +181,26 @@ export class OtelLifecycleHandler { customAttributes, }); } + + private buildWorkflowCustomAttributes( + ctx: WorkflowExecuteBeforeContext | WorkflowExecuteResumeContext, + ): CustomAttributes | undefined { + const tags = getCustomTelemetryTags(ctx.workflow.settings?.customTelemetryTags); + if (!tags?.length) return; + + const customAttributes: CustomAttributes = {}; + + for (const { key, value } of tags) { + const trimmedKey = key.trim(); + if (!trimmedKey) continue; + + customAttributes[trimmedKey] = value; + } + + if (Object.keys(customAttributes).length === 0) return; + + return customAttributes; + } } function buildProjectCustomAttributes( diff --git a/packages/cli/src/modules/otel/otel.constants.ts b/packages/cli/src/modules/otel/otel.constants.ts index bb4cdf665e1..54f8f690a04 100644 --- a/packages/cli/src/modules/otel/otel.constants.ts +++ b/packages/cli/src/modules/otel/otel.constants.ts @@ -13,6 +13,7 @@ export const ATTR = { WORKFLOW_VERSION_ID: 'n8n.workflow.version_id', WORKFLOW_NAME: 'n8n.workflow.name', WORKFLOW_NODE_COUNT: 'n8n.workflow.node_count', + WORKFLOW_CUSTOM_PREFIX: 'n8n.workflow.custom.', EXECUTION_ID: 'n8n.execution.id', EXECUTION_MODE: 'n8n.execution.mode', diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml index 1acab2ae660..42edb65a855 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml @@ -67,3 +67,16 @@ properties: Security note: When a workflow is available in MCP, it can be discovered and executed by any MCP client that has the appropriate API credentials for your n8n instance. example: false + customTelemetryTags: + type: array + items: + type: object + additionalProperties: false + required: + - key + - value + properties: + key: + type: string + value: + type: string diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 9f5f76ad628..f292753f79f 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -1427,6 +1427,10 @@ describe('POST /workflows', () => { executionOrder: 'v1', callerPolicy: 'workflowsFromSameOwner', availableInMCP: false, + customTelemetryTags: [ + { key: 'env', value: 'production' }, + { key: 'category', value: 'data-cleaning' }, + ], }, }; @@ -1763,6 +1767,10 @@ describe('PUT /workflows/:id', () => { timezone: 'America/New_York', callerPolicy: 'workflowsFromSameOwner', availableInMCP: false, + customTelemetryTags: [ + { key: 'env', value: 'production' }, + { key: 'category', value: 'data-cleaning' }, + ], }, }; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 7c8bd638e63..f4b41b0a9fc 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3883,6 +3883,21 @@ "workflowSettings.callerPolicy.options.workflowsFromSameProject": "Only workflows in the same project", "workflowSettings.callerPolicy.options.workflowsFromAList": "Selected workflows", "workflowSettings.callerPolicy.options.none": "No other workflows", + "workflowSettings.customTelemetryTags.displayName": "Custom telemetry tags", + "workflowSettings.customTelemetryTags.description": "Add custom tags to this workflow's OpenTelemetry spans.", + "workflowSettings.customTelemetryTags.configure": "Configure", + "workflowSettings.customTelemetryTags.configuredCount": "{count} tag configured | {count} tags configured", + "workflowSettings.customTelemetryTags.placeholder": "Add tag", + "workflowSettings.customTelemetryTags.modal.title": "Custom telemetry tags", + "workflowSettings.customTelemetryTags.modal.learnMore": "Learn more in the", + "workflowSettings.customTelemetryTags.modal.documentation": "documentation", + "workflowSettings.customTelemetryTags.tag.key.displayName": "Key", + "workflowSettings.customTelemetryTags.tag.key.placeholder": "Key", + "workflowSettings.customTelemetryTags.tag.value.displayName": "Value", + "workflowSettings.customTelemetryTags.tag.value.placeholder": "Value", + "workflowSettings.customTelemetryTags.delete": "Delete tag", + "workflowSettings.customTelemetryTags.error.emptyKey": "Key must not be empty", + "workflowSettings.customTelemetryTags.error.duplicateKey": "Duplicate keys are not allowed", "workflowSettings.defaultTimezone": "Default - {defaultTimezoneValue}", "workflowSettings.defaultTimezoneNotValid": "Default Timezone not valid", "workflowSettings.errorWorkflow": "Error Workflow (to notify when this one errors)", diff --git a/packages/frontend/editor-ui/src/app/components/Modals.vue b/packages/frontend/editor-ui/src/app/components/Modals.vue index 9e06344a528..36ff633a464 100644 --- a/packages/frontend/editor-ui/src/app/components/Modals.vue +++ b/packages/frontend/editor-ui/src/app/components/Modals.vue @@ -119,7 +119,7 @@ import type { AgentConfirmationModalData } from '@/features/agents/components/Ag import WorkflowVersionFormModal, { type WorkflowVersionFormModalData, } from '@/features/workflows/workflowHistory/components/WorkflowVersionFormModal.vue'; -import WorkflowSettings from '@/app/components/WorkflowSettings.vue'; +import WorkflowSettings from '@/app/components/WorkflowSettings/WorkflowSettings.vue'; import WorkflowShareModal from '@/app/components/WorkflowShareModal.ee.vue'; import WorkflowDiffModal from '@/features/workflows/workflowDiff/WorkflowDiffModal.vue'; import type { EventBus } from '@n8n/utils/event-bus'; diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings/WorkflowCustomTelemetryTags.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowSettings/WorkflowCustomTelemetryTags.test.ts new file mode 100644 index 00000000000..3ca29b89b96 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings/WorkflowCustomTelemetryTags.test.ts @@ -0,0 +1,407 @@ +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/vue'; +import { createComponentRenderer } from '@/__tests__/render'; +import WorkflowCustomTelemetryTags from '@/app/components/WorkflowSettings/WorkflowCustomTelemetryTags.vue'; + +const validTags = [{ key: 'env', value: 'production' }]; +const duplicateTags = [ + { key: ' env ', value: 'production' }, + { key: 'env', value: 'staging' }, +]; + +const renderComponent = createComponentRenderer(WorkflowCustomTelemetryTags, { + props: { + isReadOnly: false, + }, + global: { + stubs: { + N8nButton: { + props: ['disabled', 'label'], + emits: ['click'], + template: + '', + }, + N8nIconButton: { + props: ['disabled'], + emits: ['click'], + template: + '