diff --git a/packages/frontend/editor-ui/src/plugins/icons/index.ts b/packages/frontend/editor-ui/src/plugins/icons/index.ts
index cb526515c4..ef2d11a2cc 100644
--- a/packages/frontend/editor-ui/src/plugins/icons/index.ts
+++ b/packages/frontend/editor-ui/src/plugins/icons/index.ts
@@ -29,6 +29,7 @@ import {
faChartBar,
faCheck,
faCheckCircle,
+ faCheckDouble,
faCheckSquare,
faChevronDown,
faChevronUp,
@@ -225,6 +226,7 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faChartBar);
addIcon(faCheck);
addIcon(faCheckCircle);
+ addIcon(faCheckDouble);
addIcon(faCheckSquare);
addIcon(faChevronLeft);
addIcon(faChevronRight);
diff --git a/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.json b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.json
new file mode 100644
index 0000000000..19343f021c
--- /dev/null
+++ b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.json
@@ -0,0 +1,14 @@
+{
+ "node": "n8n-nodes-base.evaluationMetrics",
+ "nodeVersion": "1.0",
+ "codexVersion": "1.0",
+ "categories": ["Evaluation", "Core Nodes"],
+ "resources": {
+ "primaryDocumentation": [
+ {
+ "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.evaluationmetrics/"
+ }
+ ]
+ },
+ "alias": ["Metric"]
+}
diff --git a/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.test.ts b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.test.ts
new file mode 100644
index 0000000000..9d5e215520
--- /dev/null
+++ b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.test.ts
@@ -0,0 +1,111 @@
+import { mock } from 'jest-mock-extended';
+import type { INodeTypes, IExecuteFunctions, AssignmentCollectionValue } from 'n8n-workflow';
+import { NodeOperationError } from 'n8n-workflow';
+
+import { EvaluationMetrics } from './EvaluationMetrics.node';
+
+describe('EvaluationMetrics Node', () => {
+ const nodeTypes = mock
();
+ const evaluationMetricsNode = new EvaluationMetrics();
+
+ let mockExecuteFunction: IExecuteFunctions;
+
+ function getMockExecuteFunction(metrics: AssignmentCollectionValue['assignments']) {
+ return {
+ getInputData: jest.fn().mockReturnValue([{}]),
+
+ getNodeParameter: jest.fn().mockReturnValueOnce({
+ assignments: metrics,
+ }),
+
+ getNode: jest.fn().mockReturnValue({
+ typeVersion: 1,
+ }),
+ } as unknown as IExecuteFunctions;
+ }
+
+ beforeAll(() => {
+ mockExecuteFunction = getMockExecuteFunction([
+ {
+ id: '1',
+ name: 'Accuracy',
+ value: 0.95,
+ type: 'number',
+ },
+ {
+ id: '2',
+ name: 'Latency',
+ value: 100,
+ type: 'number',
+ },
+ ]);
+ nodeTypes.getByName.mockReturnValue(evaluationMetricsNode);
+ jest.clearAllMocks();
+ });
+
+ describe('execute', () => {
+ it('should output the defined metrics', async () => {
+ const result = await evaluationMetricsNode.execute.call(mockExecuteFunction);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveLength(1);
+
+ const outputItem = result[0][0].json;
+ expect(outputItem).toEqual({
+ Accuracy: 0.95,
+ Latency: 100,
+ });
+ });
+
+ it('should handle no metrics defined', async () => {
+ const result = await evaluationMetricsNode.execute.call(mockExecuteFunction);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveLength(1);
+ expect(result[0][0].json).toEqual({});
+ });
+
+ it('should convert string values to numbers', async () => {
+ const mockExecuteWithStringValues = getMockExecuteFunction([
+ {
+ id: '1',
+ name: 'Accuracy',
+ value: '0.95',
+ type: 'number',
+ },
+ {
+ id: '2',
+ name: 'Latency',
+ value: '100',
+ type: 'number',
+ },
+ ]);
+
+ const result = await evaluationMetricsNode.execute.call(mockExecuteWithStringValues);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveLength(1);
+
+ const outputItem = result[0][0].json;
+ expect(outputItem).toEqual({
+ Accuracy: 0.95,
+ Latency: 100,
+ });
+ });
+
+ it('should throw error for non-numeric string values', async () => {
+ const mockExecuteWithInvalidValue = getMockExecuteFunction([
+ {
+ id: '1',
+ name: 'Accuracy',
+ value: 'not-a-number',
+ type: 'number',
+ },
+ ]);
+
+ await expect(evaluationMetricsNode.execute.call(mockExecuteWithInvalidValue)).rejects.toThrow(
+ NodeOperationError,
+ );
+ });
+ });
+});
diff --git a/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.ts b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.ts
new file mode 100644
index 0000000000..2ca86b626c
--- /dev/null
+++ b/packages/nodes-base/nodes/EvaluationMetrics/EvaluationMetrics.node.ts
@@ -0,0 +1,109 @@
+import type {
+ AssignmentCollectionValue,
+ FieldType,
+ IExecuteFunctions,
+ INodeExecutionData,
+ INodeType,
+ INodeTypeDescription,
+} from 'n8n-workflow';
+import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
+
+import { composeReturnItem, validateEntry } from '../Set/v2/helpers/utils';
+
+export class EvaluationMetrics implements INodeType {
+ description: INodeTypeDescription = {
+ displayName: 'Evaluation Metrics',
+ name: 'evaluationMetrics',
+ icon: 'fa:check-double',
+ group: ['input'],
+ iconColor: 'light-green',
+ version: 1,
+ description: 'Define the metrics returned for workflow evaluation',
+ defaults: {
+ name: 'Evaluation Metrics',
+ color: '#29A568',
+ },
+ inputs: [NodeConnectionTypes.Main],
+ outputs: [NodeConnectionTypes.Main],
+ properties: [
+ {
+ displayName:
+ "Define the evaluation metrics returned in your report. Only numeric values are supported. More Info",
+ name: 'notice',
+ type: 'notice',
+ default: '',
+ },
+ {
+ displayName: 'Metrics to Return',
+ name: 'metrics',
+ type: 'assignmentCollection',
+ default: {
+ assignments: [
+ {
+ name: '',
+ value: '',
+ type: 'number',
+ },
+ ],
+ },
+ typeOptions: {
+ assignment: {
+ disableType: true,
+ defaultType: 'number',
+ },
+ },
+ },
+ ],
+ };
+
+ async execute(this: IExecuteFunctions): Promise {
+ const items = this.getInputData();
+ const metrics: INodeExecutionData[] = [];
+
+ for (let i = 0; i < items.length; i++) {
+ const dataToSave = this.getNodeParameter('metrics', i, {}) as AssignmentCollectionValue;
+
+ const newItem: INodeExecutionData = {
+ json: {},
+ pairedItem: { item: i },
+ };
+ const newData = Object.fromEntries(
+ (dataToSave?.assignments ?? []).map((assignment) => {
+ const assignmentValue =
+ typeof assignment.value === 'number' ? assignment.value : Number(assignment.value);
+
+ if (isNaN(assignmentValue)) {
+ throw new NodeOperationError(
+ this.getNode(),
+ `Invalid numeric value: "${assignment.value}". Please provide a valid number.`,
+ );
+ }
+
+ const { name, value } = validateEntry(
+ assignment.name,
+ assignment.type as FieldType,
+ assignmentValue,
+ this.getNode(),
+ i,
+ false,
+ 1,
+ );
+
+ return [name, value];
+ }),
+ );
+
+ const returnItem = composeReturnItem.call(
+ this,
+ i,
+ newItem,
+ newData,
+ { dotNotation: false, include: 'none' },
+ 1,
+ );
+ metrics.push(returnItem);
+ }
+
+ return [metrics];
+ }
+}
diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json
index 715f4afaa8..beb4670c92 100644
--- a/packages/nodes-base/package.json
+++ b/packages/nodes-base/package.json
@@ -504,6 +504,7 @@
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js",
"dist/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
"dist/nodes/ExecutionData/ExecutionData.node.js",
+ "dist/nodes/EvaluationMetrics/EvaluationMetrics.node.js",
"dist/nodes/Facebook/FacebookGraphApi.node.js",
"dist/nodes/Facebook/FacebookTrigger.node.js",
"dist/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.js",
diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts
index 426d08d34f..4244feb6ca 100644
--- a/packages/workflow/src/Interfaces.ts
+++ b/packages/workflow/src/Interfaces.ts
@@ -1352,6 +1352,8 @@ export type FilterTypeOptions = {
export type AssignmentTypeOptions = Partial<{
hideType?: boolean; // visible by default
+ defaultType?: FieldType | 'string';
+ disableType?: boolean; // visible by default
}>;
export type DisplayCondition =