mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-21 11:49:59 +00:00
feat(core): Add production root executions (#14845)
Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com> Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
@@ -16,6 +16,9 @@ export class WorkflowStatistics {
|
|||||||
@Column()
|
@Column()
|
||||||
count: number;
|
count: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
rootCount: number;
|
||||||
|
|
||||||
@Column(datetimeColumnType)
|
@Column(datetimeColumnType)
|
||||||
latestEvent: Date;
|
latestEvent: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReversibleMigration, MigrationContext } from '@/databases/types';
|
||||||
|
|
||||||
|
const columnName = 'rootCount';
|
||||||
|
const tableName = 'workflow_statistics';
|
||||||
|
|
||||||
|
export class AddWorkflowStatisticsRootCount1745587087521 implements ReversibleMigration {
|
||||||
|
async up({ escape, runQuery }: MigrationContext) {
|
||||||
|
const escapedTableName = escape.tableName(tableName);
|
||||||
|
const escapedColumnName = escape.columnName(columnName);
|
||||||
|
|
||||||
|
await runQuery(
|
||||||
|
`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} INTEGER DEFAULT 0`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down({ escape, runQuery }: MigrationContext) {
|
||||||
|
const escapedTableName = escape.tableName(tableName);
|
||||||
|
const escapedColumnName = escape.columnName(columnName);
|
||||||
|
|
||||||
|
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFo
|
|||||||
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
||||||
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
||||||
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
|
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
|
||||||
|
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||||
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
|
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
|
||||||
|
|
||||||
export const mysqlMigrations: Migration[] = [
|
export const mysqlMigrations: Migration[] = [
|
||||||
@@ -172,4 +173,5 @@ export const mysqlMigrations: Migration[] = [
|
|||||||
UpdateParentFolderIdColumn1740445074052,
|
UpdateParentFolderIdColumn1740445074052,
|
||||||
RenameAnalyticsToInsights1741167584277,
|
RenameAnalyticsToInsights1741167584277,
|
||||||
AddScopesColumnToApiKeys1742918400000,
|
AddScopesColumnToApiKeys1742918400000,
|
||||||
|
AddWorkflowStatisticsRootCount1745587087521,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFo
|
|||||||
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
||||||
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
||||||
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
|
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
|
||||||
|
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
@@ -170,4 +171,5 @@ export const postgresMigrations: Migration[] = [
|
|||||||
UpdateParentFolderIdColumn1740445074052,
|
UpdateParentFolderIdColumn1740445074052,
|
||||||
RenameAnalyticsToInsights1741167584277,
|
RenameAnalyticsToInsights1741167584277,
|
||||||
AddScopesColumnToApiKeys1742918400000,
|
AddScopesColumnToApiKeys1742918400000,
|
||||||
|
AddWorkflowStatisticsRootCount1745587087521,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import { CreateTestCaseExecutionTable1736947513045 } from '../common/17369475130
|
|||||||
import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns';
|
import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns';
|
||||||
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
||||||
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
||||||
|
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
@@ -164,6 +165,7 @@ const sqliteMigrations: Migration[] = [
|
|||||||
UpdateParentFolderIdColumn1740445074052,
|
UpdateParentFolderIdColumn1740445074052,
|
||||||
RenameAnalyticsToInsights1741167584277,
|
RenameAnalyticsToInsights1741167584277,
|
||||||
AddScopesColumnToApiKeys1742918400000,
|
AddScopesColumnToApiKeys1742918400000,
|
||||||
|
AddWorkflowStatisticsRootCount1745587087521,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { mock, mockClear } from 'jest-mock-extended';
|
|||||||
import { StatisticsNames, WorkflowStatistics } from '@/databases/entities/workflow-statistics';
|
import { StatisticsNames, WorkflowStatistics } from '@/databases/entities/workflow-statistics';
|
||||||
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository';
|
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository';
|
||||||
import { mockEntityManager } from '@test/mocking';
|
import { mockEntityManager } from '@test/mocking';
|
||||||
|
import { createWorkflow } from '@test-integration/db/workflows';
|
||||||
|
import * as testDb from '@test-integration/test-db';
|
||||||
|
|
||||||
describe('insertWorkflowStatistics', () => {
|
describe('insertWorkflowStatistics', () => {
|
||||||
const entityManager = mockEntityManager(WorkflowStatistics);
|
const entityManager = mockEntityManager(WorkflowStatistics);
|
||||||
@@ -51,3 +53,69 @@ describe('insertWorkflowStatistics', () => {
|
|||||||
expect(insertionResult).toBe('failed');
|
expect(insertionResult).toBe('failed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('upsertWorkflowStatistics', () => {
|
||||||
|
let repository: WorkflowStatisticsRepository;
|
||||||
|
beforeAll(async () => {
|
||||||
|
Container.reset();
|
||||||
|
await testDb.init();
|
||||||
|
repository = Container.get(WorkflowStatisticsRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testDb.terminate();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testDb.truncate(['WorkflowStatistics']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Successfully inserts data when it is not yet present', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const workflow = await createWorkflow({});
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const upsertResult = await repository.upsertWorkflowStatistics(
|
||||||
|
StatisticsNames.productionSuccess,
|
||||||
|
workflow.id,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(upsertResult).toBe('insert');
|
||||||
|
const insertedData = await repository.find();
|
||||||
|
expect(insertedData).toHaveLength(1);
|
||||||
|
expect(insertedData[0].workflowId).toBe(workflow.id);
|
||||||
|
expect(insertedData[0].name).toBe(StatisticsNames.productionSuccess);
|
||||||
|
expect(insertedData[0].count).toBe(1);
|
||||||
|
expect(insertedData[0].rootCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Successfully updates data when it is already present', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const workflow = await createWorkflow({});
|
||||||
|
await repository.insert({
|
||||||
|
workflowId: workflow.id,
|
||||||
|
name: StatisticsNames.productionSuccess,
|
||||||
|
count: 1,
|
||||||
|
rootCount: 1,
|
||||||
|
latestEvent: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const result = await repository.upsertWorkflowStatistics(
|
||||||
|
StatisticsNames.productionSuccess,
|
||||||
|
workflow.id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(result).toBe('update');
|
||||||
|
const updatedData = await repository.find();
|
||||||
|
expect(updatedData).toHaveLength(1);
|
||||||
|
expect(updatedData[0].workflowId).toBe(workflow.id);
|
||||||
|
expect(updatedData[0].name).toBe(StatisticsNames.productionSuccess);
|
||||||
|
expect(updatedData[0].count).toBe(2);
|
||||||
|
expect(updatedData[0].rootCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export class LicenseMetricsRepository extends Repository<LicenseMetrics> {
|
|||||||
return this.manager.connection.driver.escape(`${tablePrefix}${name}`);
|
return this.manager.connection.driver.escape(`${tablePrefix}${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toColumnName(name: string) {
|
||||||
|
return this.manager.connection.driver.escape(name);
|
||||||
|
}
|
||||||
|
|
||||||
async getLicenseRenewalMetrics() {
|
async getLicenseRenewalMetrics() {
|
||||||
type Row = {
|
type Row = {
|
||||||
enabled_user_count: string | number;
|
enabled_user_count: string | number;
|
||||||
@@ -27,6 +31,7 @@ export class LicenseMetricsRepository extends Repository<LicenseMetrics> {
|
|||||||
total_workflow_count: string | number;
|
total_workflow_count: string | number;
|
||||||
total_credentials_count: string | number;
|
total_credentials_count: string | number;
|
||||||
production_executions_count: string | number;
|
production_executions_count: string | number;
|
||||||
|
production_root_executions_count: string | number;
|
||||||
manual_executions_count: string | number;
|
manual_executions_count: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +48,7 @@ export class LicenseMetricsRepository extends Repository<LicenseMetrics> {
|
|||||||
total_workflow_count: totalWorkflows,
|
total_workflow_count: totalWorkflows,
|
||||||
total_credentials_count: totalCredentials,
|
total_credentials_count: totalCredentials,
|
||||||
production_executions_count: productionExecutions,
|
production_executions_count: productionExecutions,
|
||||||
|
production_root_executions_count: productionRootExecutions,
|
||||||
manual_executions_count: manualExecutions,
|
manual_executions_count: manualExecutions,
|
||||||
},
|
},
|
||||||
] = (await this.query(`
|
] = (await this.query(`
|
||||||
@@ -53,6 +59,7 @@ export class LicenseMetricsRepository extends Repository<LicenseMetrics> {
|
|||||||
(SELECT COUNT(*) FROM ${workflowTable}) AS total_workflow_count,
|
(SELECT COUNT(*) FROM ${workflowTable}) AS total_workflow_count,
|
||||||
(SELECT COUNT(*) FROM ${credentialTable}) AS total_credentials_count,
|
(SELECT COUNT(*) FROM ${credentialTable}) AS total_credentials_count,
|
||||||
(SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('production_success', 'production_error')) AS production_executions_count,
|
(SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('production_success', 'production_error')) AS production_executions_count,
|
||||||
|
(SELECT SUM(${this.toColumnName('rootCount')}) FROM ${workflowStatsTable} WHERE name IN ('production_success', 'production_error')) AS production_root_executions_count,
|
||||||
(SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('manual_success', 'manual_error')) AS manual_executions_count;
|
(SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('manual_success', 'manual_error')) AS manual_executions_count;
|
||||||
`)) as Row[];
|
`)) as Row[];
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@ export class LicenseMetricsRepository extends Repository<LicenseMetrics> {
|
|||||||
totalWorkflows: toNumber(totalWorkflows),
|
totalWorkflows: toNumber(totalWorkflows),
|
||||||
totalCredentials: toNumber(totalCredentials),
|
totalCredentials: toNumber(totalCredentials),
|
||||||
productionExecutions: toNumber(productionExecutions),
|
productionExecutions: toNumber(productionExecutions),
|
||||||
|
productionRootExecutions: toNumber(productionRootExecutions),
|
||||||
manualExecutions: toNumber(manualExecutions),
|
manualExecutions: toNumber(manualExecutions),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
|
|||||||
workflowId,
|
workflowId,
|
||||||
name: eventName,
|
name: eventName,
|
||||||
count: 1,
|
count: 1,
|
||||||
|
rootCount: 1,
|
||||||
latestEvent: new Date(),
|
latestEvent: new Date(),
|
||||||
});
|
});
|
||||||
return 'insert';
|
return 'insert';
|
||||||
@@ -51,16 +52,20 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
|
|||||||
async upsertWorkflowStatistics(
|
async upsertWorkflowStatistics(
|
||||||
eventName: StatisticsNames,
|
eventName: StatisticsNames,
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
|
isRootExecution: boolean,
|
||||||
): Promise<StatisticsUpsertResult> {
|
): Promise<StatisticsUpsertResult> {
|
||||||
const dbType = this.globalConfig.database.type;
|
const dbType = this.globalConfig.database.type;
|
||||||
const { tableName } = this.metadata;
|
const { tableName } = this.metadata;
|
||||||
try {
|
try {
|
||||||
if (dbType === 'sqlite') {
|
if (dbType === 'sqlite') {
|
||||||
await this.query(
|
await this.query(
|
||||||
`INSERT INTO "${tableName}" ("count", "name", "workflowId", "latestEvent")
|
`INSERT INTO "${tableName}" ("count", "rootCount", "name", "workflowId", "latestEvent")
|
||||||
VALUES (1, "${eventName}", "${workflowId}", CURRENT_TIMESTAMP)
|
VALUES (1, ${isRootExecution ? '1' : '0'}, "${eventName}", "${workflowId}", CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT (workflowId, name)
|
ON CONFLICT (workflowId, name)
|
||||||
DO UPDATE SET count = count + 1, latestEvent = CURRENT_TIMESTAMP`,
|
DO UPDATE SET
|
||||||
|
count = count + 1,
|
||||||
|
rootCount = ${isRootExecution ? 'rootCount + 1' : 'rootCount'},
|
||||||
|
latestEvent = CURRENT_TIMESTAMP`,
|
||||||
);
|
);
|
||||||
// SQLite does not offer a reliable way to know whether or not an insert or update happened.
|
// SQLite does not offer a reliable way to know whether or not an insert or update happened.
|
||||||
// We'll use a naive approach in this case. Query again after and it might cause us to miss the
|
// We'll use a naive approach in this case. Query again after and it might cause us to miss the
|
||||||
@@ -73,13 +78,19 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return counter?.count === 1 ? 'insert' : 'failed';
|
return (counter?.count ?? 0) > 1 ? 'update' : counter?.count === 1 ? 'insert' : 'failed';
|
||||||
} else if (dbType === 'postgresdb') {
|
} else if (dbType === 'postgresdb') {
|
||||||
|
const upsertRootCount = isRootExecution
|
||||||
|
? `"${tableName}"."rootCount" + 1`
|
||||||
|
: `"${tableName}"."rootCount"`;
|
||||||
const queryResult = (await this.query(
|
const queryResult = (await this.query(
|
||||||
`INSERT INTO "${tableName}" ("count", "name", "workflowId", "latestEvent")
|
`INSERT INTO "${tableName}" ("count", "rootCount", "name", "workflowId", "latestEvent")
|
||||||
VALUES (1, '${eventName}', '${workflowId}', CURRENT_TIMESTAMP)
|
VALUES (1, ${isRootExecution ? '1' : '0'}, '${eventName}', '${workflowId}', CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT ("name", "workflowId")
|
ON CONFLICT ("name", "workflowId")
|
||||||
DO UPDATE SET "count" = "${tableName}"."count" + 1, "latestEvent" = CURRENT_TIMESTAMP
|
DO UPDATE SET
|
||||||
|
"count" = "${tableName}"."count" + 1,
|
||||||
|
"rootCount" = ${upsertRootCount},
|
||||||
|
"latestEvent" = CURRENT_TIMESTAMP
|
||||||
RETURNING *;`,
|
RETURNING *;`,
|
||||||
)) as Array<{
|
)) as Array<{
|
||||||
count: number;
|
count: number;
|
||||||
@@ -87,10 +98,13 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
|
|||||||
return queryResult[0].count === 1 ? 'insert' : 'update';
|
return queryResult[0].count === 1 ? 'insert' : 'update';
|
||||||
} else {
|
} else {
|
||||||
const queryResult = (await this.query(
|
const queryResult = (await this.query(
|
||||||
`INSERT INTO \`${tableName}\` (count, name, workflowId, latestEvent)
|
`INSERT INTO \`${tableName}\` (count, rootCount, name, workflowId, latestEvent)
|
||||||
VALUES (1, "${eventName}", "${workflowId}", NOW())
|
VALUES (1, ${isRootExecution ? '1' : '0'}, "${eventName}", "${workflowId}", NOW())
|
||||||
ON DUPLICATE KEY
|
ON DUPLICATE KEY
|
||||||
UPDATE count = count + 1, latestEvent = NOW();`,
|
UPDATE
|
||||||
|
count = count + 1,
|
||||||
|
rootCount = ${isRootExecution ? 'rootCount + 1' : 'rootCount'},
|
||||||
|
latestEvent = NOW();`,
|
||||||
)) as {
|
)) as {
|
||||||
affectedRows: number;
|
affectedRows: number;
|
||||||
};
|
};
|
||||||
@@ -98,6 +112,7 @@ export class WorkflowStatisticsRepository extends Repository<WorkflowStatistics>
|
|||||||
return queryResult.affectedRows === 1 ? 'insert' : 'update';
|
return queryResult.affectedRows === 1 ? 'insert' : 'update';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log('error', error);
|
||||||
if (error instanceof QueryFailedError) return 'failed';
|
if (error instanceof QueryFailedError) return 'failed';
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe('LicenseMetricsService', () => {
|
|||||||
totalUsers: 400,
|
totalUsers: 400,
|
||||||
totalCredentials: 500,
|
totalCredentials: 500,
|
||||||
productionExecutions: 600,
|
productionExecutions: 600,
|
||||||
|
productionRootExecutions: 550,
|
||||||
manualExecutions: 700,
|
manualExecutions: 700,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,6 +61,10 @@ describe('LicenseMetricsService', () => {
|
|||||||
{ name: 'totalUsers', value: mockRenewalMetrics.totalUsers },
|
{ name: 'totalUsers', value: mockRenewalMetrics.totalUsers },
|
||||||
{ name: 'totalCredentials', value: mockRenewalMetrics.totalCredentials },
|
{ name: 'totalCredentials', value: mockRenewalMetrics.totalCredentials },
|
||||||
{ name: 'productionExecutions', value: mockRenewalMetrics.productionExecutions },
|
{ name: 'productionExecutions', value: mockRenewalMetrics.productionExecutions },
|
||||||
|
{
|
||||||
|
name: 'productionRootExecutions',
|
||||||
|
value: mockRenewalMetrics.productionRootExecutions,
|
||||||
|
},
|
||||||
{ name: 'manualExecutions', value: mockRenewalMetrics.manualExecutions },
|
{ name: 'manualExecutions', value: mockRenewalMetrics.manualExecutions },
|
||||||
{ name: 'activeWorkflowTriggers', value: mockActiveTriggerCount },
|
{ name: 'activeWorkflowTriggers', value: mockActiveTriggerCount },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class LicenseMetricsService {
|
|||||||
totalUsers,
|
totalUsers,
|
||||||
totalCredentials,
|
totalCredentials,
|
||||||
productionExecutions,
|
productionExecutions,
|
||||||
|
productionRootExecutions,
|
||||||
manualExecutions,
|
manualExecutions,
|
||||||
} = await this.licenseMetricsRepository.getLicenseRenewalMetrics();
|
} = await this.licenseMetricsRepository.getLicenseRenewalMetrics();
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export class LicenseMetricsService {
|
|||||||
{ name: 'totalUsers', value: totalUsers },
|
{ name: 'totalUsers', value: totalUsers },
|
||||||
{ name: 'totalCredentials', value: totalCredentials },
|
{ name: 'totalCredentials', value: totalCredentials },
|
||||||
{ name: 'productionExecutions', value: productionExecutions },
|
{ name: 'productionExecutions', value: productionExecutions },
|
||||||
|
{ name: 'productionRootExecutions', value: productionRootExecutions },
|
||||||
{ name: 'manualExecutions', value: manualExecutions },
|
{ name: 'manualExecutions', value: manualExecutions },
|
||||||
{ name: 'activeWorkflowTriggers', value: activeTriggerCount },
|
{ name: 'activeWorkflowTriggers', value: activeTriggerCount },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
} from '@n8n/typeorm';
|
} from '@n8n/typeorm';
|
||||||
import { mocked } from 'jest-mock';
|
import { mocked } from 'jest-mock';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { INode, IRun, WorkflowExecuteMode } from 'n8n-workflow';
|
import type { IWorkflowBase } from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
type ExecutionStatus,
|
||||||
|
type INode,
|
||||||
|
type IRun,
|
||||||
|
type WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { Project } from '@/databases/entities/project';
|
import type { Project } from '@/databases/entities/project';
|
||||||
@@ -24,6 +30,7 @@ import { mockInstance } from '@test/mocking';
|
|||||||
describe('WorkflowStatisticsService', () => {
|
describe('WorkflowStatisticsService', () => {
|
||||||
const fakeUser = mock<User>({ id: 'abcde-fghij' });
|
const fakeUser = mock<User>({ id: 'abcde-fghij' });
|
||||||
const fakeProject = mock<Project>({ id: '12345-67890', type: 'personal' });
|
const fakeProject = mock<Project>({ id: '12345-67890', type: 'personal' });
|
||||||
|
const fakeWorkflow = mock<IWorkflowBase>({ id: '1' });
|
||||||
const ownershipService = mockInstance(OwnershipService);
|
const ownershipService = mockInstance(OwnershipService);
|
||||||
const userService = mockInstance(UserService);
|
const userService = mockInstance(UserService);
|
||||||
const globalConfig = Container.get(GlobalConfig);
|
const globalConfig = Container.get(GlobalConfig);
|
||||||
@@ -70,6 +77,85 @@ describe('WorkflowStatisticsService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('workflowExecutionCompleted', () => {
|
describe('workflowExecutionCompleted', () => {
|
||||||
|
const rootCountRegex = /"?rootCount"?\s*=\s*(?:"?\w+"?\.)?"?rootCount"?\s*\+\s*1/;
|
||||||
|
|
||||||
|
test.each<WorkflowExecuteMode>(['cli', 'error', 'retry', 'trigger', 'webhook', 'evaluation'])(
|
||||||
|
'should upsert with root executions for execution mode %s',
|
||||||
|
async (mode) => {
|
||||||
|
// Call the function with a production success result, ensure metrics hook gets called
|
||||||
|
const runData: IRun = {
|
||||||
|
finished: true,
|
||||||
|
status: 'success',
|
||||||
|
data: { resultData: { runData: {} } },
|
||||||
|
mode,
|
||||||
|
startedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflowStatisticsService.workflowExecutionCompleted(fakeWorkflow, runData);
|
||||||
|
expect(entityManager.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(rootCountRegex),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each<WorkflowExecuteMode>(['manual', 'integrated', 'internal'])(
|
||||||
|
'should upsert without root executions for execution mode %s',
|
||||||
|
async (mode) => {
|
||||||
|
const runData: IRun = {
|
||||||
|
finished: true,
|
||||||
|
status: 'success',
|
||||||
|
data: { resultData: { runData: {} } },
|
||||||
|
mode,
|
||||||
|
startedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflowStatisticsService.workflowExecutionCompleted(fakeWorkflow, runData);
|
||||||
|
expect(entityManager.query).toHaveBeenCalledWith(
|
||||||
|
expect.not.stringMatching(rootCountRegex),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each<ExecutionStatus>(['success', 'crashed', 'error'])(
|
||||||
|
'should upsert with root executions for execution status %s',
|
||||||
|
async (status) => {
|
||||||
|
const runData: IRun = {
|
||||||
|
finished: true,
|
||||||
|
status,
|
||||||
|
data: { resultData: { runData: {} } },
|
||||||
|
mode: 'trigger',
|
||||||
|
startedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflowStatisticsService.workflowExecutionCompleted(fakeWorkflow, runData);
|
||||||
|
expect(entityManager.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(rootCountRegex),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each<ExecutionStatus>(['canceled', 'new', 'running', 'unknown', 'waiting'])(
|
||||||
|
'should upsert without root executions for execution status %s',
|
||||||
|
async (status) => {
|
||||||
|
const runData: IRun = {
|
||||||
|
finished: true,
|
||||||
|
status,
|
||||||
|
data: { resultData: { runData: {} } },
|
||||||
|
mode: 'trigger',
|
||||||
|
startedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await workflowStatisticsService.workflowExecutionCompleted(fakeWorkflow, runData);
|
||||||
|
expect(entityManager.query).toHaveBeenCalledWith(
|
||||||
|
expect.not.stringMatching(rootCountRegex),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('should create metrics for production successes', async () => {
|
test('should create metrics for production successes', async () => {
|
||||||
// Call the function with a production success result, ensure metrics hook gets called
|
// Call the function with a production success result, ensure metrics hook gets called
|
||||||
const workflow = {
|
const workflow = {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { Logger } from 'n8n-core';
|
import { Logger } from 'n8n-core';
|
||||||
import type { INode, IRun, IWorkflowBase } from 'n8n-workflow';
|
import type {
|
||||||
|
ExecutionStatus,
|
||||||
|
INode,
|
||||||
|
IRun,
|
||||||
|
IWorkflowBase,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { StatisticsNames } from '@/databases/entities/workflow-statistics';
|
import { StatisticsNames } from '@/databases/entities/workflow-statistics';
|
||||||
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository';
|
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflow-statistics.repository';
|
||||||
@@ -10,6 +16,35 @@ import { TypedEmitter } from '@/typed-emitter';
|
|||||||
|
|
||||||
import { OwnershipService } from './ownership.service';
|
import { OwnershipService } from './ownership.service';
|
||||||
|
|
||||||
|
const isStatusRootExecution = {
|
||||||
|
success: true,
|
||||||
|
crashed: true,
|
||||||
|
error: true,
|
||||||
|
|
||||||
|
canceled: false,
|
||||||
|
new: false,
|
||||||
|
running: false,
|
||||||
|
unknown: false,
|
||||||
|
waiting: false,
|
||||||
|
} satisfies Record<ExecutionStatus, boolean>;
|
||||||
|
|
||||||
|
const isModeRootExecution = {
|
||||||
|
cli: true,
|
||||||
|
error: true,
|
||||||
|
retry: true,
|
||||||
|
trigger: true,
|
||||||
|
webhook: true,
|
||||||
|
evaluation: true,
|
||||||
|
|
||||||
|
// sub workflows
|
||||||
|
integrated: false,
|
||||||
|
|
||||||
|
// error workflows
|
||||||
|
internal: false,
|
||||||
|
|
||||||
|
manual: false,
|
||||||
|
} satisfies Record<WorkflowExecuteMode, boolean>;
|
||||||
|
|
||||||
type WorkflowStatisticsEvents = {
|
type WorkflowStatisticsEvents = {
|
||||||
nodeFetchedData: { workflowId: string; node: INode };
|
nodeFetchedData: { workflowId: string; node: INode };
|
||||||
workflowExecutionCompleted: { workflowData: IWorkflowBase; fullRunData: IRun };
|
workflowExecutionCompleted: { workflowData: IWorkflowBase; fullRunData: IRun };
|
||||||
@@ -52,11 +87,13 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
|
|||||||
|
|
||||||
async workflowExecutionCompleted(workflowData: IWorkflowBase, runData: IRun): Promise<void> {
|
async workflowExecutionCompleted(workflowData: IWorkflowBase, runData: IRun): Promise<void> {
|
||||||
// Determine the name of the statistic
|
// Determine the name of the statistic
|
||||||
const finished = runData.finished ? runData.finished : false;
|
const isSuccess = runData.status === 'success';
|
||||||
const manual = runData.mode === 'manual';
|
const manual = runData.mode === 'manual';
|
||||||
let name: StatisticsNames;
|
let name: StatisticsNames;
|
||||||
|
const isRootExecution =
|
||||||
|
isModeRootExecution[runData.mode] && isStatusRootExecution[runData.status];
|
||||||
|
|
||||||
if (finished) {
|
if (isSuccess) {
|
||||||
if (manual) name = StatisticsNames.manualSuccess;
|
if (manual) name = StatisticsNames.manualSuccess;
|
||||||
else name = StatisticsNames.productionSuccess;
|
else name = StatisticsNames.productionSuccess;
|
||||||
} else {
|
} else {
|
||||||
@@ -69,7 +106,11 @@ export class WorkflowStatisticsService extends TypedEmitter<WorkflowStatisticsEv
|
|||||||
if (!workflowId) return;
|
if (!workflowId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const upsertResult = await this.repository.upsertWorkflowStatistics(name, workflowId);
|
const upsertResult = await this.repository.upsertWorkflowStatistics(
|
||||||
|
name,
|
||||||
|
workflowId,
|
||||||
|
isRootExecution,
|
||||||
|
);
|
||||||
|
|
||||||
if (name === StatisticsNames.productionSuccess && upsertResult === 'insert') {
|
if (name === StatisticsNames.productionSuccess && upsertResult === 'insert') {
|
||||||
const project = await this.ownershipService.getWorkflowProjectCached(workflowId);
|
const project = await this.ownershipService.getWorkflowProjectCached(workflowId);
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ describe('LicenseMetricsRepository', () => {
|
|||||||
StatisticsNames.manualError,
|
StatisticsNames.manualError,
|
||||||
secondWorkflow.id,
|
secondWorkflow.id,
|
||||||
),
|
),
|
||||||
|
workflowStatisticsRepository.upsertWorkflowStatistics(
|
||||||
|
StatisticsNames.productionSuccess,
|
||||||
|
secondWorkflow.id,
|
||||||
|
true,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const metrics = await licenseMetricsRepository.getLicenseRenewalMetrics();
|
const metrics = await licenseMetricsRepository.getLicenseRenewalMetrics();
|
||||||
@@ -70,7 +75,8 @@ describe('LicenseMetricsRepository', () => {
|
|||||||
totalCredentials: 2,
|
totalCredentials: 2,
|
||||||
totalWorkflows: 5,
|
totalWorkflows: 5,
|
||||||
activeWorkflows: 3,
|
activeWorkflows: 3,
|
||||||
productionExecutions: 2,
|
productionExecutions: 3,
|
||||||
|
productionRootExecutions: 3,
|
||||||
manualExecutions: 2,
|
manualExecutions: 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -87,6 +93,7 @@ describe('LicenseMetricsRepository', () => {
|
|||||||
totalWorkflows: 3,
|
totalWorkflows: 3,
|
||||||
activeWorkflows: 3,
|
activeWorkflows: 3,
|
||||||
productionExecutions: 0, // not NaN
|
productionExecutions: 0, // not NaN
|
||||||
|
productionRootExecutions: 0, // not NaN
|
||||||
manualExecutions: 0, // not NaN
|
manualExecutions: 0, // not NaN
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user