chore(core): Send a telementry event if a node outputs non json data (#16558)

Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
Guillaume Jacquart
2025-06-25 10:49:59 +02:00
committed by GitHub
parent 297d3001c0
commit 6edde7fb66
3 changed files with 262 additions and 0 deletions

View File

@@ -61,6 +61,7 @@ import PCancelable from 'p-cancelable';
import { ErrorReporter } from '@/errors/error-reporter';
import { WorkflowHasIssuesError } from '@/errors/workflow-has-issues.error';
import * as NodeExecuteFunctions from '@/node-execute-functions';
import { isJsonCompatible } from '@/utils/is-json-compatible';
import { ExecuteContext, PollContext } from './node-execution-context';
import {
@@ -1193,6 +1194,27 @@ export class WorkflowExecute {
: await nodeType.execute.call(context);
}
// If data is not json compatible then log it as incorrect output
// Does not block the execution from continuing
const jsonCompatibleResult = isJsonCompatible(data);
if (!jsonCompatibleResult.isValid) {
Container.get(ErrorReporter).error(
new UnexpectedError('node execution output incorrect data'),
{
extra: {
nodeName: node.name,
nodeType: node.type,
nodeVersion: node.typeVersion,
workflowId: workflow.id,
workflowName: workflow.name ?? 'Unnamed workflow',
executionId: this.additionalData.executionId ?? 'unsaved-execution',
errorPath: jsonCompatibleResult.errorPath,
errorMessage: jsonCompatibleResult.errorMessage,
},
},
);
}
const closeFunctionsResults = await Promise.allSettled(
closeFunctions.map(async (fn) => await fn()),
);

View File

@@ -0,0 +1,134 @@
import { isJsonCompatible } from '../is-json-compatible';
describe('isJsonCompatible', () => {
type CircularReferenceObject = { self: CircularReferenceObject };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const circularReferencedObject: CircularReferenceObject = {} as any;
circularReferencedObject.self = circularReferencedObject;
type CircularReferencedArray = CircularReferencedArray[];
const circularReferencedArray: CircularReferencedArray = [];
circularReferencedArray.push(circularReferencedArray);
type CircularReferencedArrayInObject = { cycle: CircularReferencedArrayInObject[] };
const temp: CircularReferencedArrayInObject[] = [];
const circularReferencedArrayInObject = { cycle: temp };
temp.push(circularReferencedArrayInObject);
type CircularReferencedObjectInArray = Array<{ cycle: CircularReferencedObjectInArray }>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const circularReferencedObjectInArray: CircularReferencedObjectInArray = [] as any;
circularReferencedObjectInArray.push({ cycle: circularReferencedObjectInArray });
test.each([
{
name: 'new Date()',
value: { date: new Date() },
errorPath: 'value.date',
errorMessage: 'has non-plain prototype (Date)',
},
{
name: 'a function',
value: { fn: () => {} },
errorPath: 'value.fn',
errorMessage: 'is a function, which is not JSON-compatible',
},
{
name: 'a circular referenced object',
value: circularReferencedObject,
errorPath: 'value.self',
errorMessage: 'contains a circular reference',
},
{
name: 'a circular referenced array',
value: circularReferencedArray,
errorPath: 'value[0]',
errorMessage: 'contains a circular reference',
},
{
name: 'an array in a object referencing the object',
value: circularReferencedArrayInObject,
errorPath: 'value.cycle[0]',
errorMessage: 'contains a circular reference',
},
{
name: 'an object in a array referencing the array',
value: circularReferencedObjectInArray,
errorPath: 'value[0].cycle',
errorMessage: 'contains a circular reference',
},
{
name: 'a symbol',
value: { symbol: Symbol() },
errorPath: 'value.symbol',
errorMessage: 'is a symbol, which is not JSON-compatible',
},
{
name: 'a bigint',
value: { bigint: BigInt(1) },
errorPath: 'value.bigint',
errorMessage: 'is a bigint, which is not JSON-compatible',
},
{
name: 'a Set',
value: { bigint: new Set() },
errorPath: 'value.bigint',
errorMessage: 'has non-plain prototype (Set)',
},
{
name: 'Infinity',
value: { infinity: Infinity },
errorPath: 'value.infinity',
errorMessage: 'is Infinity, which is not JSON-compatible',
},
{
name: 'NaN',
value: { nan: NaN },
errorPath: 'value.nan',
errorMessage: 'is NaN, which is not JSON-compatible',
},
{
name: 'undefined',
value: { undefined },
errorPath: 'value.undefined',
errorMessage: 'is of unknown type (undefined) with value undefined',
},
{
name: 'an object with symbol keys',
value: { [Symbol.for('key')]: 1 },
errorPath: 'value.Symbol(key)',
errorMessage: 'has a symbol key (Symbol(key)), which is not JSON-compatible',
},
])('returns invalid for "$name"', ({ value, errorPath, errorMessage }) => {
const result = isJsonCompatible(value);
if (result.isValid) {
fail('expected result to be invalid');
}
expect(result.errorPath).toBe(errorPath);
expect(result.errorMessage).toBe(errorMessage);
});
const objectRef = {};
test.each([
{ name: 'null', value: { null: null } },
{ name: 'an array of primitives', value: { array: [1, 'string', true, false] } },
{
name: 'an object without a prototype chain',
value: { objectWithoutPrototype: Object.create(null) },
},
{
name: 'repeated objects references in an array that are not circular',
value: { array: [objectRef, objectRef] },
},
{
name: 'repeated objects references in an object that are not circular',
value: { array: { object1: objectRef, object2: objectRef } },
},
])('returns valid for "$name"', ({ value }) => {
const result = isJsonCompatible(value);
expect(result.isValid).toBe(true);
});
});

View File

@@ -0,0 +1,106 @@
/* eslint-disable complexity */
const check = (
val: unknown,
path = 'value',
stack: Set<unknown> = new Set(),
): { isValid: true } | { isValid: false; errorPath: string; errorMessage: string } => {
const type = typeof val;
if (val === null || type === 'boolean' || type === 'string') {
return { isValid: true };
}
if (type === 'number') {
if (!Number.isFinite(val)) {
return {
isValid: false,
errorPath: path,
errorMessage: `is ${val as number}, which is not JSON-compatible`,
};
}
return { isValid: true };
}
if (type === 'function' || type === 'symbol' || type === 'bigint') {
return {
isValid: false,
errorPath: path,
errorMessage: `is a ${type}, which is not JSON-compatible`,
};
}
if (Array.isArray(val)) {
if (stack.has(val)) {
return {
isValid: false,
errorPath: path,
errorMessage: 'contains a circular reference',
};
}
stack.add(val);
for (let i = 0; i < val.length; i++) {
const result = check(val[i], `${path}[${i}]`, stack);
if (!result.isValid) return result;
}
stack.delete(val);
return { isValid: true };
}
if (type === 'object') {
if (stack.has(val)) {
return {
isValid: false,
errorPath: path,
errorMessage: 'contains a circular reference',
};
}
stack.add(val);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const proto = Object.getPrototypeOf(val);
if (proto !== Object.prototype && proto !== null) {
return {
isValid: false,
errorPath: path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
errorMessage: `has non-plain prototype (${proto?.constructor?.name || 'unknown'})`,
};
}
for (const key of Reflect.ownKeys(val as object)) {
if (typeof key === 'symbol') {
return {
isValid: false,
errorPath: `${path}.${key.toString()}`,
errorMessage: `has a symbol key (${String(key)}), which is not JSON-compatible`,
};
}
const subVal = (val as Record<string, unknown>)[key];
const result = check(subVal, `${path}.${key}`, stack);
if (!result.isValid) return result;
}
stack.delete(val);
return { isValid: true };
}
return {
isValid: false,
errorPath: path,
errorMessage: `is of unknown type (${type}) with value ${JSON.stringify(val)}`,
};
};
/**
* This function checks if a value matches JSON data type restrictions.
* @param value
* @returns boolean
*/
export function isJsonCompatible(value: unknown):
| { isValid: true }
| {
isValid: false;
errorPath: string;
errorMessage: string;
} {
return check(value);
}