mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 03:12:15 +00:00
feat(editor): Add new telemetry event for schema preview (no-changelog) (#13930)
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import { request } from '@/utils/apiUtils';
|
import { request } from '@/utils/apiUtils';
|
||||||
import type { JSONSchema7 } from 'json-schema';
|
import type { JSONSchema7 } from 'json-schema';
|
||||||
|
import type { NodeParameterValueType } from 'n8n-workflow';
|
||||||
|
|
||||||
export type GetSchemaPreviewOptions = {
|
export type GetSchemaPreviewOptions = {
|
||||||
nodeType: string;
|
nodeType: string;
|
||||||
version: number;
|
version: number;
|
||||||
resource?: string;
|
resource?: NodeParameterValueType;
|
||||||
operation?: string;
|
operation?: NodeParameterValueType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const padVersion = (version: number) => {
|
const padVersion = (version: number) => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type { IExecutionResponse } from '@/Interface';
|
|||||||
import { clearPopupWindowState, hasTrimmedData, hasTrimmedItem } from '../utils/executionUtils';
|
import { clearPopupWindowState, hasTrimmedData, hasTrimmedItem } from '../utils/executionUtils';
|
||||||
import { usePostHog } from '@/stores/posthog.store';
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
|
import { useSchemaPreviewStore } from '@/stores/schemaPreview.store';
|
||||||
|
|
||||||
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
|
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
|
||||||
const workflowHelpers = useWorkflowHelpers({ router });
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
@@ -547,6 +548,7 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
|
|||||||
|
|
||||||
workflowsStore.updateNodeExecutionData(pushData);
|
workflowsStore.updateNodeExecutionData(pushData);
|
||||||
void assistantStore.onNodeExecution(pushData);
|
void assistantStore.onNodeExecution(pushData);
|
||||||
|
void useSchemaPreviewStore().trackSchemaPreviewExecution(pushData);
|
||||||
} else if (receivedData.type === 'nodeExecuteBefore') {
|
} else if (receivedData.type === 'nodeExecuteBefore') {
|
||||||
// A node started to be executed. Set it as executing.
|
// A node started to be executed. Set it as executing.
|
||||||
const pushData = receivedData.data;
|
const pushData = receivedData.data;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,10 @@ import { defineStore } from 'pinia';
|
|||||||
import { reactive } from 'vue';
|
import { reactive } from 'vue';
|
||||||
import { useRootStore } from './root.store';
|
import { useRootStore } from './root.store';
|
||||||
import type { JSONSchema7 } from 'json-schema';
|
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', () => {
|
export const useSchemaPreviewStore = defineStore('schemaPreview', () => {
|
||||||
// Type cast to avoid 'Type instantiation is excessively deep and possibly infinite'
|
// 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 rootStore = useRootStore();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
function getSchemaPreviewKey({
|
function getSchemaPreviewKey({
|
||||||
nodeType,
|
nodeType,
|
||||||
@@ -20,7 +26,7 @@ export const useSchemaPreviewStore = defineStore('schemaPreview', () => {
|
|||||||
operation,
|
operation,
|
||||||
resource,
|
resource,
|
||||||
}: schemaPreviewApi.GetSchemaPreviewOptions) {
|
}: schemaPreviewApi.GetSchemaPreviewOptions) {
|
||||||
return `${nodeType}_${version}_${resource ?? 'all'}_${operation ?? 'all'}`;
|
return `${nodeType}_${version}_${resource?.toString() ?? 'all'}_${operation?.toString() ?? 'all'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSchemaPreview(
|
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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ describe('Utils: Expressions', () => {
|
|||||||
['=expression', 'expression'],
|
['=expression', 'expression'],
|
||||||
['notAnExpression', 'notAnExpression'],
|
['notAnExpression', 'notAnExpression'],
|
||||||
[undefined, ''],
|
[undefined, ''],
|
||||||
|
[4, 4],
|
||||||
])('turns "%s" into "%s"', (input, output) => {
|
])('turns "%s" into "%s"', (input, output) => {
|
||||||
expect(removeExpressionPrefix(input)).toBe(output);
|
expect(removeExpressionPrefix(input)).toBe(output);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export const unwrapExpression = (expr: string) => {
|
|||||||
return expr.replace(/\{\{(.*)\}\}/, '$1').trim();
|
return expr.replace(/\{\{(.*)\}\}/, '$1').trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeExpressionPrefix = (expr: string | null | undefined) => {
|
export const removeExpressionPrefix = <T = unknown>(expr: T): T | string => {
|
||||||
return expr?.startsWith('=') ? expr.slice(1) : (expr ?? '');
|
return typeof expr === 'string' && expr.startsWith('=') ? expr.slice(1) : (expr ?? '');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isTestableExpression = (expr: string) => {
|
export const isTestableExpression = (expr: string) => {
|
||||||
|
|||||||
45
packages/frontend/editor-ui/src/utils/json-schema.test.ts
Normal file
45
packages/frontend/editor-ui/src/utils/json-schema.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
packages/frontend/editor-ui/src/utils/json-schema.ts
Normal file
43
packages/frontend/editor-ui/src/utils/json-schema.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user