mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
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:
parent
76cdf993bf
commit
e39f233af7
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user