feat(core): Implement granularity and date range filtering on insights (#14841)

This commit is contained in:
Guillaume Jacquart
2025-04-25 13:54:24 +02:00
committed by GitHub
parent 5ff073bd7b
commit 28596a633e
8 changed files with 247 additions and 32 deletions

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { InsightsDateFilterDto } from './date-filter.dto';
import { paginationSchema } from '../pagination/pagination.dto';
const VALID_SORT_OPTIONS = [
@@ -30,5 +31,6 @@ const sortByValidator = z
export class ListInsightsWorkflowQueryDto extends Z.class({
...paginationSchema,
dateRange: InsightsDateFilterDto.shape.dateRange,
sortBy: sortByValidator,
}) {}

View File

@@ -29,11 +29,12 @@ export {
SOURCE_CONTROL_FILE_TYPE,
} from './schemas/source-controlled-file.schema';
export type {
InsightsSummaryType,
InsightsSummaryUnit,
InsightsSummary,
InsightsByWorkflow,
InsightsByTime,
InsightsDateRange,
export {
type InsightsSummaryType,
type InsightsSummaryUnit,
type InsightsSummary,
type InsightsByWorkflow,
type InsightsByTime,
type InsightsDateRange,
INSIGHTS_DATE_RANGE_KEYS,
} from './schemas/insights.schema';

View File

@@ -82,13 +82,21 @@ export const insightsByTimeDataSchemas = {
})
.strict(),
} as const;
export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict();
export type InsightsByTime = z.infer<typeof insightsByTimeSchema>;
export const INSIGHTS_DATE_RANGE_KEYS = [
'day',
'week',
'2weeks',
'month',
'quarter',
'6months',
'year',
] as const;
export const insightsDateRangeSchema = z
.object({
key: z.enum(['day', 'week', '2weeks', 'month', 'quarter', '6months', 'year']),
key: z.enum(INSIGHTS_DATE_RANGE_KEYS),
licensed: z.boolean(),
granularity: z.enum(['hour', 'day', 'week']),
})

View File

@@ -1,5 +1,7 @@
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { AuthenticatedRequest } from '@/requests';
import { mockInstance } from '@test/mocking';
import * as testDb from '@test-integration/test-db';
@@ -30,7 +32,10 @@ describe('InsightsController', () => {
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([]);
// ACT
const response = await controller.getInsightsSummary();
const response = await controller.getInsightsSummary(
mock<AuthenticatedRequest>(),
mock<Response>(),
);
// ASSERT
expect(response).toEqual({
@@ -52,7 +57,10 @@ describe('InsightsController', () => {
]);
// ACT
const response = await controller.getInsightsSummary();
const response = await controller.getInsightsSummary(
mock<AuthenticatedRequest>(),
mock<Response>(),
);
// ASSERT
expect(response).toEqual({
@@ -78,7 +86,10 @@ describe('InsightsController', () => {
]);
// ACT
const response = await controller.getInsightsSummary();
const response = await controller.getInsightsSummary(
mock<AuthenticatedRequest>(),
mock<Response>(),
);
// ASSERT
expect(response).toEqual({

View File

@@ -1,3 +1,4 @@
import type { InsightsDateRange } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { DateTime } from 'luxon';
@@ -517,6 +518,7 @@ describe('getAvailableDateRanges', () => {
{ key: '2weeks', licensed: true, granularity: 'day' },
{ key: 'month', licensed: true, granularity: 'day' },
{ key: 'quarter', licensed: true, granularity: 'week' },
{ key: '6months', licensed: true, granularity: 'week' },
{ key: 'year', licensed: true, granularity: 'week' },
]);
});
@@ -533,6 +535,7 @@ describe('getAvailableDateRanges', () => {
{ key: '2weeks', licensed: true, granularity: 'day' },
{ key: 'month', licensed: true, granularity: 'day' },
{ key: 'quarter', licensed: true, granularity: 'week' },
{ key: '6months', licensed: true, granularity: 'week' },
{ key: 'year', licensed: true, granularity: 'week' },
]);
});
@@ -549,6 +552,7 @@ describe('getAvailableDateRanges', () => {
{ key: '2weeks', licensed: true, granularity: 'day' },
{ key: 'month', licensed: true, granularity: 'day' },
{ key: 'quarter', licensed: false, granularity: 'week' },
{ key: '6months', licensed: false, granularity: 'week' },
{ key: 'year', licensed: false, granularity: 'week' },
]);
});
@@ -565,6 +569,7 @@ describe('getAvailableDateRanges', () => {
{ key: '2weeks', licensed: false, granularity: 'day' },
{ key: 'month', licensed: false, granularity: 'day' },
{ key: 'quarter', licensed: false, granularity: 'week' },
{ key: '6months', licensed: false, granularity: 'week' },
{ key: 'year', licensed: false, granularity: 'week' },
]);
});
@@ -581,7 +586,84 @@ describe('getAvailableDateRanges', () => {
{ key: '2weeks', licensed: true, granularity: 'day' },
{ key: 'month', licensed: true, granularity: 'day' },
{ key: 'quarter', licensed: true, granularity: 'week' },
{ key: '6months', licensed: false, granularity: 'week' },
{ key: 'year', licensed: false, granularity: 'week' },
]);
});
});
describe('getMaxAgeInDaysAndGranularity', () => {
let insightsService: InsightsService;
let licenseMock: jest.Mocked<License>;
beforeAll(() => {
licenseMock = mock<License>();
insightsService = new InsightsService(
mock<InsightsByPeriodRepository>(),
mock<InsightsCompactionService>(),
mock<InsightsCollectionService>(),
licenseMock,
mock<Logger>(),
);
});
test('returns correct maxAgeInDays and granularity for a valid licensed date range', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
const result = insightsService.getMaxAgeInDaysAndGranularity('month');
expect(result).toEqual({
key: 'month',
licensed: true,
granularity: 'day',
maxAgeInDays: 30,
});
});
test('throws an error if the date range is not available', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
expect(() => {
insightsService.getMaxAgeInDaysAndGranularity('invalidKey' as InsightsDateRange['key']);
}).toThrowError('The selected date range is not available');
});
test('throws an error if the date range is not licensed', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(30);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
expect(() => {
insightsService.getMaxAgeInDaysAndGranularity('year');
}).toThrowError('The selected date range exceeds the maximum history allowed by your license.');
});
test('returns correct maxAgeInDays and granularity for a valid date range with hourly data disabled', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(90);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
const result = insightsService.getMaxAgeInDaysAndGranularity('quarter');
expect(result).toEqual({
key: 'quarter',
licensed: true,
granularity: 'week',
maxAgeInDays: 90,
});
});
test('returns correct maxAgeInDays and granularity for a valid date range with unlimited history', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(-1);
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
const result = insightsService.getMaxAgeInDaysAndGranularity('day');
expect(result).toEqual({
key: 'day',
licensed: true,
granularity: 'hour',
maxAgeInDays: 1,
});
});
});

View File

@@ -1,22 +1,39 @@
import { ListInsightsWorkflowQueryDto } from '@n8n/api-types';
import { InsightsDateFilterDto, ListInsightsWorkflowQueryDto } from '@n8n/api-types';
import type { InsightsSummary, InsightsByTime, InsightsByWorkflow } from '@n8n/api-types';
import { Get, GlobalScope, Licensed, Query, RestController } from '@n8n/decorators';
import type { UserError } from 'n8n-workflow';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { AuthenticatedRequest } from '@/requests';
import { InsightsService } from './insights.service';
@RestController('/insights')
export class InsightsController {
private readonly maxAgeInDaysFilteredInsights = 7;
constructor(private readonly insightsService: InsightsService) {}
/**
* This method is used to transform the date range from the request payload into a maximum age in days.
* It throws a ForbiddenError if the date range does not match the license insights max history
*/
private getMaxAgeInDaysAndGranularity(payload: InsightsDateFilterDto) {
try {
return this.insightsService.getMaxAgeInDaysAndGranularity(payload.dateRange ?? 'week');
} catch (error: unknown) {
throw new ForbiddenError((error as UserError).message);
}
}
@Get('/summary')
@GlobalScope('insights:list')
async getInsightsSummary(): Promise<InsightsSummary> {
async getInsightsSummary(
_req: AuthenticatedRequest,
_res: Response,
@Query payload: InsightsDateFilterDto = { dateRange: 'week' },
): Promise<InsightsSummary> {
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload);
return await this.insightsService.getInsightsSummary({
periodLengthInDays: this.maxAgeInDaysFilteredInsights,
periodLengthInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
});
}
@@ -28,8 +45,11 @@ export class InsightsController {
_res: Response,
@Query payload: ListInsightsWorkflowQueryDto,
): Promise<InsightsByWorkflow> {
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity({
dateRange: payload.dateRange ?? 'week',
});
return await this.insightsService.getInsightsByWorkflow({
maxAgeInDays: this.maxAgeInDaysFilteredInsights,
maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
skip: payload.skip,
take: payload.take,
sortBy: payload.sortBy,
@@ -39,10 +59,15 @@ export class InsightsController {
@Get('/by-time')
@GlobalScope('insights:list')
@Licensed('feat:insights:viewDashboard')
async getInsightsByTime(): Promise<InsightsByTime[]> {
async getInsightsByTime(
_req: AuthenticatedRequest,
_res: Response,
@Query payload: InsightsDateFilterDto,
): Promise<InsightsByTime[]> {
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload);
return await this.insightsService.getInsightsByTime({
maxAgeInDays: this.maxAgeInDaysFilteredInsights,
periodUnit: 'day',
maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
periodUnit: dateRangeAndMaxAgeInDays.granularity,
});
}
}

View File

@@ -1,10 +1,13 @@
import type { InsightsSummary } from '@n8n/api-types';
import type { InsightsDateRange } from '@n8n/api-types/src/schemas/insights.schema';
import {
type InsightsSummary,
type InsightsDateRange,
INSIGHTS_DATE_RANGE_KEYS,
} from '@n8n/api-types';
import { OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { Logger } from 'n8n-core';
import type { ExecutionLifecycleHooks } from 'n8n-core';
import type { IRun } from 'n8n-workflow';
import { UserError, type IRun } from 'n8n-workflow';
import { License } from '@/license';
@@ -14,6 +17,16 @@ import { InsightsByPeriodRepository } from './database/repositories/insights-by-
import { InsightsCollectionService } from './insights-collection.service';
import { InsightsCompactionService } from './insights-compaction.service';
const keyRangeToDays: Record<InsightsDateRange['key'], number> = {
day: 1,
week: 7,
'2weeks': 14,
month: 30,
quarter: 90,
'6months': 180,
year: 365,
};
@Service()
export class InsightsService {
constructor(
@@ -181,6 +194,10 @@ export class InsightsService {
});
}
/**
* Returns the available date ranges with their license authorization and time granularity
* when grouped by time.
*/
getAvailableDateRanges(): InsightsDateRange[] {
const maxHistoryInDays =
this.license.getInsightsMaxHistory() === -1
@@ -188,13 +205,31 @@ export class InsightsService {
: this.license.getInsightsMaxHistory();
const isHourlyDateEnabled = this.license.isInsightsHourlyDataEnabled();
return [
{ key: 'day', licensed: isHourlyDateEnabled ?? false, granularity: 'hour' },
{ key: 'week', licensed: maxHistoryInDays >= 7, granularity: 'day' },
{ key: '2weeks', licensed: maxHistoryInDays >= 14, granularity: 'day' },
{ key: 'month', licensed: maxHistoryInDays >= 30, granularity: 'day' },
{ key: 'quarter', licensed: maxHistoryInDays >= 90, granularity: 'week' },
{ key: 'year', licensed: maxHistoryInDays >= 365, granularity: 'week' },
];
return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({
key,
licensed:
key === 'day' ? (isHourlyDateEnabled ?? false) : maxHistoryInDays >= keyRangeToDays[key],
granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week',
}));
}
getMaxAgeInDaysAndGranularity(
dateRangeKey: InsightsDateRange['key'],
): InsightsDateRange & { maxAgeInDays: number } {
const availableDateRanges = this.getAvailableDateRanges();
const dateRange = availableDateRanges.find((range) => range.key === dateRangeKey);
if (!dateRange) {
// Not supposed to happen if we trust the dateRangeKey type
throw new UserError('The selected date range is not available');
}
if (!dateRange.licensed) {
throw new UserError(
'The selected date range exceeds the maximum history allowed by your license.',
);
}
return { ...dateRange, maxAgeInDays: keyRangeToDays[dateRangeKey] };
}
}

View File

@@ -1,3 +1,5 @@
import type { InsightsDateRange } from '@n8n/api-types';
import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking';
@@ -11,6 +13,7 @@ let agents: Record<string, SuperAgentTest> = {};
const testServer = utils.setupTestServer({
endpointGroups: ['insights', 'license', 'auth'],
enabledFeatures: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'],
quotas: { 'quota:insights:maxHistoryDays': 365 },
});
beforeAll(async () => {
@@ -49,6 +52,54 @@ describe('GET /insights routes return 403 for dashboard routes when summary lice
);
});
describe('GET /insights routes return 403 if date range outside license limits', () => {
beforeAll(() => {
testServer.license.setDefaults({ quotas: { 'quota:insights:maxHistoryDays': 3 } });
});
test('Call should throw forbidden for default week insights', async () => {
const authAgent = agents.admin;
await authAgent.get('/insights/summary').expect(403);
await authAgent.get('/insights/by-time').expect(403);
await authAgent.get('/insights/by-workflow').expect(403);
});
test('Call should throw forbidden for daily data without viewHourlyData enabled', async () => {
const authAgent = agents.admin;
await authAgent.get('/insights/summary?dateRange=day').expect(403);
await authAgent.get('/insights/by-time?dateRange=day').expect(403);
await authAgent.get('/insights/by-workflow?dateRange=day').expect(403);
});
});
describe('GET /insights routes return 200 if date range inside license limits', () => {
beforeAll(() => {
testServer.license.setDefaults({
features: [
'feat:insights:viewSummary',
'feat:insights:viewDashboard',
'feat:insights:viewHourlyData',
],
quotas: { 'quota:insights:maxHistoryDays': 365 },
});
});
test.each<InsightsDateRange['key']>([
'day',
'week',
'2weeks',
'month',
'quarter',
'6months',
'year',
])('Call should work for date range %s', async (dateRange) => {
const authAgent = agents.admin;
await authAgent.get(`/insights/summary?dateRange=${dateRange}`).expect(200);
await authAgent.get(`/insights/by-time?dateRange=${dateRange}`).expect(200);
await authAgent.get(`/insights/by-workflow?dateRange=${dateRange}`).expect(200);
});
});
describe('GET /insights/by-workflow', () => {
beforeAll(() => {
testServer.license.setDefaults({