feat(core): Implement API to retrieve summary metrics (#13927)

Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
Danny Martini
2025-03-24 14:10:24 +01:00
committed by GitHub
parent 743b63e97a
commit b616ceb08b
12 changed files with 384 additions and 37 deletions

View File

@@ -0,0 +1,93 @@
import { Container } from '@n8n/di';
import { mockInstance } from '@test/mocking';
import * as testDb from '@test-integration/test-db';
import { TypeToNumber } from '../entities/insights-shared';
import { InsightsController } from '../insights.controller';
import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository';
// Initialize DB once for all tests
beforeAll(async () => {
await testDb.init();
});
// Terminate DB once after all tests complete
afterAll(async () => {
await testDb.terminate();
});
describe('InsightsController', () => {
const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository);
let controller: InsightsController;
beforeAll(async () => {
controller = Container.get(InsightsController);
});
describe('getInsightsSummary', () => {
it('should return default insights if no data', async () => {
// ARRANGE
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([]);
// ACT
const response = await controller.getInsightsSummary();
// ASSERT
expect(response).toEqual({
total: { deviation: 0, unit: 'count', value: 0 },
failed: { deviation: 0, unit: 'count', value: 0 },
failureRate: { deviation: 0, unit: 'ratio', value: 0 },
averageRunTime: { deviation: 0, unit: 'time', value: 0 },
timeSaved: { deviation: 0, unit: 'time', value: 0 },
});
});
it('should return the insights summary with deviation = current if insights exist only for current period', async () => {
// ARRANGE
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([
{ period: 'current', type: TypeToNumber.success, total_value: 20 },
{ period: 'current', type: TypeToNumber.failure, total_value: 10 },
{ period: 'current', type: TypeToNumber.runtime_ms, total_value: 300 },
{ period: 'current', type: TypeToNumber.time_saved_min, total_value: 10 },
]);
// ACT
const response = await controller.getInsightsSummary();
// ASSERT
expect(response).toEqual({
total: { deviation: 30, unit: 'count', value: 30 },
failed: { deviation: 10, unit: 'count', value: 10 },
failureRate: { deviation: 0.33, unit: 'ratio', value: 0.33 },
averageRunTime: { deviation: 10, unit: 'time', value: 10 },
timeSaved: { deviation: 10, unit: 'time', value: 10 },
});
});
it('should return the insights summary if insights exist for both periods', async () => {
// ARRANGE
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([
{ period: 'previous', type: TypeToNumber.success, total_value: 16 },
{ period: 'previous', type: TypeToNumber.failure, total_value: 4 },
{ period: 'previous', type: TypeToNumber.runtime_ms, total_value: 40 },
{ period: 'previous', type: TypeToNumber.time_saved_min, total_value: 5 },
{ period: 'current', type: TypeToNumber.success, total_value: 20 },
{ period: 'current', type: TypeToNumber.failure, total_value: 10 },
{ period: 'current', type: TypeToNumber.runtime_ms, total_value: 300 },
{ period: 'current', type: TypeToNumber.time_saved_min, total_value: 10 },
]);
// ACT
const response = await controller.getInsightsSummary();
// ASSERT
expect(response).toEqual({
total: { deviation: 10, unit: 'count', value: 30 },
failed: { deviation: 6, unit: 'count', value: 10 },
failureRate: { deviation: 0.33 - 0.2, unit: 'ratio', value: 0.33 },
averageRunTime: { deviation: 300 / 30 - 40 / 20, unit: 'time', value: 10 },
timeSaved: { deviation: 5, unit: 'time', value: 10 },
});
});
});
});

View File

@@ -7,7 +7,7 @@ import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow';
import type { Project } from '@/databases/entities/project';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { IWorkflowDb } from '@/interfaces';
import type { TypeUnits } from '@/modules/insights/entities/insights-shared';
import type { TypeUnit } from '@/modules/insights/entities/insights-shared';
import { InsightsMetadataRepository } from '@/modules/insights/repositories/insights-metadata.repository';
import { InsightsRawRepository } from '@/modules/insights/repositories/insights-raw.repository';
import { createTeamProject } from '@test-integration/db/projects';
@@ -38,7 +38,6 @@ async function truncateAll() {
// Initialize DB once for all tests
beforeAll(async () => {
jest.useFakeTimers();
await testDb.init();
});
@@ -74,7 +73,7 @@ describe('workflowExecuteAfterHandler', () => {
);
});
test.each<{ status: ExecutionStatus; type: TypeUnits }>([
test.each<{ status: ExecutionStatus; type: TypeUnit }>([
{ status: 'success', type: 'success' },
{ status: 'error', type: 'failure' },
{ status: 'crashed', type: 'failure' },
@@ -524,20 +523,28 @@ describe('compaction', () => {
const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0);
expect(accumulatedValues).toBe(batchSize);
});
});
describe('compactionSchedule', () => {
test('compaction is running on schedule', async () => {
// ARRANGE
const insightsService = Container.get(InsightsService);
jest.useFakeTimers();
try {
// ARRANGE
const insightsService = Container.get(InsightsService);
insightsService.initializeCompaction();
// spy on the compactInsights method to check if it's called
insightsService.compactInsights = jest.fn();
// spy on the compactInsights method to check if it's called
insightsService.compactInsights = jest.fn();
// ACT
// advance by 1 hour and 1 minute
jest.advanceTimersByTime(1000 * 60 * 60);
// ACT
// advance by 1 hour and 1 minute
jest.advanceTimersByTime(1000 * 60 * 61);
// ASSERT
expect(insightsService.compactInsights).toHaveBeenCalledTimes(1);
// ASSERT
expect(insightsService.compactInsights).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
});
@@ -602,3 +609,68 @@ describe('compaction', () => {
});
});
});
describe('getInsightsSummary', () => {
let insightsService: InsightsService;
beforeAll(async () => {
insightsService = Container.get(InsightsService);
});
let project: Project;
let workflow: IWorkflowDb & WorkflowEntity;
beforeEach(async () => {
await truncateAll();
project = await createTeamProject();
workflow = await createWorkflow({}, project);
});
test('compacted data are summarized correctly', async () => {
// ARRANGE
// last 7 days
await createCompactedInsightsEvent(workflow, {
type: 'success',
value: 1,
periodUnit: 'day',
periodStart: DateTime.utc(),
});
await createCompactedInsightsEvent(workflow, {
type: 'success',
value: 1,
periodUnit: 'day',
periodStart: DateTime.utc().minus({ day: 2 }),
});
await createCompactedInsightsEvent(workflow, {
type: 'failure',
value: 2,
periodUnit: 'day',
periodStart: DateTime.utc(),
});
// last 14 days
await createCompactedInsightsEvent(workflow, {
type: 'success',
value: 1,
periodUnit: 'day',
periodStart: DateTime.utc().minus({ days: 10 }),
});
await createCompactedInsightsEvent(workflow, {
type: 'runtime_ms',
value: 123,
periodUnit: 'day',
periodStart: DateTime.utc().minus({ days: 10 }),
});
// ACT
const summary = await insightsService.getInsightsSummary();
// ASSERT
expect(summary).toEqual({
averageRunTime: { deviation: -123, unit: 'time', value: 0 },
failed: { deviation: 2, unit: 'count', value: 2 },
failureRate: { deviation: 0.5, unit: 'ratio', value: 0.5 },
timeSaved: { deviation: 0, unit: 'time', value: 0 },
total: { deviation: 3, unit: 'count', value: 4 },
});
});
});

View File

@@ -1,4 +1,3 @@
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { DateTime } from 'luxon';
import type { IWorkflowBase } from 'n8n-workflow';
@@ -19,7 +18,6 @@ async function getWorkflowSharing(workflow: IWorkflowBase) {
relations: { project: true },
});
}
export const { type: dbType } = Container.get(GlobalConfig).database;
export async function createMetadata(workflow: WorkflowEntity) {
const insightsMetadataRepository = Container.get(InsightsMetadataRepository);

View File

@@ -4,7 +4,7 @@ import * as testDb from '@test-integration/test-db';
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
import { InsightsByPeriod } from '../insights-by-period';
import type { PeriodUnits, TypeUnits } from '../insights-shared';
import type { PeriodUnit, TypeUnit } from '../insights-shared';
let insightsRawRepository: InsightsRawRepository;
@@ -22,7 +22,7 @@ afterAll(async () => {
});
describe('Insights By Period', () => {
test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnits[])(
test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnit[])(
'`%s` can be serialized and deserialized correctly',
(typeUnit) => {
// ARRANGE
@@ -35,7 +35,7 @@ describe('Insights By Period', () => {
expect(insightByPeriod.type).toBe(typeUnit);
},
);
test.each(['hour', 'day', 'week'] satisfies PeriodUnits[])(
test.each(['hour', 'day', 'week'] satisfies PeriodUnit[])(
'`%s` can be serialized and deserialized correctly',
(periodUnit) => {
// ARRANGE

View File

@@ -8,7 +8,7 @@ import * as testDb from '@test-integration/test-db';
import { createMetadata, createRawInsightsEvent } from './db-utils';
import { InsightsRawRepository } from '../../repositories/insights-raw.repository';
import { InsightsRaw } from '../insights-raw';
import type { TypeUnits } from '../insights-shared';
import type { TypeUnit } from '../insights-shared';
let insightsRawRepository: InsightsRawRepository;
@@ -26,7 +26,7 @@ afterAll(async () => {
});
describe('Insights Raw Entity', () => {
test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnits[])(
test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnit[])(
'`%s` can be serialized and deserialized correctly',
(typeUnit) => {
// ARRANGE

View File

@@ -1,7 +1,7 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow';
import type { PeriodUnits } from './insights-shared';
import type { PeriodUnit } from './insights-shared';
import {
isValidPeriodNumber,
isValidTypeNumber,
@@ -53,7 +53,7 @@ export class InsightsByPeriod extends BaseEntity {
return NumberToPeriodUnit[this.periodUnit_];
}
set periodUnit(value: PeriodUnits) {
set periodUnit(value: PeriodUnit) {
this.periodUnit_ = PeriodUnitToNumber[value];
}

View File

@@ -11,14 +11,16 @@ export const PeriodUnitToNumber = {
day: 1,
week: 2,
} as const;
export type PeriodUnits = keyof typeof PeriodUnitToNumber;
export type PeriodUnitNumbers = (typeof PeriodUnitToNumber)[PeriodUnits];
export type PeriodUnit = keyof typeof PeriodUnitToNumber;
export type PeriodUnitNumber = (typeof PeriodUnitToNumber)[PeriodUnit];
export const NumberToPeriodUnit = Object.entries(PeriodUnitToNumber).reduce(
(acc, [key, value]: [PeriodUnits, PeriodUnitNumbers]) => {
(acc, [key, value]: [PeriodUnit, PeriodUnitNumber]) => {
acc[value] = key;
return acc;
},
{} as Record<PeriodUnitNumbers, PeriodUnits>,
{} as Record<PeriodUnitNumber, PeriodUnit>,
);
export function isValidPeriodNumber(value: number) {
return isValid(value, NumberToPeriodUnit);
@@ -31,14 +33,16 @@ export const TypeToNumber = {
success: 2,
failure: 3,
} as const;
export type TypeUnits = keyof typeof TypeToNumber;
export type TypeUnitNumbers = (typeof TypeToNumber)[TypeUnits];
export type TypeUnit = keyof typeof TypeToNumber;
export type TypeUnitNumber = (typeof TypeToNumber)[TypeUnit];
export const NumberToType = Object.entries(TypeToNumber).reduce(
(acc, [key, value]: [TypeUnits, TypeUnitNumbers]) => {
(acc, [key, value]: [TypeUnit, TypeUnitNumber]) => {
acc[value] = key;
return acc;
},
{} as Record<TypeUnitNumbers, TypeUnits>,
{} as Record<TypeUnitNumber, TypeUnit>,
);
export function isValidTypeNumber(value: number) {

View File

@@ -0,0 +1,16 @@
import type { InsightsSummary } from '@n8n/api-types';
import { Get, GlobalScope, RestController } from '@/decorators';
import { InsightsService } from './insights.service';
@RestController('/insights')
export class InsightsController {
constructor(private readonly insightsService: InsightsService) {}
@Get('/summary')
@GlobalScope('insights:list')
async getInsightsSummary(): Promise<InsightsSummary> {
return await this.insightsService.getInsightsSummary();
}
}

View File

@@ -6,6 +6,8 @@ import { N8nModule } from '@/decorators/module';
import { InsightsService } from './insights.service';
import './insights.controller';
@N8nModule()
export class InsightsModule implements BaseN8nModule {
constructor(

View File

@@ -1,7 +1,12 @@
import type { InsightsSummary } from '@n8n/api-types';
import { Container, Service } from '@n8n/di';
import type { ExecutionLifecycleHooks } from 'n8n-core';
import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
import {
UnexpectedError,
type ExecutionStatus,
type IRun,
type WorkflowExecuteMode,
} from 'n8n-workflow';
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
@@ -9,6 +14,8 @@ import { OnShutdown } from '@/decorators/on-shutdown';
import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata';
import { InsightsRaw } from '@/modules/insights/entities/insights-raw';
import type { TypeUnit } from './entities/insights-shared';
import { NumberToType } from './entities/insights-shared';
import { InsightsConfig } from './insights.config';
import { InsightsByPeriodRepository } from './repositories/insights-by-period.repository';
import { InsightsRawRepository } from './repositories/insights-raw.repository';
@@ -49,6 +56,13 @@ export class InsightsService {
private readonly insightsByPeriodRepository: InsightsByPeriodRepository,
private readonly insightsRawRepository: InsightsRawRepository,
) {
this.initializeCompaction();
}
initializeCompaction() {
if (this.compactInsightsTimer !== undefined) {
clearInterval(this.compactInsightsTimer);
}
const intervalMilliseconds = config.compactionIntervalMinutes * 60 * 1000;
this.compactInsightsTimer = setInterval(
async () => await this.compactInsights(),
@@ -178,4 +192,84 @@ export class InsightsService {
periodUnit: 'day',
});
}
// TODO: add return type once rebased on master and InsightsSummary is
// available
async getInsightsSummary(): Promise<InsightsSummary> {
const rows = await this.insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates();
// Initialize data structures for both periods
const data = {
current: { byType: {} as Record<TypeUnit, number> },
previous: { byType: {} as Record<TypeUnit, number> },
};
// Organize data by period and type
rows.forEach((row) => {
const { period, type, total_value } = row;
if (!data[period]) return;
data[period].byType[NumberToType[type]] = total_value ? Number(total_value) : 0;
});
// Get values with defaults for missing data
const getValueByType = (period: 'current' | 'previous', type: TypeUnit) =>
data[period]?.byType[type] ?? 0;
// Calculate metrics
const currentSuccesses = getValueByType('current', 'success');
const currentFailures = getValueByType('current', 'failure');
const previousSuccesses = getValueByType('previous', 'success');
const previousFailures = getValueByType('previous', 'failure');
const currentTotal = currentSuccesses + currentFailures;
const previousTotal = previousSuccesses + previousFailures;
const currentFailureRate =
currentTotal > 0 ? Math.round((currentFailures / currentTotal) * 100) / 100 : 0;
const previousFailureRate =
previousTotal > 0 ? Math.round((previousFailures / previousTotal) * 100) / 100 : 0;
const currentTotalRuntime = getValueByType('current', 'runtime_ms') ?? 0;
const previousTotalRuntime = getValueByType('previous', 'runtime_ms') ?? 0;
const currentAvgRuntime =
currentTotal > 0 ? Math.round((currentTotalRuntime / currentTotal) * 100) / 100 : 0;
const previousAvgRuntime =
previousTotal > 0 ? Math.round((previousTotalRuntime / previousTotal) * 100) / 100 : 0;
const currentTimeSaved = getValueByType('current', 'time_saved_min');
const previousTimeSaved = getValueByType('previous', 'time_saved_min');
// Return the formatted result
const result: InsightsSummary = {
averageRunTime: {
value: currentAvgRuntime,
unit: 'time',
deviation: currentAvgRuntime - previousAvgRuntime,
},
failed: {
value: currentFailures,
unit: 'count',
deviation: currentFailures - previousFailures,
},
failureRate: {
value: currentFailureRate,
unit: 'ratio',
deviation: currentFailureRate - previousFailureRate,
},
timeSaved: {
value: currentTimeSaved,
unit: 'time',
deviation: currentTimeSaved - previousTimeSaved,
},
total: {
value: currentTotal,
unit: 'count',
deviation: currentTotal - previousTotal,
},
};
return result;
}
}

View File

@@ -1,15 +1,26 @@
import { GlobalConfig } from '@n8n/config';
import { Container, Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { z } from 'zod';
import { sql } from '@/utils/sql';
import { InsightsByPeriod } from '../entities/insights-by-period';
import type { PeriodUnits } from '../entities/insights-shared';
import type { PeriodUnit } from '../entities/insights-shared';
import { PeriodUnitToNumber } from '../entities/insights-shared';
const dbType = Container.get(GlobalConfig).database.type;
const summaryParser = z
.object({
period: z.enum(['previous', 'current']),
type: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]),
// depending on db engine, sum(value) can be a number or a string - because of big numbers
total_value: z.union([z.number(), z.string()]),
})
.array();
@Service()
export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
constructor(dataSource: DataSource) {
@@ -20,7 +31,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return this.manager.connection.driver.escape(fieldName);
}
private getPeriodFilterExpr(periodUnit: PeriodUnits) {
private getPeriodFilterExpr(periodUnit: PeriodUnit) {
const daysAgo = periodUnit === 'day' ? 90 : 180;
// Database-specific period start expression to filter out data to compact by days matching the periodUnit
let periodStartExpr = `date('now', '-${daysAgo} days')`;
@@ -33,7 +44,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return periodStartExpr;
}
private getPeriodStartExpr(periodUnit: PeriodUnits) {
private getPeriodStartExpr(periodUnit: PeriodUnit) {
// Database-specific period start expression to truncate timestamp to the periodUnit
// SQLite by default
let periodStartExpr = `strftime('%Y-%m-%d ${periodUnit === 'hour' ? '%H' : '00'}:00:00.000', periodStart)`;
@@ -49,7 +60,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return periodStartExpr;
}
getPeriodInsightsBatchQuery(periodUnit: PeriodUnits, compactionBatchSize: number) {
getPeriodInsightsBatchQuery(periodUnit: PeriodUnit, compactionBatchSize: number) {
// Build the query to gather period insights data for the batch
const batchQuery = this.createQueryBuilder()
.select(
@@ -64,7 +75,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return batchQuery;
}
getAggregationQuery(periodUnit: PeriodUnits) {
getAggregationQuery(periodUnit: PeriodUnit) {
// Get the start period expression depending on the period unit and database type
const periodStartExpr = this.getPeriodStartExpr(periodUnit);
@@ -91,7 +102,7 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
}: {
sourceBatchQuery: string;
sourceTableName?: string;
periodUnit: PeriodUnits;
periodUnit: PeriodUnit;
}): Promise<number> {
// Create temp table that only exists in this transaction for rows to compact
const getBatchAndStoreInTemporaryTable = sql`
@@ -161,4 +172,60 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return result;
}
async getPreviousAndCurrentPeriodTypeAggregates(): Promise<
Array<{
period: 'previous' | 'current';
type: 0 | 1 | 2 | 3;
total_value: string | number;
}>
> {
const cte =
dbType === 'sqlite'
? sql`
SELECT
datetime('now', '-7 days') AS current_start,
datetime('now') AS current_end,
datetime('now', '-14 days') AS previous_start
`
: dbType === 'postgresdb'
? sql`
SELECT
(CURRENT_DATE - INTERVAL '7 days')::timestamptz AS current_start,
CURRENT_DATE::timestamptz AS current_end,
(CURRENT_DATE - INTERVAL '14 days')::timestamptz AS previous_start
`
: sql`
SELECT
DATE_SUB(CURDATE(), INTERVAL 7 DAY) AS current_start,
CURDATE() AS current_end,
DATE_SUB(CURDATE(), INTERVAL 14 DAY) AS previous_start
`;
const rawRows = await this.createQueryBuilder('insights')
.addCommonTableExpression(cte, 'date_ranges')
.select(
sql`
CASE
WHEN insights.periodStart >= date_ranges.current_start AND insights.periodStart <= date_ranges.current_end
THEN 'current'
ELSE 'previous'
END
`,
'period',
)
.addSelect('insights.type', 'type')
.addSelect('SUM(value)', 'total_value')
// Use a cross join with the CTE
.innerJoin('date_ranges', 'date_ranges', '1=1')
// Filter to only include data from the last 14 days
.where('insights.periodStart >= date_ranges.previous_start')
.andWhere('insights.periodStart <= date_ranges.current_end')
// Group by both period and type
.groupBy('period')
.addGroupBy('insights.type')
.getRawMany();
return summaryParser.parse(rawRows);
}
}

View File

@@ -75,6 +75,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'project:read',
'project:update',
'project:delete',
'insights:list',
];
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();