feat(editor): Add new telemetry event for schema preview (no-changelog) (#13930)

This commit is contained in:
Elias Meire
2025-03-17 11:14:31 +01:00
committed by GitHub
parent e157217d8c
commit 042aa39024
8 changed files with 310 additions and 6 deletions

View File

@@ -1,11 +1,12 @@
import { request } from '@/utils/apiUtils';
import type { JSONSchema7 } from 'json-schema';
import type { NodeParameterValueType } from 'n8n-workflow';
export type GetSchemaPreviewOptions = {
nodeType: string;
version: number;
resource?: string;
operation?: string;
resource?: NodeParameterValueType;
operation?: NodeParameterValueType;
};
const padVersion = (version: number) => {

View File

@@ -39,6 +39,7 @@ import type { IExecutionResponse } from '@/Interface';
import { clearPopupWindowState, hasTrimmedData, hasTrimmedItem } from '../utils/executionUtils';
import { usePostHog } from '@/stores/posthog.store';
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import { useSchemaPreviewStore } from '@/stores/schemaPreview.store';
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
const workflowHelpers = useWorkflowHelpers({ router });
@@ -547,6 +548,7 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
workflowsStore.updateNodeExecutionData(pushData);
void assistantStore.onNodeExecution(pushData);
void useSchemaPreviewStore().trackSchemaPreviewExecution(pushData);
} else if (receivedData.type === 'nodeExecuteBefore') {
// A node started to be executed. Set it as executing.
const pushData = receivedData.data;

View File

@@ -0,0 +1,173 @@
import { createPinia, setActivePinia } from 'pinia';
import { useSchemaPreviewStore } from './schemaPreview.store';
import * as schemaPreviewApi from '@/api/schemaPreview';
import type { JSONSchema7 } from 'json-schema';
import { mock } from 'vitest-mock-extended';
import type { PushPayload } from '@n8n/api-types';
import { useTelemetry } from '../composables/useTelemetry';
import type { INode } from 'n8n-workflow';
import { useWorkflowsStore } from './workflows.store';
vi.mock('@/api/schemaPreview');
vi.mock('@/composables/useTelemetry', () => {
const track = vi.fn();
return {
useTelemetry: () => {
return { track };
},
};
});
vi.mock('@/stores/root.store', () => ({
useRootStore: vi.fn(() => ({
baseUrl: 'https://test.com',
})),
}));
vi.mock('@/stores/workflows.store', () => {
const getNodeByName = vi.fn();
return {
useWorkflowsStore: vi.fn(() => ({
workflowId: '123',
getNodeByName,
})),
};
});
describe('schemaPreview.store', () => {
beforeEach(() => {
vi.resetAllMocks();
setActivePinia(createPinia());
});
describe('getSchemaPreview', () => {
it('should fetch schema preview and cache', async () => {
const store = useSchemaPreviewStore();
const schemaPreviewApiSpy = vi.mocked(schemaPreviewApi.getSchemaPreview);
const mockedSchema: JSONSchema7 = {
type: 'object',
properties: { foo: { type: 'string' } },
};
schemaPreviewApiSpy.mockResolvedValueOnce(mockedSchema);
const options = {
nodeType: 'n8n-nodes-base.test',
version: 1.2,
resource: 'messages',
operation: 'send',
};
const result = await store.getSchemaPreview(options);
expect(schemaPreviewApiSpy).toHaveBeenCalledTimes(1);
expect(result).toEqual({ ok: true, result: mockedSchema });
const result2 = await store.getSchemaPreview(options);
expect(schemaPreviewApiSpy).toHaveBeenCalledTimes(1);
expect(result2).toEqual({ ok: true, result: mockedSchema });
});
it('should handle errors', async () => {
const store = useSchemaPreviewStore();
const schemaPreviewApiSpy = vi.mocked(schemaPreviewApi.getSchemaPreview);
const error = new Error('Not Found');
schemaPreviewApiSpy.mockRejectedValueOnce(error);
const options = {
nodeType: 'n8n-nodes-base.test',
version: 1.2,
resource: 'messages',
operation: 'send',
};
const result = await store.getSchemaPreview(options);
expect(result).toEqual({ ok: false, error });
});
});
describe('trackSchemaPreviewExecution', () => {
const options = {
nodeType: 'n8n-nodes-base.test',
version: 1.2,
resource: 'messages',
operation: 'send',
};
beforeEach(async () => {
const store = useSchemaPreviewStore();
const schemaPreviewApiSpy = vi.mocked(schemaPreviewApi.getSchemaPreview);
const mockedSchema: JSONSchema7 = {
type: 'object',
properties: { foo: { type: 'string' } },
};
schemaPreviewApiSpy.mockResolvedValueOnce(mockedSchema);
// Populate the schema preview cache
await store.getSchemaPreview(options);
});
it('should track both the preview schema and the output one', async () => {
const store = useSchemaPreviewStore();
vi.mocked(useWorkflowsStore().getNodeByName).mockReturnValueOnce(
mock<INode>({
id: 'test-node-id',
type: options.nodeType,
typeVersion: options.version,
parameters: { resource: options.resource, operation: options.operation },
}),
);
await store.trackSchemaPreviewExecution(
mock<PushPayload<'nodeExecuteAfter'>>({
nodeName: 'Test',
data: {
executionStatus: 'success',
data: { main: [[{ json: { foo: 'bar', quz: 'qux' } }]] },
},
}),
);
expect(useTelemetry().track).toHaveBeenCalledWith('User executed node with schema preview', {
node_id: 'test-node-id',
node_operation: 'send',
node_resource: 'messages',
node_type: 'n8n-nodes-base.test',
node_version: 1.2,
output_schema:
'{"type":"object","properties":{"foo":{"type":"string"},"quz":{"type":"string"}}}',
schema_preview: '{"type":"object","properties":{"foo":{"type":"string"}}}',
workflow_id: '123',
});
});
it('should not track nodes without a schema preview', async () => {
const store = useSchemaPreviewStore();
vi.mocked(useWorkflowsStore().getNodeByName).mockReturnValueOnce(mock<INode>());
await store.trackSchemaPreviewExecution(
mock<PushPayload<'nodeExecuteAfter'>>({
nodeName: 'Test',
data: {
executionStatus: 'success',
data: { main: [[{ json: { foo: 'bar', quz: 'qux' } }]] },
},
}),
);
expect(useTelemetry().track).not.toHaveBeenCalled();
});
it('should not track failed executions', async () => {
const store = useSchemaPreviewStore();
await store.trackSchemaPreviewExecution(
mock<PushPayload<'nodeExecuteAfter'>>({
data: {
executionStatus: 'error',
},
}),
);
expect(useTelemetry().track).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,6 +4,10 @@ import { defineStore } from 'pinia';
import { reactive } from 'vue';
import { useRootStore } from './root.store';
import type { JSONSchema7 } from 'json-schema';
import type { PushPayload } from '@n8n/api-types';
import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { generateJsonSchema } from '@/utils/json-schema';
export const useSchemaPreviewStore = defineStore('schemaPreview', () => {
// Type cast to avoid 'Type instantiation is excessively deep and possibly infinite'
@@ -13,6 +17,8 @@ export const useSchemaPreviewStore = defineStore('schemaPreview', () => {
>;
const rootStore = useRootStore();
const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
function getSchemaPreviewKey({
nodeType,
@@ -20,7 +26,7 @@ export const useSchemaPreviewStore = defineStore('schemaPreview', () => {
operation,
resource,
}: schemaPreviewApi.GetSchemaPreviewOptions) {
return `${nodeType}_${version}_${resource ?? 'all'}_${operation ?? 'all'}`;
return `${nodeType}_${version}_${resource?.toString() ?? 'all'}_${operation?.toString() ?? 'all'}`;
}
async function getSchemaPreview(
@@ -42,5 +48,38 @@ export const useSchemaPreviewStore = defineStore('schemaPreview', () => {
}
}
return { getSchemaPreview };
async function trackSchemaPreviewExecution(pushEvent: PushPayload<'nodeExecuteAfter'>) {
if (schemaPreviews.size === 0 || pushEvent.data.executionStatus !== 'success') {
return;
}
const node = workflowsStore.getNodeByName(pushEvent.nodeName);
if (!node) return;
const {
id,
type,
typeVersion,
parameters: { resource, operation },
} = node;
const result = schemaPreviews.get(
getSchemaPreviewKey({ nodeType: type, version: typeVersion, resource, operation }),
);
if (!result || !result.ok) return;
telemetry.track('User executed node with schema preview', {
node_id: id,
node_type: type,
node_version: typeVersion,
node_resource: resource,
node_operation: operation,
schema_preview: JSON.stringify(result.result),
output_schema: JSON.stringify(generateJsonSchema(pushEvent.data.data?.main?.[0]?.[0]?.json)),
workflow_id: workflowsStore.workflowId,
});
}
return { getSchemaPreview, trackSchemaPreviewExecution };
});

View File

@@ -55,6 +55,7 @@ describe('Utils: Expressions', () => {
['=expression', 'expression'],
['notAnExpression', 'notAnExpression'],
[undefined, ''],
[4, 4],
])('turns "%s" into "%s"', (input, output) => {
expect(removeExpressionPrefix(input)).toBe(output);
});

View File

@@ -16,8 +16,8 @@ export const unwrapExpression = (expr: string) => {
return expr.replace(/\{\{(.*)\}\}/, '$1').trim();
};
export const removeExpressionPrefix = (expr: string | null | undefined) => {
return expr?.startsWith('=') ? expr.slice(1) : (expr ?? '');
export const removeExpressionPrefix = <T = unknown>(expr: T): T | string => {
return typeof expr === 'string' && expr.startsWith('=') ? expr.slice(1) : (expr ?? '');
};
export const isTestableExpression = (expr: string) => {

View File

@@ -0,0 +1,45 @@
import { generateJsonSchema } from './json-schema';
describe('util: JSON Schema', () => {
test.each([
{ message: 'a string', input: 'foo', output: { type: 'string' } },
{
message: 'a simple object',
input: { a: 'foo', b: 4, c: true },
output: {
properties: {
a: { type: 'string' },
b: { type: 'number' },
c: { type: 'boolean' },
},
type: 'object',
},
},
{
message: 'a nested object',
input: { nested: { foo: 'bar', array: [{ a: 1 }] } },
output: {
type: 'object',
properties: {
nested: {
type: 'object',
properties: {
array: {
items: {
properties: {
a: { type: 'number' },
},
type: 'object',
},
type: 'array',
},
foo: { type: 'string' },
},
},
},
},
},
])('should generate a valid JSON schema for $message', ({ input, output }) => {
expect(generateJsonSchema(input)).toEqual(output);
});
});

View File

@@ -0,0 +1,43 @@
import type { JSONSchema7 } from 'json-schema';
// Simple JSON schema generator
// Prioritizes performance and simplicity over supporting all JSON schema features
export function generateJsonSchema(json: unknown): JSONSchema7 {
return inferType(json);
}
function isPrimitive(type: string): type is 'string' | 'number' | 'boolean' {
return ['string', 'number', 'boolean'].includes(type);
}
function inferType(value: unknown): JSONSchema7 {
if (value === null) return { type: 'null' };
const type = typeof value;
if (isPrimitive(type)) return { type };
if (Array.isArray(value)) return inferArrayType(value);
if (value && type === 'object') return inferObjectType(value);
return { type: 'string' };
}
function inferArrayType(arr: unknown[]): JSONSchema7 {
return {
type: 'array',
items: arr.length > 0 ? inferType(arr[0]) : {},
};
}
function inferObjectType(obj: object): JSONSchema7 {
const properties: JSONSchema7['properties'] = {};
Object.entries(obj).forEach(([key, value]) => {
properties[key] = inferType(value);
});
return {
type: 'object',
properties,
};
}