diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 3412f72a8f..1066aa059c 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -33,6 +33,9 @@ import { WorkflowEntity } from './workflow-entity'; import { WorkflowHistory } from './workflow-history'; import { WorkflowStatistics } from './workflow-statistics'; import { WorkflowTagMapping } from './workflow-tag-mapping'; +import { InsightsByPeriod } from '../../modules/insights/entities/insights-by-period'; +import { InsightsMetadata } from '../../modules/insights/entities/insights-metadata'; +import { InsightsRaw } from '../../modules/insights/entities/insights-raw'; export const entities = { AnnotationTagEntity, @@ -70,4 +73,7 @@ export const entities = { TestCaseExecution, Folder, FolderTagMapping, + InsightsRaw, + InsightsMetadata, + InsightsByPeriod, }; diff --git a/packages/cli/src/databases/migrations/common/1739549398681-CreateAnalyticsTables.ts b/packages/cli/src/databases/migrations/common/1739549398681-CreateAnalyticsTables.ts index ee1bdb4ccf..02ae64ce4f 100644 --- a/packages/cli/src/databases/migrations/common/1739549398681-CreateAnalyticsTables.ts +++ b/packages/cli/src/databases/migrations/common/1739549398681-CreateAnalyticsTables.ts @@ -99,8 +99,8 @@ export class CreateAnalyticsTables1739549398681 implements ReversibleMigration { } async down({ schemaBuilder: { dropTable } }: MigrationContext) { - await dropTable(names.t.analyticsMetadata); await dropTable(names.t.analyticsRaw); await dropTable(names.t.analyticsByPeriod); + await dropTable(names.t.analyticsMetadata); } } diff --git a/packages/cli/src/databases/migrations/common/1741167584277-RenameAnalyticsToInsights.ts b/packages/cli/src/databases/migrations/common/1741167584277-RenameAnalyticsToInsights.ts new file mode 100644 index 0000000000..7cda87fef1 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1741167584277-RenameAnalyticsToInsights.ts @@ -0,0 +1,112 @@ +import type { IrreversibleMigration, MigrationContext } from '@/databases/types'; + +const names = { + // table names + t: { + analyticsMetadata: 'analytics_metadata', + analyticsRaw: 'analytics_raw', + analyticsByPeriod: 'analytics_by_period', + + insightsMetadata: 'insights_metadata', + insightsRaw: 'insights_raw', + insightsByPeriod: 'insights_by_period', + + workflowEntity: 'workflow_entity', + project: 'project', + }, + // column names by table + c: { + insightsMetadata: { + metaId: 'metaId', + projectId: 'projectId', + workflowId: 'workflowId', + }, + insightsRaw: { + metaId: 'metaId', + }, + insightsByPeriod: { + metaId: 'metaId', + type: 'type', + periodUnit: 'periodUnit', + periodStart: 'periodStart', + }, + project: { + id: 'id', + }, + workflowEntity: { + id: 'id', + }, + }, +}; + +export class RenameAnalyticsToInsights1741167584277 implements IrreversibleMigration { + async up({ schemaBuilder: { createTable, column, dropTable } }: MigrationContext) { + // Until the insights feature is released we're dropping the tables instead + // of migrating them. + await dropTable(names.t.analyticsRaw); + await dropTable(names.t.analyticsByPeriod); + await dropTable(names.t.analyticsMetadata); + + await createTable(names.t.insightsMetadata) + .withColumns( + column(names.c.insightsMetadata.metaId).int.primary.autoGenerate2, + column(names.c.insightsMetadata.workflowId).varchar(16), + column(names.c.insightsMetadata.projectId).varchar(36), + column('workflowName').varchar(128).notNull, + column('projectName').varchar(255).notNull, + ) + .withForeignKey(names.c.insightsMetadata.workflowId, { + tableName: names.t.workflowEntity, + columnName: names.c.workflowEntity.id, + onDelete: 'SET NULL', + }) + .withForeignKey(names.c.insightsMetadata.projectId, { + tableName: names.t.project, + columnName: names.c.project.id, + onDelete: 'SET NULL', + }) + .withIndexOn(names.c.insightsMetadata.workflowId, true); + + const typeComment = '0: time_saved_minutes, 1: runtime_milliseconds, 2: success, 3: failure'; + + await createTable(names.t.insightsRaw) + .withColumns( + column('id').int.primary.autoGenerate2, + column(names.c.insightsRaw.metaId).int.notNull, + column('type').int.notNull.comment(typeComment), + column('value').int.notNull, + column('timestamp').timestampTimezone(0).default('CURRENT_TIMESTAMP').notNull, + ) + .withForeignKey(names.c.insightsRaw.metaId, { + tableName: names.t.insightsMetadata, + columnName: names.c.insightsMetadata.metaId, + onDelete: 'CASCADE', + }); + + await createTable(names.t.insightsByPeriod) + .withColumns( + column('id').int.primary.autoGenerate2, + column(names.c.insightsByPeriod.metaId).int.notNull, + column(names.c.insightsByPeriod.type).int.notNull.comment(typeComment), + column('value').int.notNull, + column(names.c.insightsByPeriod.periodUnit).int.notNull.comment('0: hour, 1: day, 2: week'), + column(names.c.insightsByPeriod.periodStart) + .default('CURRENT_TIMESTAMP') + .timestampTimezone(0), + ) + .withForeignKey(names.c.insightsByPeriod.metaId, { + tableName: names.t.insightsMetadata, + columnName: names.c.insightsMetadata.metaId, + onDelete: 'CASCADE', + }) + .withIndexOn( + [ + names.c.insightsByPeriod.periodStart, + names.c.insightsByPeriod.type, + names.c.insightsByPeriod.periodUnit, + names.c.insightsByPeriod.metaId, + ], + true, + ); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 01cda1ef2e..c19d8218c4 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -82,6 +82,7 @@ import { CreateTestCaseExecutionTable1736947513045 } from '../common/17369475130 import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns'; import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; +import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; export const mysqlMigrations: Migration[] = [ @@ -168,4 +169,5 @@ export const mysqlMigrations: Migration[] = [ FixTestDefinitionPrimaryKey1739873751194, CreateAnalyticsTables1739549398681, UpdateParentFolderIdColumn1740445074052, + RenameAnalyticsToInsights1741167584277, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 03d1368072..ddfc9c470c 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -82,6 +82,7 @@ import { CreateTestCaseExecutionTable1736947513045 } from '../common/17369475130 import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns'; import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; +import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -166,4 +167,5 @@ export const postgresMigrations: Migration[] = [ CreateFolderTable1738709609940, CreateAnalyticsTables1739549398681, UpdateParentFolderIdColumn1740445074052, + RenameAnalyticsToInsights1741167584277, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index c175d214c7..179c103aeb 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -79,6 +79,7 @@ import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-A import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable'; import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; +import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -160,6 +161,7 @@ const sqliteMigrations: Migration[] = [ CreateFolderTable1738709609940, CreateAnalyticsTables1739549398681, UpdateParentFolderIdColumn1740445074052, + RenameAnalyticsToInsights1741167584277, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts new file mode 100644 index 0000000000..9b84cbea6a --- /dev/null +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -0,0 +1,247 @@ +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import { DateTime } from 'luxon'; +import type { ExecutionLifecycleHooks } from 'n8n-core'; +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 { InsightsMetadataRepository } from '@/modules/insights/repositories/insights-metadata.repository'; +import { InsightsRawRepository } from '@/modules/insights/repositories/insights-raw.repository'; +import { createTeamProject } from '@test-integration/db/projects'; +import { createWorkflow } from '@test-integration/db/workflows'; +import * as testDb from '@test-integration/test-db'; + +import { InsightsService } from '../insights.service'; +import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository'; + +async function truncateAll() { + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsMetadataRepository = Container.get(InsightsMetadataRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + for (const repo of [ + insightsRawRepository, + insightsMetadataRepository, + insightsByPeriodRepository, + ]) { + await repo.delete({}); + } +} + +describe('workflowExecuteAfterHandler', () => { + let insightsService: InsightsService; + let insightsRawRepository: InsightsRawRepository; + let insightsMetadataRepository: InsightsMetadataRepository; + beforeAll(async () => { + await testDb.init(); + + insightsService = Container.get(InsightsService); + insightsRawRepository = Container.get(InsightsRawRepository); + insightsMetadataRepository = Container.get(InsightsMetadataRepository); + }); + + let project: Project; + let workflow: IWorkflowDb & WorkflowEntity; + + beforeEach(async () => { + await truncateAll(); + + project = await createTeamProject(); + workflow = await createWorkflow( + { + settings: { + timeSavedPerExecution: 3, + }, + }, + project, + ); + }); + + test.each<{ status: ExecutionStatus; type: TypeUnits }>([ + { status: 'success', type: 'success' }, + { status: 'error', type: 'failure' }, + { status: 'crashed', type: 'failure' }, + ])('stores events for executions with the status `$status`', async ({ status, type }) => { + // ARRANGE + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode: 'webhook', + status, + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT + await insightsService.workflowExecuteAfterHandler(ctx, run); + + // ASSERT + const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + + if (!metadata) { + return fail('expected metadata to exist'); + } + + expect(metadata).toMatchObject({ + workflowId: workflow.id, + workflowName: workflow.name, + projectId: project.id, + projectName: project.name, + }); + + const allInsights = await insightsRawRepository.find(); + expect(allInsights).toHaveLength(status === 'success' ? 3 : 2); + expect(allInsights).toContainEqual( + expect.objectContaining({ metaId: metadata.metaId, type, value: 1 }), + ); + expect(allInsights).toContainEqual( + expect.objectContaining({ + metaId: metadata.metaId, + type: 'runtime_ms', + value: stoppedAt.diff(startedAt).toMillis(), + }), + ); + if (status === 'success') { + expect(allInsights).toContainEqual( + expect.objectContaining({ + metaId: metadata.metaId, + type: 'time_saved_min', + value: 3, + }), + ); + } + }); + + test.each<{ status: ExecutionStatus }>([ + { status: 'waiting' }, + { status: 'canceled' }, + { status: 'unknown' }, + { status: 'new' }, + { status: 'running' }, + ])('does not store events for executions with the status `$status`', async ({ status }) => { + // ARRANGE + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode: 'webhook', + status, + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT + await insightsService.workflowExecuteAfterHandler(ctx, run); + + // ASSERT + const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + const allInsights = await insightsRawRepository.find(); + expect(metadata).toBeNull(); + expect(allInsights).toHaveLength(0); + }); + + test.each<{ mode: WorkflowExecuteMode }>([{ mode: 'internal' }, { mode: 'manual' }])( + 'does not store events for executions with the mode `$mode`', + async ({ mode }) => { + // ARRANGE + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode, + status: 'success', + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT + await insightsService.workflowExecuteAfterHandler(ctx, run); + + // ASSERT + const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + const allInsights = await insightsRawRepository.find(); + expect(metadata).toBeNull(); + expect(allInsights).toHaveLength(0); + }, + ); + + test.each<{ mode: WorkflowExecuteMode }>([ + { mode: 'evaluation' }, + { mode: 'error' }, + { mode: 'cli' }, + { mode: 'retry' }, + { mode: 'trigger' }, + { mode: 'webhook' }, + { mode: 'integrated' }, + ])('stores events for executions with the mode `$mode`', async ({ mode }) => { + // ARRANGE + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode, + status: 'success', + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT + await insightsService.workflowExecuteAfterHandler(ctx, run); + + // ASSERT + const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + + if (!metadata) { + return fail('expected metadata to exist'); + } + + expect(metadata).toMatchObject({ + workflowId: workflow.id, + workflowName: workflow.name, + projectId: project.id, + projectName: project.name, + }); + + const allInsights = await insightsRawRepository.find(); + expect(allInsights).toHaveLength(3); + expect(allInsights).toContainEqual( + expect.objectContaining({ metaId: metadata.metaId, type: 'success', value: 1 }), + ); + expect(allInsights).toContainEqual( + expect.objectContaining({ + metaId: metadata.metaId, + type: 'runtime_ms', + value: stoppedAt.diff(startedAt).toMillis(), + }), + ); + expect(allInsights).toContainEqual( + expect.objectContaining({ + metaId: metadata.metaId, + type: 'time_saved_min', + value: 3, + }), + ); + }); + + test("throws UnexpectedError if the execution's workflow has no owner", async () => { + // ARRANGE + const workflow = await createWorkflow({}); + const ctx = mock({ workflowData: workflow }); + const startedAt = DateTime.utc(); + const stoppedAt = startedAt.plus({ seconds: 5 }); + const run = mock({ + mode: 'webhook', + status: 'success', + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + }); + + // ACT & ASSERT + await expect(insightsService.workflowExecuteAfterHandler(ctx, run)).rejects.toThrowError( + `Could not find an owner for the workflow with the name '${workflow.name}' and the id '${workflow.id}'`, + ); + }); +}); diff --git a/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts b/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts new file mode 100644 index 0000000000..8781220b93 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts @@ -0,0 +1,64 @@ +import { Container } from '@n8n/di'; +import type { DateTime } from 'luxon'; +import type { IWorkflowBase } from 'n8n-workflow'; + +import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; + +import { InsightsMetadata } from '../../entities/insights-metadata'; +import { InsightsRaw } from '../../entities/insights-raw'; +import { InsightsMetadataRepository } from '../../repositories/insights-metadata.repository'; +import { InsightsRawRepository } from '../../repositories/insights-raw.repository'; + +async function getWorkflowSharing(workflow: IWorkflowBase) { + return await Container.get(SharedWorkflowRepository).find({ + where: { workflowId: workflow.id }, + relations: { project: true }, + }); +} + +export async function createMetadata(workflow: WorkflowEntity) { + const insightsMetadataRepository = Container.get(InsightsMetadataRepository); + const alreadyExisting = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); + + if (alreadyExisting) { + return alreadyExisting; + } + + const metadata = new InsightsMetadata(); + metadata.workflowName = workflow.name; + metadata.workflowId = workflow.id; + + const workflowSharing = (await getWorkflowSharing(workflow)).find( + (wfs) => wfs.role === 'workflow:owner', + ); + if (workflowSharing) { + metadata.projectName = workflowSharing.project.name; + metadata.projectId = workflowSharing.project.id; + } + + await insightsMetadataRepository.save(metadata); + + return metadata; +} + +export async function createRawInsightsEvent( + workflow: WorkflowEntity, + parameters: { + type: InsightsRaw['type']; + value: number; + timestamp?: DateTime; + }, +) { + const insightsRawRepository = Container.get(InsightsRawRepository); + const metadata = await createMetadata(workflow); + + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = parameters.type; + event.value = parameters.value; + if (parameters.timestamp) { + event.timestamp = parameters.timestamp.toUTC().toJSDate(); + } + return await insightsRawRepository.save(event); +} diff --git a/packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts b/packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts new file mode 100644 index 0000000000..33af07ad56 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts @@ -0,0 +1,51 @@ +import { Container } from '@n8n/di'; + +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'; + +let insightsRawRepository: InsightsRawRepository; + +beforeAll(async () => { + await testDb.init(); + insightsRawRepository = Container.get(InsightsRawRepository); +}); + +beforeEach(async () => { + await insightsRawRepository.delete({}); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('Insights By Period', () => { + test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnits[])( + '`%s` can be serialized and deserialized correctly', + (typeUnit) => { + // ARRANGE + const insightByPeriod = new InsightsByPeriod(); + + // ACT + insightByPeriod.type = typeUnit; + + // ASSERT + expect(insightByPeriod.type).toBe(typeUnit); + }, + ); + test.each(['hour', 'day', 'week'] satisfies PeriodUnits[])( + '`%s` can be serialized and deserialized correctly', + (periodUnit) => { + // ARRANGE + const insightByPeriod = new InsightsByPeriod(); + + // ACT + insightByPeriod.periodUnit = periodUnit; + + // ASSERT + expect(insightByPeriod.periodUnit).toBe(periodUnit); + }, + ); +}); diff --git a/packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts b/packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts new file mode 100644 index 0000000000..848e0870e0 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts @@ -0,0 +1,80 @@ +import { Container } from '@n8n/di'; +import { DateTime } from 'luxon'; + +import { createTeamProject } from '@test-integration/db/projects'; +import { createWorkflow } from '@test-integration/db/workflows'; +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'; + +let insightsRawRepository: InsightsRawRepository; + +beforeAll(async () => { + await testDb.init(); + insightsRawRepository = Container.get(InsightsRawRepository); +}); + +beforeEach(async () => { + await insightsRawRepository.delete({}); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('Insights Raw Entity', () => { + test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnits[])( + '`%s` can be serialized and deserialized correctly', + (typeUnit) => { + // ARRANGE + const rawInsight = new InsightsRaw(); + + // ACT + rawInsight.type = typeUnit; + + // ASSERT + expect(rawInsight.type).toBe(typeUnit); + }, + ); + + test('`timestamp` can be serialized and deserialized correctly', () => { + // ARRANGE + const rawInsight = new InsightsRaw(); + const now = new Date(); + + // ACT + + rawInsight.timestamp = now; + + // ASSERT + now.setMilliseconds(0); + expect(rawInsight.timestamp).toEqual(now); + }); + + test('timestamp uses the correct default value', async () => { + // ARRANGE + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + await createMetadata(workflow); + const rawInsight = await createRawInsightsEvent(workflow, { + type: 'success', + value: 1, + }); + + // ACT + const now = DateTime.utc().startOf('second'); + await insightsRawRepository.save(rawInsight); + + // ASSERT + const timestampValue = await insightsRawRepository.find(); + expect(timestampValue).toHaveLength(1); + const timestamp = timestampValue[0].timestamp; + + expect( + Math.abs(now.toSeconds() - DateTime.fromJSDate(timestamp).toUTC().toSeconds()), + ).toBeLessThan(2); + }); +}); diff --git a/packages/cli/src/modules/insights/entities/insights-by-period.ts b/packages/cli/src/modules/insights/entities/insights-by-period.ts new file mode 100644 index 0000000000..b2e532be33 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/insights-by-period.ts @@ -0,0 +1,62 @@ +import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm'; +import { UnexpectedError } from 'n8n-workflow'; + +import type { PeriodUnits } from './insights-shared'; +import { + isValidPeriodNumber, + isValidTypeNumber, + NumberToPeriodUnit, + NumberToType, + PeriodUnitToNumber, + TypeToNumber, +} from './insights-shared'; +import { datetimeColumnType } from '../../../databases/entities/abstract-entity'; + +@Entity() +export class InsightsByPeriod extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + metaId: number; + + @Column({ name: 'type', type: 'int' }) + private type_: number; + + get type() { + if (!isValidTypeNumber(this.type_)) { + throw new UnexpectedError( + `Type '${this.type_}' is not a valid type for 'InsightsByPeriod.type'`, + ); + } + + return NumberToType[this.type_]; + } + + set type(value: keyof typeof TypeToNumber) { + this.type_ = TypeToNumber[value]; + } + + @Column() + value: number; + + @Column({ name: 'periodUnit' }) + private periodUnit_: number; + + get periodUnit() { + if (!isValidPeriodNumber(this.periodUnit_)) { + throw new UnexpectedError( + `Period unit '${this.periodUnit_}' is not a valid unit for 'InsightsByPeriod.periodUnit'`, + ); + } + + return NumberToPeriodUnit[this.periodUnit_]; + } + + set periodUnit(value: PeriodUnits) { + this.periodUnit_ = PeriodUnitToNumber[value]; + } + + @Column({ type: datetimeColumnType }) + periodStart: Date; +} diff --git a/packages/cli/src/modules/insights/entities/insights-metadata.ts b/packages/cli/src/modules/insights/entities/insights-metadata.ts new file mode 100644 index 0000000000..2c050d3c0c --- /dev/null +++ b/packages/cli/src/modules/insights/entities/insights-metadata.ts @@ -0,0 +1,19 @@ +import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm'; + +@Entity() +export class InsightsMetadata extends BaseEntity { + @PrimaryGeneratedColumn() + metaId: number; + + @Column({ unique: true, type: 'varchar', length: 16 }) + workflowId: string; + + @Column({ type: 'varchar', length: 36 }) + projectId: string; + + @Column({ type: 'varchar', length: 128 }) + workflowName: string; + + @Column({ type: 'varchar', length: 255 }) + projectName: string; +} diff --git a/packages/cli/src/modules/insights/entities/insights-raw.ts b/packages/cli/src/modules/insights/entities/insights-raw.ts new file mode 100644 index 0000000000..ceff552a98 --- /dev/null +++ b/packages/cli/src/modules/insights/entities/insights-raw.ts @@ -0,0 +1,49 @@ +import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; +import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm'; +import { UnexpectedError } from 'n8n-workflow'; + +import { isValidTypeNumber, NumberToType, TypeToNumber } from './insights-shared'; +import { datetimeColumnType } from '../../../databases/entities/abstract-entity'; + +export const { type: dbType } = Container.get(GlobalConfig).database; + +@Entity() +export class InsightsRaw extends BaseEntity { + constructor() { + super(); + this.timestamp = new Date(); + } + + @PrimaryGeneratedColumn() + id: number; + + @Column() + metaId: number; + + @Column({ name: 'type', type: 'int' }) + private type_: number; + + get type() { + if (!isValidTypeNumber(this.type_)) { + throw new UnexpectedError( + `Type '${this.type_}' is not a valid type for 'InsightsByPeriod.type'`, + ); + } + + return NumberToType[this.type_]; + } + + set type(value: keyof typeof TypeToNumber) { + this.type_ = TypeToNumber[value]; + } + + @Column() + value: number; + + @Column({ + name: 'timestamp', + type: datetimeColumnType, + }) + timestamp: Date; +} diff --git a/packages/cli/src/modules/insights/entities/insights-shared.ts b/packages/cli/src/modules/insights/entities/insights-shared.ts new file mode 100644 index 0000000000..14260dd69e --- /dev/null +++ b/packages/cli/src/modules/insights/entities/insights-shared.ts @@ -0,0 +1,46 @@ +function isValid>( + value: number | string | symbol, + constant: T, +): value is keyof T { + return Object.keys(constant).includes(value.toString()); +} + +// Periods +export const PeriodUnitToNumber = { + hour: 0, + day: 1, + week: 2, +} as const; +export type PeriodUnits = keyof typeof PeriodUnitToNumber; +export type PeriodUnitNumbers = (typeof PeriodUnitToNumber)[PeriodUnits]; +export const NumberToPeriodUnit = Object.entries(PeriodUnitToNumber).reduce( + (acc, [key, value]: [PeriodUnits, PeriodUnitNumbers]) => { + acc[value] = key; + return acc; + }, + {} as Record, +); +export function isValidPeriodNumber(value: number) { + return isValid(value, NumberToPeriodUnit); +} + +// Types +export const TypeToNumber = { + time_saved_min: 0, + runtime_ms: 1, + success: 2, + failure: 3, +} as const; +export type TypeUnits = keyof typeof TypeToNumber; +export type TypeUnitNumbers = (typeof TypeToNumber)[TypeUnits]; +export const NumberToType = Object.entries(TypeToNumber).reduce( + (acc, [key, value]: [TypeUnits, TypeUnitNumbers]) => { + acc[value] = key; + return acc; + }, + {} as Record, +); + +export function isValidTypeNumber(value: number) { + return isValid(value, NumberToType); +} diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts new file mode 100644 index 0000000000..0c7920cf3d --- /dev/null +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -0,0 +1,29 @@ +import type { ExecutionLifecycleHooks } from 'n8n-core'; +import { InstanceSettings, Logger } from 'n8n-core'; + +import type { BaseN8nModule } from '@/decorators/module'; +import { N8nModule } from '@/decorators/module'; + +import { InsightsService } from './insights.service'; + +@N8nModule() +export class InsightsModule implements BaseN8nModule { + constructor( + private readonly logger: Logger, + private readonly insightsService: InsightsService, + private readonly instanceSettings: InstanceSettings, + ) { + this.logger = this.logger.scoped('insights'); + } + + registerLifecycleHooks(hooks: ExecutionLifecycleHooks) { + const insightsService = this.insightsService; + + // Workers should not be saving any insights + if (this.instanceSettings.instanceType !== 'worker') { + hooks.addHandler('workflowExecuteAfter', async function (fullRunData) { + await insightsService.workflowExecuteAfterHandler(this, fullRunData); + }); + } + } +} diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts new file mode 100644 index 0000000000..3ccd92b986 --- /dev/null +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -0,0 +1,110 @@ +import { Service } from '@n8n/di'; +import type { ExecutionLifecycleHooks } from 'n8n-core'; +import { UnexpectedError } from 'n8n-workflow'; +import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow'; + +import { SharedWorkflow } from '@/databases/entities/shared-workflow'; +import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; +import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata'; +import { InsightsRaw } from '@/modules/insights/entities/insights-raw'; + +const shouldSkipStatus: Record = { + success: false, + crashed: false, + error: false, + + canceled: true, + new: true, + running: true, + unknown: true, + waiting: true, +}; + +const shouldSkipMode: Record = { + cli: false, + error: false, + integrated: false, + retry: false, + trigger: false, + webhook: false, + evaluation: false, + + internal: true, + manual: true, +}; + +@Service() +export class InsightsService { + constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} + + async workflowExecuteAfterHandler(ctx: ExecutionLifecycleHooks, fullRunData: IRun) { + if (shouldSkipStatus[fullRunData.status] || shouldSkipMode[fullRunData.mode]) { + return; + } + + const status = fullRunData.status === 'success' ? 'success' : 'failure'; + + await this.sharedWorkflowRepository.manager.transaction(async (trx) => { + const sharedWorkflow = await trx.findOne(SharedWorkflow, { + where: { workflowId: ctx.workflowData.id, role: 'workflow:owner' }, + relations: { project: true }, + }); + + if (!sharedWorkflow) { + throw new UnexpectedError( + `Could not find an owner for the workflow with the name '${ctx.workflowData.name}' and the id '${ctx.workflowData.id}'`, + ); + } + + await trx.upsert( + InsightsMetadata, + { + workflowId: ctx.workflowData.id, + workflowName: ctx.workflowData.name, + projectId: sharedWorkflow.projectId, + projectName: sharedWorkflow.project.name, + }, + ['workflowId'], + ); + const metadata = await trx.findOneBy(InsightsMetadata, { + workflowId: ctx.workflowData.id, + }); + + if (!metadata) { + // This can't happen, we just wrote the metadata in the same + // transaction. + throw new UnexpectedError( + `Could not find metadata for the workflow with the id '${ctx.workflowData.id}'`, + ); + } + + // success or failure event + { + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = status; + event.value = 1; + await trx.insert(InsightsRaw, event); + } + + // run time event + if (fullRunData.stoppedAt) { + const value = fullRunData.stoppedAt.getTime() - fullRunData.startedAt.getTime(); + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = 'runtime_ms'; + event.value = value; + await trx.insert(InsightsRaw, event); + } + + // time saved event + if (status === 'success' && ctx.workflowData.settings?.timeSavedPerExecution) { + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = 'time_saved_min'; + event.value = ctx.workflowData.settings.timeSavedPerExecution; + await trx.insert(InsightsRaw, event); + } + }); + } +} diff --git a/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts new file mode 100644 index 0000000000..94bc057271 --- /dev/null +++ b/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { InsightsByPeriod } from '../entities/insights-by-period'; + +@Service() +export class InsightsByPeriodRepository extends Repository { + constructor(dataSource: DataSource) { + super(InsightsByPeriod, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/insights/repositories/insights-metadata.repository.ts b/packages/cli/src/modules/insights/repositories/insights-metadata.repository.ts new file mode 100644 index 0000000000..f21cd5ddc9 --- /dev/null +++ b/packages/cli/src/modules/insights/repositories/insights-metadata.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { InsightsMetadata } from '../entities/insights-metadata'; + +@Service() +export class InsightsMetadataRepository extends Repository { + constructor(dataSource: DataSource) { + super(InsightsMetadata, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts b/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts new file mode 100644 index 0000000000..9bad708eed --- /dev/null +++ b/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { InsightsRaw } from '../entities/insights-raw'; + +@Service() +export class InsightsRawRepository extends Repository { + constructor(dataSource: DataSource) { + super(InsightsRaw, dataSource.manager); + } +} diff --git a/packages/cli/src/utils/sql.ts b/packages/cli/src/utils/sql.ts new file mode 100644 index 0000000000..ce25b661e8 --- /dev/null +++ b/packages/cli/src/utils/sql.ts @@ -0,0 +1,17 @@ +/** + * Provides syntax highlighting for embedded SQL queries in template strings. + */ +export function sql(strings: TemplateStringsArray, ...values: string[]): string { + let result = ''; + + // Interleave the strings with the values + for (let i = 0; i < values.length; i++) { + result += strings[i]; + result += values[i]; + } + + // Add the last string + result += strings[strings.length - 1]; + + return result; +} diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index 87d7d21242..3feec218dd 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -24,7 +24,7 @@ export async function createManyWorkflows( } export function newWorkflow(attributes: Partial = {}): IWorkflowDb { - const { active, name, nodes, connections, versionId } = attributes; + const { active, name, nodes, connections, versionId, settings } = attributes; const workflowEntity = Container.get(WorkflowRepository).create({ active: active ?? false, @@ -41,7 +41,7 @@ export function newWorkflow(attributes: Partial = {}): IWorkflowDb ], connections: connections ?? {}, versionId: versionId ?? uuid(), - settings: {}, + settings: settings ?? {}, ...attributes, }); @@ -119,8 +119,9 @@ export async function shareWorkflowWithProjects( } export async function getWorkflowSharing(workflow: IWorkflowBase) { - return await Container.get(SharedWorkflowRepository).findBy({ - workflowId: workflow.id, + return await Container.get(SharedWorkflowRepository).find({ + where: { workflowId: workflow.id }, + relations: { project: true }, }); } diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index 495f64a15a..8e50f384d7 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -86,6 +86,9 @@ const repositories = [ 'WorkflowTagMapping', 'ApiKey', 'Folder', + 'InsightsRaw', + 'InsightsMetadata', + 'InsightsByPeriod', ] as const; /** diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 1f876acb21..81d0482c7a 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2398,6 +2398,7 @@ export interface IWorkflowSettings { saveExecutionProgress?: 'DEFAULT' | boolean; executionTimeout?: number; executionOrder?: 'v0' | 'v1'; + timeSavedPerExecution?: number; } export interface WorkflowFEMeta {