feat(core): Add custom OpenTelemetry tags per node (#30442)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Irénée 2026-05-19 09:09:29 +01:00 committed by GitHub
parent 76cdf993bf
commit e39f233af7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 827 additions and 20 deletions

View File

@ -318,6 +318,14 @@ export type FrontendModuleSettings = {
systemRolesEnabled: boolean;
};
/**
* Client settings for the OpenTelemetry module.
*/
otel?: {
/** Whether OpenTelemetry tracing is enabled on this instance. */
enabled: boolean;
};
/**
* Client settings for the agents module.
*/

View File

@ -16,6 +16,8 @@ import type { OtelTestProvider } from './support/otel-test-provider';
import type { WorkflowRunner } from '@/workflow-runner';
import type { ExecutionRepository } from '@n8n/db';
import { createTeamProject, createWorkflow } from '@n8n/backend-test-utils';
import { NodeConnectionTypes } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
let otel: OtelTestProvider;
let workflowRunner: WorkflowRunner;
@ -94,3 +96,151 @@ describe('OTEL Workflow Tracing Integration', () => {
expect(workflowSpan.spanContext().traceId).toBe(inboundTraceId);
});
});
describe('Custom Telemetry Tags', () => {
const createWorkflowWithCustomTagsFixture = () => ({
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: { category: 'doNothing' },
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [200, 0] as [number, number],
id: uuid(),
name: 'DebugHelper',
customTelemetryTags: {
tag: [
{ key: 'environment', value: 'production' },
{ key: 'team', value: 'backend' },
{ key: 'env', value: '={{ $json.env }}' },
],
},
},
],
connections: {
Trigger: {
main: [
[
{
node: 'DebugHelper',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
},
pinData: {},
});
const createMultiNodeCustomTagsFixture = () => ({
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: { category: 'doNothing' },
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [200, 0] as [number, number],
id: uuid(),
name: 'HelperA',
customTelemetryTags: {
tag: [{ key: 'service', value: 'auth' }],
},
},
{
parameters: { category: 'doNothing' },
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [400, 0] as [number, number],
id: uuid(),
name: 'HelperB',
customTelemetryTags: {
tag: [{ key: 'tier', value: 'premium' }],
},
},
],
connections: {
Trigger: {
main: [
[
{
node: 'HelperA',
type: NodeConnectionTypes.Main,
index: 0,
},
{
node: 'HelperB',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
},
pinData: {},
});
it('should attach static custom telemetry tags as node span attributes', async () => {
const project = await createTeamProject();
const workflow = await createWorkflow(createWorkflowWithCustomTagsFixture(), project);
const executionId = await executeWorkflow(workflowRunner, workflow, project.id);
await waitForExecution(executionRepository, executionId);
const nodeSpan = otel
.getFinishedSpans()
.find((s) => s.name === 'node.execute' && s.attributes['n8n.node.name'] === 'DebugHelper')!;
expect(nodeSpan).toBeDefined();
expect(nodeSpan.attributes['n8n.node.custom.environment']).toBe('production');
expect(nodeSpan.attributes['n8n.node.custom.team']).toBe('backend');
});
it('should evaluate expression-based custom telemetry tags', async () => {
const project = await createTeamProject();
const workflow = await createWorkflow(createWorkflowWithCustomTagsFixture(), project);
const executionId = await executeWorkflow(workflowRunner, workflow, project.id, {
triggerData: { env: 'staging' },
});
await waitForExecution(executionRepository, executionId);
const nodeSpan = otel
.getFinishedSpans()
.find((s) => s.name === 'node.execute' && s.attributes['n8n.node.name'] === 'DebugHelper')!;
expect(nodeSpan).toBeDefined();
expect(nodeSpan.attributes['n8n.node.custom.env']).toBe('staging');
expect(nodeSpan.attributes['n8n.node.custom.environment']).toBe('production');
});
it('should attach custom tags to the correct node spans in a multi-node workflow', async () => {
const project = await createTeamProject();
const workflow = await createWorkflow(createMultiNodeCustomTagsFixture(), project);
const executionId = await executeWorkflow(workflowRunner, workflow, project.id);
await waitForExecution(executionRepository, executionId);
const spans = otel.getFinishedSpans().filter((s) => s.name === 'node.execute');
const helperA = spans.find((s) => s.attributes['n8n.node.name'] === 'HelperA')!;
const helperB = spans.find((s) => s.attributes['n8n.node.name'] === 'HelperB')!;
expect(helperA).toBeDefined();
expect(helperB).toBeDefined();
expect(helperA.attributes['n8n.node.custom.service']).toBe('auth');
expect(helperA.attributes['n8n.node.custom.tier']).toBeUndefined();
expect(helperB.attributes['n8n.node.custom.tier']).toBe('premium');
expect(helperB.attributes['n8n.node.custom.service']).toBeUndefined();
});
});

View File

@ -9,7 +9,7 @@ import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger.
import { TestNodeWithTracing } from './test-node-with-tracing';
import { createRunExecutionData } from 'n8n-workflow';
import type { INodeType, INodeTypeData, NodeLoadingDetails } from 'n8n-workflow';
import type { IDataObject, INodeType, INodeTypeData, NodeLoadingDetails } from 'n8n-workflow';
import { readFileSync } from 'fs';
import path from 'path';
@ -103,16 +103,17 @@ export async function executeWorkflow(
mode?: 'webhook' | 'trigger' | 'manual' | 'retry';
retryOf?: string;
tracingContext?: { traceparent: string; tracestate?: string };
triggerData?: IDataObject;
} = {},
): Promise<string> {
const { mode = 'webhook', retryOf, tracingContext } = options;
const { mode = 'webhook', retryOf, tracingContext, triggerData } = options;
const triggerNode = workflow.nodes.find((n) => n.type === 'n8n-nodes-base.manualTrigger')!;
const executionData = createRunExecutionData({
executionData: {
nodeExecutionStack: [
{
node: triggerNode,
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
data: { main: [[{ json: triggerData ?? {}, pairedItem: { item: 0 } }]] },
source: null,
},
],

View File

@ -248,7 +248,7 @@ function buildNodeEndAttributes(params: EndNodeParams): Record<string, string |
if (params.customAttributes) {
for (const [key, value] of Object.entries(params.customAttributes)) {
attrs[`n8n.node.custom.${key}`] = value;
attrs[`${ATTR.NODE_CUSTOM_PREFIX}${key}`] = value;
}
}

View File

@ -26,6 +26,7 @@ export const ATTR = {
NODE_ITEMS_INPUT: 'n8n.node.items.input',
NODE_ITEMS_OUTPUT: 'n8n.node.items.output',
NODE_TERMINATION_REASON: 'n8n.node.termination_reason',
NODE_CUSTOM_PREFIX: 'n8n.node.custom.',
CONTINUATION_REASON: 'n8n.continuation.reason',
} as const;

View File

@ -19,6 +19,13 @@ export class OtelModule implements ModuleInterface {
await import('./otel-lifecycle-handler');
}
async settings() {
const { OtelConfig } = await import('./otel.config');
const config = Container.get(OtelConfig);
return { enabled: config.enabled };
}
async context(): Promise<ModuleContext> {
const { OtelConfig } = await import('./otel.config');
const config = Container.get(OtelConfig);

View File

@ -29,12 +29,16 @@ jest.mock('@/errors/error-reporter', () => ({
},
}));
jest.mock('../node-execution-context', () => ({
ExecuteContext: jest.fn().mockImplementation(() => ({
hints: [],
})),
PollContext: jest.fn().mockImplementation(() => ({})),
}));
jest.mock('../node-execution-context', () => {
const actual = jest.requireActual('../node-execution-context');
return {
...actual,
ExecuteContext: jest.fn().mockImplementation(() => ({
hints: [],
})),
PollContext: jest.fn().mockImplementation(() => ({})),
};
});
jest.mock('../triggers-and-pollers', () => ({
TriggersAndPollers: jest.fn(),
@ -1300,4 +1304,295 @@ describe('WorkflowExecute.runNode - Real Implementation', () => {
expect(result).toEqual({ data: undefined });
});
});
describe('customTelemetryTags', () => {
let getParameterValue: jest.Mock;
beforeEach(() => {
getParameterValue = jest.fn();
mockWorkflow.expression = {
getParameterValue,
} as unknown as Workflow['expression'];
mockAdditionalData.webhookWaitingBaseUrl = 'https://n8n.local/webhook-waiting';
mockAdditionalData.formWaitingBaseUrl = 'https://n8n.local/form-waiting';
mockAdditionalData.variables = {};
mockNodeType.execute = jest.fn().mockResolvedValue([[{ json: {} }]]);
const mockContextInstance = { hints: [] };
mockExecuteContext.mockImplementation(() => mockContextInstance as unknown as ExecuteContext);
});
const makeTelemetryExecutionData = (overrides: Partial<IExecuteData> = {}): IExecuteData => ({
...mockExecutionData,
data: { main: [[{ json: { env: 'prod' } }]] },
source: null,
...overrides,
});
const runNodeForTelemetry = async (executionData: IExecuteData) => {
await workflowExecute.runNode(
mockWorkflow,
executionData,
mockRunExecutionData,
0,
mockAdditionalData,
'manual',
);
};
it('evaluates tag expressions and writes them into metadata.tracing', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [
{ key: 'env', value: '={{ $json.env }}' },
{ key: 'static', value: 'foo' },
],
},
};
getParameterValue.mockImplementation((value: string) =>
value === '={{ $json.env }}' ? 'prod' : value,
);
const executionData = makeTelemetryExecutionData({ node });
await runNodeForTelemetry(executionData);
expect(executionData.metadata?.tracing).toEqual({ env: 'prod', static: 'foo' });
});
it('preserves existing tracing entries on key collision', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [{ key: 'env', value: 'user-set' }],
},
};
getParameterValue.mockReturnValue('user-set');
const executionData = makeTelemetryExecutionData({
node,
metadata: { tracing: { env: 'node-authored' } },
});
await runNodeForTelemetry(executionData);
expect(executionData.metadata?.tracing).toEqual({ env: 'node-authored' });
});
it('skips tags with empty or whitespace keys', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [
{ key: ' ', value: 'ignored' },
{ key: 'kept', value: 'value' },
],
},
};
getParameterValue.mockImplementation((value: string) => value);
const executionData = makeTelemetryExecutionData({ node });
await runNodeForTelemetry(executionData);
expect(executionData.metadata?.tracing).toEqual({ kept: 'value' });
});
it('preserves string, number, and boolean evaluated values', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [
{ key: 'count', value: '={{ 42 }}' },
{ key: 'enabled', value: '={{ true }}' },
],
},
};
getParameterValue.mockImplementation((value: string) => (value === '={{ 42 }}' ? 42 : true));
const executionData = makeTelemetryExecutionData({ node });
await runNodeForTelemetry(executionData);
expect(executionData.metadata?.tracing).toEqual({ count: 42, enabled: true });
});
it('skips tags when expression evaluates to a non-primitive value', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [
{ key: 'obj', value: '={{ $json }}' },
{ key: 'ok', value: 'still-here' },
],
},
};
getParameterValue.mockImplementation((value: string) =>
value === '={{ $json }}' ? { nested: 1 } : value,
);
const executionData = makeTelemetryExecutionData({ node });
await runNodeForTelemetry(executionData);
expect(executionData.metadata?.tracing).toEqual({ ok: 'still-here' });
});
it('ignores tags whose expression evaluates to null or undefined', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [
{ key: 'maybe', value: '={{ $json.missing }}' },
{ key: 'definitely', value: 'value' },
],
},
};
getParameterValue.mockImplementation((value: string) =>
value === '={{ $json.missing }}' ? undefined : value,
);
const executionData = makeTelemetryExecutionData({ node });
await runNodeForTelemetry(executionData);
expect(executionData.metadata?.tracing).toEqual({ definitely: 'value' });
});
it('does not modify metadata when customTelemetryTags is absent', async () => {
const executionData = makeTelemetryExecutionData();
await runNodeForTelemetry(executionData);
expect(executionData.metadata).toBeUndefined();
});
it('continues evaluating remaining tags after one expression throws', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [
{ key: 'broken', value: '={{ $json.missing.deep }}' },
{ key: 'ok', value: 'value' },
],
},
};
getParameterValue.mockImplementation((value: string) => {
if (value === '={{ $json.missing.deep }}') throw new Error('boom');
return value;
});
const executionData = makeTelemetryExecutionData({ node });
await runNodeForTelemetry(executionData);
expect(executionData.metadata?.tracing).toEqual({ ok: 'value' });
});
it('writes tracing for trigger nodes', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [{ key: 'env', value: 'prod' }],
},
};
getParameterValue.mockReturnValue('prod');
mockNodeType.trigger = jest.fn();
mockNodeType.execute = undefined;
mockNodeType.poll = undefined;
mockNodeType.webhook = undefined;
const mockTriggersAndPollersInstance = {
runTrigger: jest.fn().mockResolvedValue({
manualTriggerResponse: Promise.resolve([[{ json: { triggered: 'data' } }]]),
}),
};
mockContainer.get.mockImplementation((token) => {
if (token === TriggersAndPollers) return mockTriggersAndPollersInstance;
return { sentry: { backendDsn: '' } };
});
const executionData = makeTelemetryExecutionData({ node });
await runNodeForTelemetry(executionData);
expect(executionData.metadata?.tracing).toEqual({ env: 'prod' });
});
it('writes tracing for poll nodes in non-manual mode', async () => {
const node: INode = {
...mockNode,
customTelemetryTags: {
tag: [{ key: 'env', value: 'staging' }],
},
};
getParameterValue.mockReturnValue('staging');
mockNodeType.poll = jest.fn();
mockNodeType.execute = undefined;
const executionData = makeTelemetryExecutionData({ node });
await workflowExecute.runNode(
mockWorkflow,
executionData,
mockRunExecutionData,
0,
mockAdditionalData,
'trigger',
);
expect(executionData.metadata?.tracing).toEqual({ env: 'staging' });
});
it('writes tracing when execution uses a custom operation instead of execute()', async () => {
const mockData = [[{ json: { result: 'custom operation result' } }]];
const mockCustomOperation = jest.fn().mockResolvedValue(mockData);
const customOpNode: INode = {
...mockNode,
parameters: {
resource: 'testResource',
operation: 'testOperation',
},
customTelemetryTags: {
tag: [{ key: 'env', value: '={{ $json.env }}' }],
},
};
const customOpNodeType = {
...mockNodeType,
customOperations: {
testResource: {
testOperation: mockCustomOperation,
},
},
execute: undefined,
};
mockWorkflow.nodeTypes.getByNameAndVersion = jest.fn().mockReturnValue(customOpNodeType);
getParameterValue.mockImplementation((value: string) =>
value === '={{ $json.env }}' ? 'prod' : value,
);
const executionData = makeTelemetryExecutionData({ node: customOpNode });
const result = await workflowExecute.runNode(
mockWorkflow,
executionData,
mockRunExecutionData,
0,
mockAdditionalData,
'manual',
);
expect(mockCustomOperation).toHaveBeenCalled();
expect(result).toEqual({ data: mockData, hints: [] });
expect(executionData.metadata?.tracing).toEqual({ env: 'prod' });
});
});
});

View File

@ -65,7 +65,12 @@ import { assertExecutionDataExists } from '@/utils/assertions';
import { establishExecutionContext } from './execution-context';
import type { ExecutionLifecycleHooks } from './execution-lifecycle-hooks';
import { ExecuteContext, PollContext, resolveSourceOverwrite } from './node-execution-context';
import {
ExecuteContext,
getAdditionalKeys,
PollContext,
resolveSourceOverwrite,
} from './node-execution-context';
import {
DirectedGraph,
findStartNodes,
@ -1089,6 +1094,71 @@ export class WorkflowExecute {
return { data, hints: context.hints };
}
private buildCustomTelemetryTracing(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
runExecutionData: IRunExecutionData,
runIndex: number,
connectionInputData: INodeExecutionData[],
executionData: IExecuteData,
): NonNullable<ITaskMetadata['tracing']> | undefined {
const tags = node.customTelemetryTags?.tag;
if (!tags?.length) return;
const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData);
const tracing: NonNullable<ITaskMetadata['tracing']> = {};
for (const { key, value } of tags) {
const trimmedKey = key?.trim();
if (!trimmedKey) continue;
try {
const evaluated = workflow.expression.getParameterValue(
value,
runExecutionData,
runIndex,
0,
node.name,
connectionInputData,
mode,
additionalKeys,
executionData,
false,
{},
);
if (evaluated === undefined || evaluated === null) continue;
if (
typeof evaluated !== 'string' &&
typeof evaluated !== 'number' &&
typeof evaluated !== 'boolean'
) {
Logger.warn(
'customTelemetryTags expression resolved to a non-primitive value; skipping',
{
nodeName: node.name,
tagKey: trimmedKey,
},
);
continue;
}
tracing[trimmedKey] = evaluated;
} catch (error) {
// failing to evaluate a tag expression is not a critical error and should not block the execution
Logger.warn('Failed to evaluate customTelemetryTags expression', {
nodeName: node.name,
tagKey: trimmedKey,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (Object.keys(tracing).length === 0) return;
return tracing;
}
/**
* Executes a poll node
*/
@ -1235,6 +1305,24 @@ export class WorkflowExecute {
inputData = this.handleExecuteOnce(node, inputData);
const tracingFromTags = this.buildCustomTelemetryTracing(
workflow,
node,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
executionData,
);
if (tracingFromTags !== undefined) {
executionData.metadata = {
...(executionData.metadata ?? {}),
tracing: { ...tracingFromTags, ...(executionData.metadata?.tracing ?? {}) },
};
}
if (nodeType.execute || customOperation) {
return await this.executeNode(
workflow,

View File

@ -2169,6 +2169,12 @@
"nodeSettings.alwaysOutputData.description": "If active, will output a single, empty item when the output would have been empty. Use to prevent the workflow finishing on this node.",
"nodeSettings.alwaysOutputData.displayName": "Always Output Data",
"nodeSettings.clickOnTheQuestionMarkIcon": "Click the '?' icon to open this node on n8n.io",
"nodeSettings.customTelemetryTags.displayName": "Custom Telemetry Tags",
"nodeSettings.customTelemetryTags.description": "Add custom tags that will be attached to this node's OpenTelemetry spans. Values support expressions.",
"nodeSettings.customTelemetryTags.placeholder": "Add Tag",
"nodeSettings.customTelemetryTags.tag.displayName": "Tag",
"nodeSettings.customTelemetryTags.tag.key.displayName": "Key",
"nodeSettings.customTelemetryTags.tag.value.displayName": "Value",
"nodeSettings.onError.description": "Action to take when the node execution fails",
"nodeSettings.onError.displayName": "On Error",
"nodeSettings.onError.options.continueRegularOutput.description": "Pass error message as item in regular output",

View File

@ -238,4 +238,45 @@ describe('settings.store', () => {
});
});
});
describe('isOtelEnabled', () => {
it('should return false when otel module is not active', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
activeModules: [],
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
settingsStore.moduleSettings = { otel: { enabled: true } };
expect(settingsStore.isOtelEnabled).toBe(false);
});
it('should return false when otel module is active but not enabled in moduleSettings', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
activeModules: ['otel'],
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
settingsStore.moduleSettings = { otel: { enabled: false } };
expect(settingsStore.isOtelEnabled).toBe(false);
});
it('should return true when otel module is active and enabled', async () => {
getSettings.mockResolvedValueOnce({
...mockSettings,
activeModules: ['otel'],
});
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
settingsStore.moduleSettings = { otel: { enabled: true } };
expect(settingsStore.isOtelEnabled).toBe(true);
});
});
});

View File

@ -170,6 +170,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
() => isModuleActive('chat-hub') && moduleSettings.value['chat-hub']?.enabled !== false,
);
const isOtelEnabled = computed(
() => isModuleActive('otel') === true && moduleSettings.value.otel?.enabled === true,
);
// Opt-in flag: the `node-tools-searcher` token must be listed in the backend
// `N8N_AGENTS_MODULES` env var for this to evaluate true.
const isAgentsNodeToolsFeatureEnabled = computed(() =>
@ -469,6 +473,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isAgentModuleActive,
isDataTableFeatureEnabled,
isChatFeatureEnabled,
isOtelEnabled,
isAgentsNodeToolsFeatureEnabled,
isPublicChatTriggerDisabled,
};

View File

@ -158,7 +158,8 @@ const hiddenIssuesInputs = ref<string[]>([]);
const subConnections = ref<InstanceType<typeof NDVSubConnections> | null>(null);
const isDemoRoute = computed(() => route?.name === VIEWS.DEMO);
const { isPreviewMode } = useSettingsStore();
const settingsStore = useSettingsStore();
const { isPreviewMode } = settingsStore;
const isDemoPreview = computed(() => isDemoRoute.value && isPreviewMode);
const currentWorkflow = computed(() =>
workflowsListStore.getWorkflowById(workflowsStore.workflowId),
@ -434,6 +435,19 @@ const valueChanged = (parameterData: IUpdateInformation) => {
_node,
isToolNode.value,
);
} else if (parameterData.name.includes('.') || parameterData.name.includes('[')) {
// A nested property on the node itself changed (e.g. a fixedCollection setting
// like `customTelemetryTags.tag`). Update the nested path in `nodeValues`,
// then persist the whole top-level field back to the node.
const topLevelKey = parameterData.name.split(/[.[]/)[0];
const valueForSetter = newValue === undefined ? null : newValue;
nodeSettingsParameters.setValue(nodeValues, parameterData.name, valueForSetter);
workflowDocumentStore?.value?.setNodeValue({
name: _node.name,
key: topLevelKey,
value: nodeValues.value[topLevelKey] as NodeParameterValue,
});
} else {
// A property on the node itself changed
@ -488,7 +502,11 @@ const populateHiddenIssuesSet = () => {
};
const nodeSettings = computed(() =>
createCommonNodeSettings(isToolNode.value || isModelNode.value, i18n.baseText.bind(i18n)),
createCommonNodeSettings(
isToolNode.value || isModelNode.value,
i18n.baseText.bind(i18n),
settingsStore.isOtelEnabled,
),
);
const iconSource = useNodeIconSource(nodeType, node);

View File

@ -18,6 +18,7 @@ import {
setValue,
shouldSkipParamValidation,
createCommonNodeSettings,
collectSettings,
} from './ndv.utils';
import { CUSTOM_API_CALL_KEY, SWITCH_NODE_TYPE } from '@/app/constants';
import type { INodeUi, IUpdateInformation } from '@/Interface';
@ -595,6 +596,78 @@ describe('createCommonNodeSettings', () => {
expect(names).toContain('notesInFlow');
}
});
it('should not include customTelemetryTags when isOtelEnabled is false', () => {
const regularSettings = createCommonNodeSettings(false, mockT, false);
const toolSettings = createCommonNodeSettings(true, mockT, false);
expect(regularSettings.map((s) => s.name)).not.toContain('customTelemetryTags');
expect(toolSettings.map((s) => s.name)).not.toContain('customTelemetryTags');
});
it('should not include customTelemetryTags when isOtelEnabled is omitted', () => {
const settings = createCommonNodeSettings(false, mockT);
expect(settings.map((s) => s.name)).not.toContain('customTelemetryTags');
});
it('should include customTelemetryTags as the last setting when isOtelEnabled is true', () => {
const regularSettings = createCommonNodeSettings(false, mockT, true);
const toolSettings = createCommonNodeSettings(true, mockT, true);
for (const settings of [regularSettings, toolSettings]) {
expect(settings[settings.length - 1].name).toBe('customTelemetryTags');
}
});
it('should configure customTelemetryTags with non-expression key and expression-capable value', () => {
const settings = createCommonNodeSettings(false, mockT, true);
const tagsSetting = settings.find((s) => s.name === 'customTelemetryTags');
expect(tagsSetting).toBeDefined();
expect(tagsSetting?.type).toBe('fixedCollection');
expect(tagsSetting?.typeOptions).toEqual({ multipleValues: true, sortable: true });
expect(tagsSetting?.isNodeSetting).toBe(true);
const tagOption = (tagsSetting?.options as INodeProperties[] | undefined)?.[0] as
| { name: string; values: INodeProperties[] }
| undefined;
expect(tagOption?.name).toBe('tag');
const keyField = tagOption?.values.find((v) => v.name === 'key');
const valueField = tagOption?.values.find((v) => v.name === 'value');
expect(keyField?.noDataExpression).toBe(true);
expect(valueField?.noDataExpression).toBeUndefined();
});
});
describe('collectSettings', () => {
const customTelemetryTags = {
tag: [
{ key: 'environment', value: 'production' },
{ key: 'team', value: '={{ $json.team }}' },
],
};
it('should round-trip customTelemetryTags from the node object', () => {
const node = { customTelemetryTags } as INodeUi;
const result = collectSettings(node, []);
expect(result.customTelemetryTags).toEqual(customTelemetryTags);
expect(result.customTelemetryTags).not.toBe(customTelemetryTags);
expect((result.customTelemetryTags as typeof customTelemetryTags).tag).not.toBe(
customTelemetryTags.tag,
);
});
it('should fall back to an empty customTelemetryTags object when the node has none', () => {
const node = mock<INodeUi>({ customTelemetryTags: undefined });
const result = collectSettings(node, []);
expect(result.customTelemetryTags).toEqual({});
});
});
describe('setValue', () => {
@ -639,4 +712,26 @@ describe('setValue', () => {
expect(nodeValues.value.newProperty).toBe('newValue');
});
it('deletes array items from nested values', () => {
nodeValues.value = {
parameters: {},
customTelemetryTags: {
tag: [
{ key: 'environment', value: 'production' },
{ key: 'team', value: 'engineering' },
],
},
};
setValue(nodeValues, 'customTelemetryTags.tag[0]', null);
expect(nodeValues.value.customTelemetryTags).toEqual({
tag: [{ key: 'team', value: 'engineering' }],
});
setValue(nodeValues, 'customTelemetryTags.tag[0]', null);
expect(nodeValues.value.customTelemetryTags).toEqual({});
});
});

View File

@ -49,6 +49,7 @@ export function getNodeSettingsInitialValues(): INodeParameters {
maxTries: 3,
waitBetweenTries: 1000,
notes: '',
customTelemetryTags: {},
parameters: {},
};
}
@ -91,12 +92,14 @@ export function setValue(
// Data is on lower level
if (value === null) {
// Property should be deleted
let tempValue = get(nodeValues.value, nameParts.join('.')) as
| INodeParameters
| INodeParameters[];
const path = nameParts.join('.');
let tempValue = get(nodeValues.value, path) as INodeParameters | INodeParameters[];
if (lastNamePart && !Array.isArray(tempValue)) {
tempValue = omitKey(tempValue, lastNamePart);
if (isArray && Array.isArray(tempValue) && lastNamePart !== undefined) {
tempValue.splice(parseInt(lastNamePart, 10), 1);
set(nodeValues.value, path, tempValue);
} else if (lastNamePart && tempValue && !Array.isArray(tempValue)) {
set(nodeValues.value, path, omitKey(tempValue, lastNamePart));
}
if (isArray && Array.isArray(tempValue) && tempValue.length === 0) {
@ -105,7 +108,11 @@ export function setValue(
lastNamePart = nameParts.pop();
tempValue = get(nodeValues.value, nameParts.join('.')) as INodeParameters;
if (lastNamePart) {
tempValue = omitKey(tempValue, lastNamePart);
if (nameParts.length === 0) {
nodeValues.value = omitKey(nodeValues.value, lastNamePart);
} else {
set(nodeValues.value, nameParts.join('.'), omitKey(tempValue, lastNamePart));
}
}
}
} else {
@ -451,6 +458,7 @@ export function shouldSkipParamValidation(
export function createCommonNodeSettings(
isToolOrModelNode: boolean,
t: (key: BaseTextKey) => string,
isOtelEnabled = false,
) {
const ret: INodeProperties[] = [];
@ -572,6 +580,42 @@ export function createCommonNodeSettings(
},
);
if (isOtelEnabled) {
ret.push({
displayName: t('nodeSettings.customTelemetryTags.displayName'),
name: 'customTelemetryTags',
type: 'fixedCollection',
typeOptions: { multipleValues: true, sortable: true },
placeholder: t('nodeSettings.customTelemetryTags.placeholder'),
default: {},
description: t('nodeSettings.customTelemetryTags.description'),
isNodeSetting: true,
options: [
{
name: 'tag',
displayName: t('nodeSettings.customTelemetryTags.tag.displayName'),
values: [
{
displayName: t('nodeSettings.customTelemetryTags.tag.key.displayName'),
name: 'key',
type: 'string',
default: '',
noDataExpression: true,
isNodeSetting: true,
},
{
displayName: t('nodeSettings.customTelemetryTags.tag.value.displayName'),
name: 'value',
type: 'string',
default: '',
isNodeSetting: true,
},
],
},
],
});
}
return ret;
}
@ -660,6 +704,14 @@ export function collectSettings(node: INodeUi, nodeSettings: INodeProperties[]):
};
}
if (node.customTelemetryTags) {
foundNodeSettings.push('customTelemetryTags');
ret = {
...ret,
customTelemetryTags: deepCopy(node.customTelemetryTags),
};
}
// Set default node settings
for (const nodeSetting of nodeSettings) {
if (!foundNodeSettings.includes(nodeSetting.name)) {

View File

@ -33,6 +33,7 @@ import {
} from '@/app/constants';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
@ -56,6 +57,7 @@ const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
const nodeHelpers = useNodeHelpers();
const environmentsStore = useEnvironmentsStore();
const settingsStore = useSettingsStore();
const node = shallowRef<INode | null>(props.initialNode);
const userEditedName = ref(false);
@ -92,7 +94,7 @@ const tabOptions = computed<Array<ITab<ToolSettingsTab>>>(() => {
});
const nodeSettings = computed(() =>
createCommonNodeSettings(true, i18n.baseText.bind(i18n)).filter(
createCommonNodeSettings(true, i18n.baseText.bind(i18n), settingsStore.isOtelEnabled).filter(
(s) => s.name !== 'notes' && s.name !== 'notesInFlow',
),
);
@ -101,6 +103,7 @@ const settingsNodeValues = computed<INodeParameters>(() => {
if (!node.value) return { parameters: {} };
return {
parameters: deepCopy(node.value.parameters),
customTelemetryTags: deepCopy(node.value.customTelemetryTags ?? {}),
};
});
@ -199,6 +202,15 @@ function handleChangeSettingsValue(updateData: IUpdateInformation) {
...node.value,
parameters: newParameters,
};
} else if (updateData.name.includes('.') || updateData.name.includes('[')) {
const newNode = deepCopy(node.value);
setParameterValue(newNode as unknown as INodeParameters, updateData.name, updateData.value);
if (newNode.customTelemetryTags?.tag?.length === 0) {
newNode.customTelemetryTags = {};
}
node.value = newNode;
} else {
node.value = { ...node.value, [updateData.name]: updateData.value };
}

View File

@ -6,6 +6,7 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import NodeToolSettingsContent from '../NodeToolSettingsContent.vue';
import { NodeHelpers, type INode, type INodeTypeDescription } from 'n8n-workflow';
import { waitFor } from '@testing-library/vue';
@ -136,6 +137,7 @@ describe('NodeToolSettingsContent', () => {
let credentialsStore: ReturnType<typeof mockedStore<typeof useCredentialsStore>>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
let environmentsStore: ReturnType<typeof mockedStore<typeof useEnvironmentsStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
beforeEach(() => {
vi.clearAllMocks();
@ -146,6 +148,7 @@ describe('NodeToolSettingsContent', () => {
credentialsStore = mockedStore(useCredentialsStore);
projectsStore = mockedStore(useProjectsStore);
environmentsStore = mockedStore(useEnvironmentsStore);
settingsStore = mockedStore(useSettingsStore);
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(MOCK_NODE_TYPE);
environmentsStore.variablesAsObject = {};
@ -421,6 +424,28 @@ describe('NodeToolSettingsContent', () => {
});
});
describe('customTelemetryTags', () => {
it('should show settings tab when isOtelEnabled is true', () => {
settingsStore.isOtelEnabled = true;
const { getByText } = renderComponent({
props: { initialNode: createMockNode() },
});
expect(getByText('nodeSettings.settings')).toBeTruthy();
});
it('should not show settings tab from otel alone when isOtelEnabled is false', () => {
settingsStore.isOtelEnabled = false;
const { queryByText } = renderComponent({
props: { initialNode: createMockNode() },
});
expect(queryByText('nodeSettings.settings')).toBeFalsy();
});
});
describe('initialNode watcher', () => {
it('should initialize parameters with defaults from node type', () => {
const getNodeParametersSpy = vi.spyOn(NodeHelpers, 'getNodeParameters').mockReturnValue({

View File

@ -1380,6 +1380,9 @@ export interface INode {
executeOnce?: boolean;
onError?: OnError;
continueOnFail?: boolean;
customTelemetryTags?: {
tag?: Array<{ key: string; value: string }>;
};
parameters: INodeParameters;
credentials?: INodeCredentials;
webhookId?: string;