feat: Add workflow-level telemetry tags (#30948)

This commit is contained in:
Irénée 2026-06-01 17:08:46 +01:00 committed by GitHub
parent 7c138e12a9
commit dbe395202b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1619 additions and 65 deletions

View File

@ -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: [] },

View File

@ -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: [] },

View File

@ -33,12 +33,36 @@ export const workflowConnectionsSchema = z.custom<IConnections>(
},
);
export const workflowSettingsSchema = z.custom<IWorkflowSettings>(
(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<IWorkflowSettings | null> = z
.object({
customTelemetryTags: customTelemetryTagsSchema.optional(),
})
.passthrough()
.nullable();
export const workflowStaticDataSchema = z.preprocess(
(val) => {

View File

@ -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',

View File

@ -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<INodeTypes>();
function createWorkflowInstance() {
return new Workflow({
id: 'wf-1',
name: 'Test',
active: false,
nodes: [],
connections: {},
nodeTypes,
});
}
function makeOtelConfig(overrides: Partial<OtelConfig> = {}): 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);

View File

@ -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();
});
});

View File

@ -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<string, string |
const attrs: Record<string, string | number> = {
[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;
}

View File

@ -2,11 +2,18 @@ import type { ExecutionStatus, WorkflowExecuteMode, INode } from 'n8n-workflow';
import type { TracingContext } from './tracing-context';
export type CustomAttributes = Record<string, string>;
type ProjectContext = {
id: string;
customAttributes?: Record<string, string>;
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;

View File

@ -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(

View File

@ -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',

View File

@ -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

View File

@ -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' },
],
},
};

View File

@ -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)",

View File

@ -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';

View File

@ -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:
'<button type="button" v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot />{{ label }}</button>',
},
N8nIconButton: {
props: ['disabled'],
emits: ['click'],
template:
'<button type="button" v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')" />',
},
N8nInput: {
props: ['id', 'modelValue', 'placeholder', 'disabled'],
emits: ['update:modelValue'],
template:
'<input v-bind="$attrs" :id="id" :value="modelValue" :placeholder="placeholder" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
N8nInputLabel: {
props: ['label', 'inputName'],
template: '<label :for="inputName"><span v-if="label">{{ label }}</span><slot /></label>',
},
},
},
});
async function openModal(getByTestId: (id: string) => HTMLElement) {
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-configure'));
await waitFor(() => {
expect(getByTestId('workflow-settings-custom-telemetry-tags-modal')).toBeVisible();
});
}
describe('WorkflowCustomTelemetryTags', () => {
describe('summary', () => {
it.each([
{
modelValue: [{ key: 'team', value: 'platform' }],
expectedCount: '1 tag configured',
},
{
modelValue: [
{ key: 'team', value: 'platform' },
{ key: 'env', value: 'production' },
],
expectedCount: '2 tags configured',
},
])('renders $expectedCount', ({ modelValue, expectedCount }) => {
const { getByTestId } = renderComponent({
props: {
modelValue,
},
});
expect(getByTestId('workflow-settings-custom-telemetry-tags-count')).toHaveTextContent(
expectedCount,
);
});
it('does not render the configured tag count when there are no tags', () => {
const { queryByTestId } = renderComponent();
expect(
queryByTestId('workflow-settings-custom-telemetry-tags-count'),
).not.toBeInTheDocument();
});
it('does not include a hidden zero count in the configure button accessible name', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('workflow-settings-custom-telemetry-tags-configure')).toHaveAccessibleName(
'Configure custom telemetry tags',
);
});
it('includes the configured tag count in the configure button accessible name', () => {
const { getByTestId } = renderComponent({
props: {
modelValue: [
{ key: 'team', value: 'platform' },
{ key: 'env', value: 'production' },
],
},
});
expect(getByTestId('workflow-settings-custom-telemetry-tags-configure')).toHaveAccessibleName(
'Configure custom telemetry tags, 2 tags configured',
);
});
});
describe('modal', () => {
it('opens with title, description, docs link, and save action', async () => {
const { getByLabelText, getByRole, getByTestId, getByText } = renderComponent();
await openModal(getByTestId);
expect(getByLabelText('Back')).toBeVisible();
expect(getByRole('heading', { name: 'Custom telemetry tags' })).toBeVisible();
expect(getByRole('dialog')).toHaveAccessibleDescription(
/Add custom tags to this workflow's OpenTelemetry spans\.\s+Learn more in the\s+documentation/,
);
expect(getByRole('link', { name: 'documentation' })).toHaveAttribute(
'href',
'https://docs.n8n.io/hosting/logging-monitoring/opentelemetry/',
);
expect(getByText('Save')).toBeVisible();
});
it('renders existing tags in labeled inputs', async () => {
const { getAllByTestId, getByTestId } = renderComponent({
props: {
modelValue: [{ key: 'team', value: 'platform' }],
},
});
await openModal(getByTestId);
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0]).toHaveValue('team');
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0]).toHaveValue(
'platform',
);
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0]).toHaveAccessibleName(
'Key',
);
expect(
getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0],
).toHaveAccessibleName('Value');
});
it('opens with no rows when there are no existing tags', async () => {
const { getByTestId, queryAllByTestId } = renderComponent();
await openModal(getByTestId);
expect(queryAllByTestId('workflow-settings-custom-telemetry-tags-row')).toHaveLength(0);
expect(getByTestId('workflow-settings-custom-telemetry-tags-add')).toBeVisible();
});
});
describe('editing tags', () => {
it('adds a new empty tag row', async () => {
const { getAllByTestId, getAllByText, getByTestId } = renderComponent();
await openModal(getByTestId);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-row')).toHaveLength(1);
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0]).toHaveValue('');
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0]).toHaveValue('');
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-row')).toHaveLength(2);
expect(getAllByText('Key')).toHaveLength(1);
expect(getAllByText('Value')).toHaveLength(1);
});
it('edits an existing tag and emits the changed tag on save', async () => {
const { emitted, getAllByTestId, getByTestId } = renderComponent({
props: {
modelValue: [{ key: 'team', value: 'platform' }],
},
});
await openModal(getByTestId);
await userEvent.clear(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0]);
await userEvent.type(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0], 'env');
await userEvent.clear(getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0]);
await userEvent.type(
getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0],
'production',
);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save'));
expect(emitted('update:modelValue')).toEqual([[validTags]]);
});
it('deletes a tag row and emits the remaining tags on save', async () => {
const { emitted, getAllByTestId, getByTestId } = renderComponent({
props: {
modelValue: [
{ key: 'team', value: 'platform' },
{ key: 'env', value: 'production' },
],
},
});
await openModal(getByTestId);
await userEvent.click(getAllByTestId('workflow-settings-custom-telemetry-tags-delete')[0]);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save'));
expect(emitted('update:modelValue')).toEqual([[[{ key: 'env', value: 'production' }]]]);
});
it('emits an empty list when all tag rows are deleted and saved', async () => {
const { emitted, getAllByTestId, getByTestId } = renderComponent({
props: {
modelValue: [{ key: 'team', value: 'platform' }],
},
});
await openModal(getByTestId);
await userEvent.click(getAllByTestId('workflow-settings-custom-telemetry-tags-delete')[0]);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save'));
expect(emitted('update:modelValue')).toEqual([[[]]]);
});
});
describe('saving', () => {
it('emits saved tags with trimmed keys', async () => {
const { emitted, getAllByTestId, getByTestId } = renderComponent();
await openModal(getByTestId);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
await userEvent.type(
getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0],
' env ',
);
await userEvent.type(
getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0],
'production',
);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save'));
expect(emitted('update:modelValue')).toEqual([[validTags]]);
});
it('awaits the async save handler before emitting and closing', async () => {
let resolveSave: () => void = () => {};
const saveTags = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveSave = resolve;
}),
);
const { emitted, getAllByTestId, getByTestId, queryByTestId } = renderComponent({
props: { saveTags },
});
await openModal(getByTestId);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
await userEvent.type(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0], 'env');
await userEvent.type(
getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0],
'production',
);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save'));
expect(saveTags).toHaveBeenCalledWith(validTags);
expect(emitted('update:modelValue')).toBeUndefined();
expect(getByTestId('workflow-settings-custom-telemetry-tags-save')).toBeDisabled();
resolveSave();
await waitFor(() => {
expect(emitted('update:modelValue')).toEqual([[validTags]]);
expect(
queryByTestId('workflow-settings-custom-telemetry-tags-modal'),
).not.toBeInTheDocument();
});
});
it('keeps the modal open and does not emit when the async save handler rejects', async () => {
const saveTags = vi.fn().mockRejectedValue(new Error('Save failed'));
const { emitted, getAllByTestId, getByTestId } = renderComponent({
props: { saveTags },
});
await openModal(getByTestId);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
await userEvent.type(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0], 'env');
await userEvent.type(
getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0],
'production',
);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save'));
await waitFor(() => {
expect(saveTags).toHaveBeenCalledWith(validTags);
expect(getByTestId('workflow-settings-custom-telemetry-tags-modal')).toBeVisible();
expect(getByTestId('workflow-settings-custom-telemetry-tags-save')).not.toBeDisabled();
});
expect(emitted('update:modelValue')).toBeUndefined();
});
});
describe('validation', () => {
it('disables save when a tag has a value but no key', async () => {
const { emitted, getAllByTestId, getByTestId } = renderComponent();
await openModal(getByTestId);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
await userEvent.type(
getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0],
'production',
);
expect(getByTestId('workflow-settings-custom-telemetry-tags-modal-error')).toHaveTextContent(
'Key must not be empty',
);
expect(getByTestId('workflow-settings-custom-telemetry-tags-save')).toBeDisabled();
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save'));
expect(emitted('update:modelValue')).toBeUndefined();
});
it('disables save when tag keys are duplicated after trim', async () => {
const { getAllByTestId, getByTestId } = renderComponent();
await openModal(getByTestId);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
await userEvent.type(
getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0],
' env ',
);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
await userEvent.type(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[1], 'env');
expect(getByTestId('workflow-settings-custom-telemetry-tags-modal-error')).toHaveTextContent(
'Duplicate keys are not allowed',
);
expect(getByTestId('workflow-settings-custom-telemetry-tags-save')).toBeDisabled();
});
it('emits validity changes for invalid saved tags and resets validity on unmount', () => {
const onValidityChange = vi.fn();
const { unmount } = renderComponent({
props: {
modelValue: duplicateTags,
'onValidity-change': onValidityChange,
},
});
expect(onValidityChange).toHaveBeenCalledWith(true);
unmount();
expect(onValidityChange).toHaveBeenLastCalledWith(false);
});
});
describe('discarding drafts', () => {
it('discards draft changes when cancelled', async () => {
const { emitted, getAllByTestId, getByTestId, queryAllByTestId } = renderComponent();
await openModal(getByTestId);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
await userEvent.type(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0], 'env');
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-cancel'));
await openModal(getByTestId);
expect(queryAllByTestId('workflow-settings-custom-telemetry-tags-row')).toHaveLength(0);
expect(emitted('update:modelValue')).toBeUndefined();
});
it('discards draft changes when the dialog is closed', async () => {
const { emitted, getAllByTestId, getByTestId, queryAllByTestId } = renderComponent();
await openModal(getByTestId);
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-add'));
await userEvent.type(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0], 'env');
await userEvent.click(getByTestId('dialog-close-button'));
await openModal(getByTestId);
expect(queryAllByTestId('workflow-settings-custom-telemetry-tags-row')).toHaveLength(0);
expect(emitted('update:modelValue')).toBeUndefined();
});
});
describe('read-only', () => {
it('disables editing controls when read-only', async () => {
const { getAllByTestId, getByTestId } = renderComponent({
props: {
isReadOnly: true,
modelValue: [{ key: 'team', value: 'platform' }],
},
});
await openModal(getByTestId);
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-key')[0]).toBeDisabled();
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-value')[0]).toBeDisabled();
expect(getAllByTestId('workflow-settings-custom-telemetry-tags-delete')[0]).toBeDisabled();
expect(getByTestId('workflow-settings-custom-telemetry-tags-add')).toBeDisabled();
expect(getByTestId('workflow-settings-custom-telemetry-tags-save')).toBeDisabled();
});
});
});

View File

@ -0,0 +1,466 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import {
N8nButton,
N8nDialog,
N8nDialogDescription,
N8nDialogFooter,
N8nDialogHeader,
N8nDialogTitle,
N8nIcon,
N8nIconButton,
N8nInput,
N8nInputLabel,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import type { ICustomTelemetryTag } from 'n8n-workflow';
import { ElCol, ElRow } from 'element-plus';
const OPEN_TELEMETRY_DOCS_URL = 'https://docs.n8n.io/hosting/logging-monitoring/opentelemetry/';
type Props = {
modelValue?: ICustomTelemetryTag[];
isReadOnly: boolean;
saveTags?: (tags: ICustomTelemetryTag[]) => Promise<void> | void;
};
type Emits = {
'update:modelValue': [tags: ICustomTelemetryTag[]];
'validity-change': [hasErrors: boolean];
};
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const i18n = useI18n();
const showModal = ref(false);
const isSaving = ref(false);
const draft = ref<ICustomTelemetryTag[]>([]);
const tags = computed(() => props.modelValue ?? []);
const cloneTags = (tagsToClone: ICustomTelemetryTag[] = []) =>
tagsToClone.map((tag) => ({ ...tag }));
const createEmptyTag = (): ICustomTelemetryTag => ({ key: '', value: '' });
const isEmptyTag = (tag: ICustomTelemetryTag) => !tag.key.trim() && !tag.value.trim();
const getSavedTags = (tagsToSave: ICustomTelemetryTag[]) =>
tagsToSave
.filter((tag) => !isEmptyTag(tag))
.map((tag) => ({ key: tag.key.trim(), value: tag.value }));
const getTagErrors = (tagsToValidate: ICustomTelemetryTag[]) => {
const seen = new Set<string>();
return tagsToValidate.map((tag) => {
const trimmedKey = tag.key.trim();
if (!trimmedKey) return i18n.baseText('workflowSettings.customTelemetryTags.error.emptyKey');
if (seen.has(trimmedKey)) {
return i18n.baseText('workflowSettings.customTelemetryTags.error.duplicateKey');
}
seen.add(trimmedKey);
return null;
});
};
const tagErrors = computed(() => getTagErrors(tags.value));
const draftErrors = computed(() => getTagErrors(draft.value));
const validationError = computed(() => {
const error = tagErrors.value.find((tagError) => tagError !== null);
return error ?? null;
});
const draftValidationError = computed(() => {
const error = draftErrors.value.find((tagError) => tagError !== null);
return error ?? null;
});
const hasTagErrors = computed(() => validationError.value !== null);
const hasDraftErrors = computed(() => draftValidationError.value !== null);
const areControlsDisabled = computed(() => props.isReadOnly || isSaving.value);
const configuredTagsCount = computed(() => tags.value.length);
const hasConfiguredTags = computed(() => configuredTagsCount.value > 0);
const configuredTagsCountLabel = computed(() =>
i18n.baseText('workflowSettings.customTelemetryTags.configuredCount' as BaseTextKey, {
adjustToNumber: configuredTagsCount.value,
interpolate: { count: configuredTagsCount.value },
}),
);
const configureButtonAriaLabel = computed(() => {
const label = `${i18n.baseText('workflowSettings.customTelemetryTags.configure')} ${i18n
.baseText('workflowSettings.customTelemetryTags.displayName')
.toLowerCase()}`;
return hasConfiguredTags.value
? `${label}, ${configuredTagsCountLabel.value.toLowerCase()}`
: label;
});
watch(
hasTagErrors,
(hasErrors) => {
emit('validity-change', hasErrors);
},
{ immediate: true },
);
onBeforeUnmount(() => {
emit('validity-change', false);
});
const updateDraftTag = (index: number, field: keyof ICustomTelemetryTag, value: string) => {
draft.value = draft.value.map((tag, tagIndex) =>
tagIndex === index ? { ...tag, [field]: value } : tag,
);
};
const addTag = () => {
draft.value = [...draft.value, createEmptyTag()];
};
const deleteTag = (index: number) => {
draft.value = draft.value.filter((_, tagIndex) => tagIndex !== index);
};
const openModal = () => {
draft.value = cloneTags(tags.value);
showModal.value = true;
};
const cancelModal = () => {
draft.value = cloneTags(tags.value);
showModal.value = false;
};
const saveModal = async () => {
if (hasDraftErrors.value || isSaving.value) return;
const savedTags = getSavedTags(draft.value);
isSaving.value = true;
try {
await props.saveTags?.(savedTags);
emit('update:modelValue', savedTags);
} catch {
return;
} finally {
isSaving.value = false;
}
showModal.value = false;
};
const onModalOpenChange = (open: boolean) => {
if (open) {
openModal();
return;
}
if (isSaving.value) return;
cancelModal();
};
</script>
<template>
<div :class="$style.wrapper">
<ElRow
:class="$style.customTelemetryTags"
data-test-id="workflow-settings-custom-telemetry-tags"
>
<ElCol :span="10" :class="$style.settingName">
<label>
{{ i18n.baseText('workflowSettings.customTelemetryTags.displayName') }}
<N8nTooltip placement="top">
<template #content>
{{ i18n.baseText('workflowSettings.customTelemetryTags.description') }}
</template>
<N8nIcon icon="circle-help" />
</N8nTooltip>
</label>
</ElCol>
<ElCol :span="14" :class="$style.customTelemetryTagsControl">
<div :class="$style.customTelemetryTagsSummary">
<N8nButton
variant="subtle"
size="large"
native-type="button"
:class="$style.customTelemetryTagsConfigure"
:aria-label="configureButtonAriaLabel"
data-test-id="workflow-settings-custom-telemetry-tags-configure"
@click="openModal"
>
{{ i18n.baseText('workflowSettings.customTelemetryTags.configure') }}
</N8nButton>
<span
v-if="hasConfiguredTags"
:class="$style.customTelemetryTagsCount"
data-test-id="workflow-settings-custom-telemetry-tags-count"
>
{{ configuredTagsCountLabel }}
</span>
</div>
<N8nText
v-if="validationError"
size="small"
color="danger"
tag="p"
:class="$style.customTelemetryTagsError"
data-test-id="workflow-settings-custom-telemetry-tags-error"
>
{{ validationError }}
</N8nText>
</ElCol>
</ElRow>
<N8nDialog :open="showModal" size="large" @update:open="onModalOpenChange">
<N8nDialogHeader>
<div :class="$style.customTelemetryTagsModalTitle">
<N8nIconButton
icon="chevron-left"
variant="ghost"
size="medium"
icon-size="large"
:disabled="isSaving"
:aria-label="i18n.baseText('generic.back')"
@click="cancelModal"
/>
<N8nDialogTitle>
{{ i18n.baseText('workflowSettings.customTelemetryTags.modal.title') }}
</N8nDialogTitle>
</div>
<N8nDialogDescription :class="$style.customTelemetryTagsModalDescription">
{{ i18n.baseText('workflowSettings.customTelemetryTags.description') }}
{{ ' ' }}
{{ i18n.baseText('workflowSettings.customTelemetryTags.modal.learnMore') }}
{{ ' ' }}
<a
:class="$style.customTelemetryTagsDocsLink"
:href="OPEN_TELEMETRY_DOCS_URL"
target="_blank"
>
{{ i18n.baseText('workflowSettings.customTelemetryTags.modal.documentation') }}
<N8nIcon icon="arrow-up-right" size="xsmall" />
</a>
</N8nDialogDescription>
</N8nDialogHeader>
<div
:class="$style.customTelemetryTagsModal"
data-test-id="workflow-settings-custom-telemetry-tags-modal"
>
<div
v-for="(tag, index) in draft"
:key="index"
:class="$style.customTelemetryTagsRow"
data-test-id="workflow-settings-custom-telemetry-tags-row"
>
<N8nInputLabel
:class="$style.customTelemetryTagsField"
:label="
index === 0
? i18n.baseText('workflowSettings.customTelemetryTags.tag.key.displayName')
: undefined
"
size="small"
:input-name="`workflow-custom-telemetry-tag-key-${index}`"
>
<N8nInput
:id="`workflow-custom-telemetry-tag-key-${index}`"
:model-value="tag.key"
size="medium"
:disabled="areControlsDisabled"
:placeholder="
i18n.baseText('workflowSettings.customTelemetryTags.tag.key.placeholder')
"
:aria-label="
i18n.baseText('workflowSettings.customTelemetryTags.tag.key.displayName')
"
data-test-id="workflow-settings-custom-telemetry-tags-key"
@update:model-value="updateDraftTag(index, 'key', String($event))"
/>
</N8nInputLabel>
<N8nInputLabel
:class="$style.customTelemetryTagsField"
:label="
index === 0
? i18n.baseText('workflowSettings.customTelemetryTags.tag.value.displayName')
: undefined
"
size="small"
:input-name="`workflow-custom-telemetry-tag-value-${index}`"
>
<N8nInput
:id="`workflow-custom-telemetry-tag-value-${index}`"
:model-value="tag.value"
size="medium"
:disabled="areControlsDisabled"
:placeholder="
i18n.baseText('workflowSettings.customTelemetryTags.tag.value.placeholder')
"
:aria-label="
i18n.baseText('workflowSettings.customTelemetryTags.tag.value.displayName')
"
data-test-id="workflow-settings-custom-telemetry-tags-value"
@update:model-value="updateDraftTag(index, 'value', String($event))"
/>
</N8nInputLabel>
<N8nIconButton
icon="trash-2"
variant="ghost"
size="small"
:disabled="areControlsDisabled"
:title="i18n.baseText('workflowSettings.customTelemetryTags.delete')"
:aria-label="i18n.baseText('workflowSettings.customTelemetryTags.delete')"
data-test-id="workflow-settings-custom-telemetry-tags-delete"
@click="deleteTag(index)"
/>
</div>
<N8nButton
icon="plus"
variant="subtle"
size="small"
native-type="button"
:disabled="areControlsDisabled"
:class="$style.customTelemetryTagsAdd"
data-test-id="workflow-settings-custom-telemetry-tags-add"
@click="addTag"
>
{{ i18n.baseText('workflowSettings.customTelemetryTags.placeholder') }}
</N8nButton>
<N8nText
v-if="draftValidationError"
size="small"
color="danger"
tag="p"
:class="$style.customTelemetryTagsError"
data-test-id="workflow-settings-custom-telemetry-tags-modal-error"
>
{{ draftValidationError }}
</N8nText>
</div>
<N8nDialogFooter>
<N8nButton
variant="subtle"
:disabled="isSaving"
data-test-id="workflow-settings-custom-telemetry-tags-cancel"
@click="cancelModal"
>
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton
:disabled="areControlsDisabled || hasDraftErrors"
data-test-id="workflow-settings-custom-telemetry-tags-save"
@click="saveModal"
>
{{ i18n.baseText('generic.save') }}
</N8nButton>
</N8nDialogFooter>
</N8nDialog>
</div>
</template>
<style module lang="scss">
.wrapper {
display: contents;
}
.customTelemetryTags {
margin-top: var(--spacing--xs);
align-items: flex-start;
}
.settingName {
&,
& label {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
svg {
display: inline-flex;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover {
svg {
opacity: 1;
}
}
}
.customTelemetryTagsControl {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.customTelemetryTagsSummary {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
.customTelemetryTagsCount {
color: inherit;
font-family: var(--font-family);
font-size: var(--font-size--sm);
font-weight: var(--font-weight--regular);
}
.customTelemetryTagsConfigure {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
}
.customTelemetryTagsModalTitle {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
.customTelemetryTagsModalDescription {
margin-top: var(--spacing--3xs);
}
.customTelemetryTagsDocsLink {
color: inherit;
text-decoration: underline;
display: inline-flex;
align-items: center;
gap: var(--spacing--5xs);
&:hover {
color: var(--color--primary);
}
}
.customTelemetryTagsModal {
margin-top: var(--spacing--sm);
}
.customTelemetryTagsRow {
display: flex;
align-items: flex-end;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--2xs);
}
.customTelemetryTagsField {
flex: 1;
min-width: 0;
}
.customTelemetryTagsAdd {
margin-top: var(--spacing--5xs);
}
.customTelemetryTagsError {
margin-top: var(--spacing--2xs);
}
</style>

View File

@ -10,7 +10,7 @@ import { createTestWorkflow } from '@/__tests__/mocks';
import { getDropdownItems, mockedStore, type MockedStore } from '@/__tests__/utils';
import { EnterpriseEditionFeature } from '@/app/constants';
import { useRBACStore } from '@/app/stores/rbac.store';
import WorkflowSettingsVue from '@/app/components/WorkflowSettings.vue';
import WorkflowSettingsVue from '@/app/components/WorkflowSettings/WorkflowSettings.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useSettingsStore } from '@/app/stores/settings.store';
@ -82,20 +82,77 @@ let pinia: ReturnType<typeof createTestingPinia>;
let searchWorkflowsSpy: MockInstance<(typeof workflowsListStore)['searchWorkflows']>;
let workflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore>;
const workflowSettingsStubs = {
Modal: {
template:
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
// Stub ElSwitch to prevent spurious update:model-value emissions in jsdom.
// userEvent.click simulates pointer movement that can trigger the switch
// during mouse path traversal, toggling executionTimeout and breaking save.
ElSwitch: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template:
'<button type="button" :data-test-id="$attrs[\'data-test-id\']" :aria-checked="!!modelValue" role="switch" :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)" />',
},
ParameterInputFull: {
props: ['value', 'isReadOnly', 'path'],
emits: ['update'],
template: `
<input
:data-test-id="$attrs['data-test-id']"
:value="value"
:disabled="isReadOnly"
@input="$emit('update', { name: path, value: $event.target.value })"
/>
`,
},
};
const createComponent = createComponentRenderer(WorkflowSettingsVue, {
global: {
stubs: workflowSettingsStubs,
},
});
const createComponentWithCustomTelemetryTagsStub = createComponentRenderer(WorkflowSettingsVue, {
global: {
stubs: {
Modal: {
template:
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
// Stub ElSwitch to prevent spurious update:model-value emissions in jsdom.
// userEvent.click simulates pointer movement that can trigger the switch
// during mouse path traversal, toggling executionTimeout and breaking save.
ElSwitch: {
props: ['modelValue', 'disabled'],
template:
'<span :data-test-id="$attrs[\'data-test-id\']" :aria-checked="!!modelValue" role="switch" />',
...workflowSettingsStubs,
WorkflowCustomTelemetryTags: {
props: ['modelValue', 'isReadOnly', 'saveTags'],
emits: ['update:modelValue', 'validity-change'],
methods: {
async saveCustomTelemetryTags() {
const tags = [{ key: 'env', value: 'production' }];
try {
await this.saveTags(tags);
} catch {
return;
}
this.$emit('update:modelValue', tags);
},
},
template: `
<div data-test-id="workflow-settings-custom-telemetry-tags">
<button
type="button"
data-test-id="workflow-settings-custom-telemetry-tags-update"
@click="$emit('update:modelValue', [{ key: 'env', value: 'production' }])"
/>
<button
type="button"
data-test-id="workflow-settings-custom-telemetry-tags-save-immediate"
@click="saveCustomTelemetryTags"
/>
<button
type="button"
data-test-id="workflow-settings-custom-telemetry-tags-invalid"
@click="$emit('validity-change', true)"
/>
</div>
`,
},
},
},
@ -178,6 +235,88 @@ describe('WorkflowSettingsVue', () => {
expect(getByTestId('workflow-caller-policy')).toBeVisible();
});
describe('Custom telemetry tags', () => {
beforeEach(() => {
settingsStore.settings.activeModules = ['dynamic-credentials', 'otel'];
settingsStore.moduleSettings = { otel: { enabled: true } };
});
it('should show custom telemetry tag settings when OTel is enabled', async () => {
const { getByTestId } = createComponentWithCustomTelemetryTagsStub({ pinia });
await flushPromises();
expect(getByTestId('workflow-settings-custom-telemetry-tags')).toBeVisible();
});
it('should hide custom telemetry tag settings when OTel is disabled', async () => {
settingsStore.moduleSettings = { otel: { enabled: false } };
const { queryByTestId } = createComponentWithCustomTelemetryTagsStub({ pinia });
await flushPromises();
expect(queryByTestId('workflow-settings-custom-telemetry-tags')).not.toBeInTheDocument();
});
it('should save workflow settings with custom telemetry tags emitted by the child', async () => {
const { getByTestId, getByRole } = createComponentWithCustomTelemetryTagsStub({ pinia });
await flushPromises();
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-update'));
await userEvent.click(getByRole('button', { name: 'Save' }));
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
settings: expect.objectContaining({
customTelemetryTags: [{ key: 'env', value: 'production' }],
}),
}),
);
});
it('should persist custom telemetry tags immediately with a partial settings payload', async () => {
workflowDocumentStore.setChecksum('test-checksum');
const { getByTestId } = createComponentWithCustomTelemetryTagsStub({ pinia });
await flushPromises();
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save-immediate'));
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith('1', {
settings: {
customTelemetryTags: [{ key: 'env', value: 'production' }],
},
expectedChecksum: 'test-checksum',
});
expect(workflowDocumentStore.settings.customTelemetryTags).toEqual([
{ key: 'env', value: 'production' },
]);
});
it('should show an error when immediate custom telemetry tag persistence fails', async () => {
const error = new Error('Save failed');
workflowsStore.updateWorkflow.mockRejectedValue(error);
const { getByTestId } = createComponentWithCustomTelemetryTagsStub({ pinia });
await flushPromises();
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-save-immediate'));
await waitFor(() => {
expect(toast.showError).toHaveBeenCalledWith(error, 'Problem saving settings');
});
expect(workflowDocumentStore.settings.customTelemetryTags).toBeUndefined();
});
it('should disable workflow settings save when custom telemetry tags are invalid', async () => {
const { getByTestId, getByRole } = createComponentWithCustomTelemetryTagsStub({ pinia });
await flushPromises();
await userEvent.click(getByTestId('workflow-settings-custom-telemetry-tags-invalid'));
expect(getByRole('button', { name: 'Save' })).toBeDisabled();
});
});
it('should render list of workflows field when policy is set to workflowsFromAList', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = true;
const { getByTestId } = createComponent({ pinia });

View File

@ -27,7 +27,11 @@ import {
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import type { WorkflowSettings, WorkflowSettingsBinaryMode } from 'n8n-workflow';
import type {
ICustomTelemetryTag,
WorkflowSettings,
WorkflowSettingsBinaryMode,
} from 'n8n-workflow';
import { BINARY_MODE_COMBINED, BINARY_MODE_SEPARATE } from 'n8n-workflow';
import { SYSTEM_RESOLVER_ID } from '@n8n/api-types';
import { useSettingsStore } from '@/app/stores/settings.store';
@ -57,6 +61,7 @@ import { useRedactionEnforcementFeatureFlag } from '@/features/redaction-enforce
import * as securitySettingsApi from '@n8n/rest-api-client/api/security-settings';
import type { RedactionFloor } from '@n8n/api-types';
import { hasPermission } from '@/app/utils/rbac/permissions';
import WorkflowCustomTelemetryTags from '@/app/components/WorkflowSettings/WorkflowCustomTelemetryTags.vue';
import { ElCol, ElRow, ElSwitch } from 'element-plus';
@ -94,6 +99,7 @@ const workflowsEEStore = useWorkflowsEEStore();
const nodeCreatorStore = useNodeCreatorStore();
const posthogStore = usePostHog();
const isLoading = ref(true);
const hasCustomTelemetryTagErrors = ref(false);
const workflowCallerPolicyOptions = ref<Array<{ key: string; value: string }>>([]);
const redactionToggleOptions = ref<Array<{ key: string; value: string }>>([
{
@ -289,6 +295,10 @@ const workflowHasDynamicCredentials = computed(
() => isCredentialResolverEnabled.value && !!workflowSettings.value.credentialResolverId,
);
const isWorkflowSettingsReadOnly = computed(
() => readOnlyEnv.value || !workflowPermissions.value.update,
);
/**
* Maps the two independent redaction toggles to/from the single `redactionPolicy` field.
*
@ -645,7 +655,22 @@ const convertToHMS = (num: number): ITimeoutHMS => {
return { hours: 0, minutes: 0, seconds: 0 };
};
const saveCustomTelemetryTags = async (customTelemetryTags: ICustomTelemetryTag[]) => {
try {
await workflowsStore.updateWorkflow(String(route.params.workflowId), {
settings: { customTelemetryTags },
expectedChecksum: workflowDocumentStore.value.checksum,
});
workflowDocumentStore.value.mergeSettings({ customTelemetryTags });
} catch (error) {
toast.showError(error, i18n.baseText('workflowSettings.showError.saveSettings3.title'));
throw error;
}
};
const saveSettings = async () => {
if (hasCustomTelemetryTagErrors.value) return;
// Set that the active state should be changed
const data: WorkflowDataUpdate & { settings: IWorkflowSettings } = {
settings: workflowSettings.value,
@ -1682,12 +1707,19 @@ onBeforeUnmount(() => {
</div>
</ElCol>
</ElRow>
<WorkflowCustomTelemetryTags
v-if="settingsStore.isOtelEnabled"
v-model="workflowSettings.customTelemetryTags"
:is-read-only="isWorkflowSettingsReadOnly"
:save-tags="saveCustomTelemetryTags"
@validity-change="hasCustomTelemetryTagErrors = $event"
/>
</div>
</template>
<template #footer>
<div :class="$style['action-buttons']" data-test-id="workflow-settings-save-button">
<N8nButton
:disabled="readOnlyEnv || !workflowPermissions.update"
:disabled="readOnlyEnv || !workflowPermissions.update || hasCustomTelemetryTagErrors"
:label="i18n.baseText('workflowSettings.save')"
size="large"
float="right"

View File

@ -239,7 +239,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
pinData: workflowDocumentPinData.getPinDataSnapshot(),
connections,
active: workflowDocumentActive.active.value,
settings: workflowDocumentSettings.settings.value,
settings: workflowDocumentSettings.getSettingsSnapshot(),
tags: [...workflowDocumentTags.tags.value],
versionId: workflowDocumentVersionData.versionId.value,
meta: workflowDocumentMeta.meta.value,
@ -375,7 +375,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
updatedAt: workflowDocumentTimestamps.updatedAt.value,
nodes: workflowDocumentNodes.allNodes.value,
connections: workflowDocumentConnections.connectionsBySourceNode.value,
settings: { ...DEFAULT_SETTINGS, ...workflowDocumentSettings.settings.value },
settings: { ...DEFAULT_SETTINGS, ...workflowDocumentSettings.getSettingsSnapshot() },
tags: [...workflowDocumentTags.tags.value],
pinData: workflowDocumentPinData.getPinDataSnapshot(),
sharedWithProjects: (workflowDocumentSharedWithProjects.sharedWithProjects.value ??

View File

@ -548,7 +548,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(id));
if (isCurrentWorkflow) {
currentSettings = workflowDocumentStore.settings;
currentSettings = workflowDocumentStore.getSettingsSnapshot();
currentVersionId = workflowDocumentStore.versionId;
currentChecksum = workflowDocumentStore.checksum;
} else {

View File

@ -57,7 +57,7 @@ export function getNodeSettingsInitialValues(): INodeParameters {
export function setValue(
nodeValues: Ref<INodeParameters>,
name: string,
value: NodeParameterValue,
value: NodeParameterValueType,
) {
const nameParts = name.split('.');
let lastNamePart: string | undefined = nameParts.pop();

View File

@ -1386,6 +1386,11 @@ export interface INodeCredentials {
[key: string]: INodeCredentialsDetails;
}
export type ICustomTelemetryTag = {
key: string;
value: string;
};
export type OnError = 'continueErrorOutput' | 'continueRegularOutput' | 'stopWorkflow';
export interface INode {
id: string;
@ -1404,7 +1409,7 @@ export interface INode {
onError?: OnError;
continueOnFail?: boolean;
customTelemetryTags?: {
tag?: Array<{ key: string; value: string }>;
tag?: ICustomTelemetryTag[];
};
parameters: INodeParameters;
credentials?: INodeCredentials;
@ -3344,6 +3349,7 @@ export interface IWorkflowSettings {
availableInMCP?: boolean;
credentialResolverId?: string;
redactionPolicy?: WorkflowSettings.RedactionPolicy;
customTelemetryTags?: ICustomTelemetryTag[];
}
export interface WorkflowFEMeta {