From 042aa39024478b5dcbdb059c784c6fb5797e90cb Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 17 Mar 2025 11:14:31 +0100 Subject: [PATCH] feat(editor): Add new telemetry event for schema preview (no-changelog) (#13930) --- .../editor-ui/src/api/schemaPreview.ts | 5 +- .../src/composables/usePushConnection.ts | 2 + .../src/stores/schemaPreview.store.test.ts | 173 ++++++++++++++++++ .../src/stores/schemaPreview.store.ts | 43 ++++- .../editor-ui/src/utils/expressions.test.ts | 1 + .../editor-ui/src/utils/expressions.ts | 4 +- .../editor-ui/src/utils/json-schema.test.ts | 45 +++++ .../editor-ui/src/utils/json-schema.ts | 43 +++++ 8 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 packages/frontend/editor-ui/src/stores/schemaPreview.store.test.ts create mode 100644 packages/frontend/editor-ui/src/utils/json-schema.test.ts create mode 100644 packages/frontend/editor-ui/src/utils/json-schema.ts diff --git a/packages/frontend/editor-ui/src/api/schemaPreview.ts b/packages/frontend/editor-ui/src/api/schemaPreview.ts index cc08067255..0881a9ddee 100644 --- a/packages/frontend/editor-ui/src/api/schemaPreview.ts +++ b/packages/frontend/editor-ui/src/api/schemaPreview.ts @@ -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) => { diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection.ts b/packages/frontend/editor-ui/src/composables/usePushConnection.ts index cd070940b3..1be7a39aac 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection.ts @@ -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 }) { const workflowHelpers = useWorkflowHelpers({ router }); @@ -547,6 +548,7 @@ export function usePushConnection({ router }: { router: ReturnType { + 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({ + id: 'test-node-id', + type: options.nodeType, + typeVersion: options.version, + parameters: { resource: options.resource, operation: options.operation }, + }), + ); + await store.trackSchemaPreviewExecution( + mock>({ + 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()); + await store.trackSchemaPreviewExecution( + mock>({ + 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>({ + data: { + executionStatus: 'error', + }, + }), + ); + + expect(useTelemetry().track).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/stores/schemaPreview.store.ts b/packages/frontend/editor-ui/src/stores/schemaPreview.store.ts index e1039f271b..2cd1631b8e 100644 --- a/packages/frontend/editor-ui/src/stores/schemaPreview.store.ts +++ b/packages/frontend/editor-ui/src/stores/schemaPreview.store.ts @@ -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 }; }); diff --git a/packages/frontend/editor-ui/src/utils/expressions.test.ts b/packages/frontend/editor-ui/src/utils/expressions.test.ts index 1ae1a47f5e..6eda0545f3 100644 --- a/packages/frontend/editor-ui/src/utils/expressions.test.ts +++ b/packages/frontend/editor-ui/src/utils/expressions.test.ts @@ -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); }); diff --git a/packages/frontend/editor-ui/src/utils/expressions.ts b/packages/frontend/editor-ui/src/utils/expressions.ts index 155b83d9da..af6be4a8b7 100644 --- a/packages/frontend/editor-ui/src/utils/expressions.ts +++ b/packages/frontend/editor-ui/src/utils/expressions.ts @@ -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 = (expr: T): T | string => { + return typeof expr === 'string' && expr.startsWith('=') ? expr.slice(1) : (expr ?? ''); }; export const isTestableExpression = (expr: string) => { diff --git a/packages/frontend/editor-ui/src/utils/json-schema.test.ts b/packages/frontend/editor-ui/src/utils/json-schema.test.ts new file mode 100644 index 0000000000..6fd7ac116b --- /dev/null +++ b/packages/frontend/editor-ui/src/utils/json-schema.test.ts @@ -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); + }); +}); diff --git a/packages/frontend/editor-ui/src/utils/json-schema.ts b/packages/frontend/editor-ui/src/utils/json-schema.ts new file mode 100644 index 0000000000..ef8ffc8147 --- /dev/null +++ b/packages/frontend/editor-ui/src/utils/json-schema.ts @@ -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, + }; +}