mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-19 11:01: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 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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { 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 };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
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