mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 18:49:20 +02:00
feat: Add workflow-level telemetry tags (#30948)
This commit is contained in:
parent
7c138e12a9
commit
dbe395202b
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user