mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
feat: Extend collection of usage metrics during license renewal (no-changelog) (#8369)
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
parent
ebf2b0d55c
commit
d597c2ab29
|
@ -12,12 +12,12 @@ import {
|
||||||
UNLIMITED_LICENSE_QUOTA,
|
UNLIMITED_LICENSE_QUOTA,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { SettingsRepository } from '@db/repositories/settings.repository';
|
import { SettingsRepository } from '@db/repositories/settings.repository';
|
||||||
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
|
||||||
import type { BooleanLicenseFeature, N8nInstanceType, NumericLicenseFeature } from './Interfaces';
|
import type { BooleanLicenseFeature, N8nInstanceType, NumericLicenseFeature } from './Interfaces';
|
||||||
import type { RedisServicePubSubPublisher } from './services/redis/RedisServicePubSubPublisher';
|
import type { RedisServicePubSubPublisher } from './services/redis/RedisServicePubSubPublisher';
|
||||||
import { RedisService } from './services/redis.service';
|
import { RedisService } from './services/redis.service';
|
||||||
import { OrchestrationService } from '@/services/orchestration.service';
|
import { OrchestrationService } from '@/services/orchestration.service';
|
||||||
import { OnShutdown } from '@/decorators/OnShutdown';
|
import { OnShutdown } from '@/decorators/OnShutdown';
|
||||||
|
import { UsageMetricsService } from './services/usageMetrics.service';
|
||||||
|
|
||||||
type FeatureReturnType = Partial<
|
type FeatureReturnType = Partial<
|
||||||
{
|
{
|
||||||
|
@ -38,7 +38,7 @@ export class License {
|
||||||
private readonly instanceSettings: InstanceSettings,
|
private readonly instanceSettings: InstanceSettings,
|
||||||
private readonly orchestrationService: OrchestrationService,
|
private readonly orchestrationService: OrchestrationService,
|
||||||
private readonly settingsRepository: SettingsRepository,
|
private readonly settingsRepository: SettingsRepository,
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly usageMetricsService: UsageMetricsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init(instanceType: N8nInstanceType = 'main') {
|
async init(instanceType: N8nInstanceType = 'main') {
|
||||||
|
@ -63,7 +63,7 @@ export class License {
|
||||||
? async (features: TFeatures) => await this.onFeatureChange(features)
|
? async (features: TFeatures) => await this.onFeatureChange(features)
|
||||||
: async () => {};
|
: async () => {};
|
||||||
const collectUsageMetrics = isMainInstance
|
const collectUsageMetrics = isMainInstance
|
||||||
? async () => await this.collectUsageMetrics()
|
? async () => await this.usageMetricsService.collectUsageMetrics()
|
||||||
: async () => [];
|
: async () => [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -91,15 +91,6 @@ export class License {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectUsageMetrics() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: 'activeWorkflows',
|
|
||||||
value: await this.workflowRepository.count({ where: { active: true } }),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadCertStr(): Promise<TLicenseBlock> {
|
async loadCertStr(): Promise<TLicenseBlock> {
|
||||||
// if we have an ephemeral license, we don't want to load it from the database
|
// if we have an ephemeral license, we don't want to load it from the database
|
||||||
const ephemeralLicense = config.get('license.cert');
|
const ephemeralLicense = config.get('license.cert');
|
||||||
|
|
|
@ -187,7 +187,7 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await this.userRepository.findManybyIds([inviterId, inviteeId]);
|
const users = await this.userRepository.findManyByIds([inviterId, inviteeId]);
|
||||||
|
|
||||||
if (users.length !== 2) {
|
if (users.length !== 2) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
|
|
@ -136,7 +136,7 @@ export class InvitationController {
|
||||||
|
|
||||||
const validPassword = this.passwordUtility.validate(password);
|
const validPassword = this.passwordUtility.validate(password);
|
||||||
|
|
||||||
const users = await this.userRepository.findManybyIds([inviterId, inviteeId]);
|
const users = await this.userRepository.findManyByIds([inviterId, inviteeId]);
|
||||||
|
|
||||||
if (users.length !== 2) {
|
if (users.length !== 2) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
|
|
@ -172,7 +172,7 @@ export class UsersController {
|
||||||
|
|
||||||
const userIds = transferId ? [transferId, idToDelete] : [idToDelete];
|
const userIds = transferId ? [transferId, idToDelete] : [idToDelete];
|
||||||
|
|
||||||
const users = await this.userRepository.findManybyIds(userIds);
|
const users = await this.userRepository.findManyByIds(userIds);
|
||||||
|
|
||||||
if (!users.length || (transferId && users.length !== 2)) {
|
if (!users.length || (transferId && users.length !== 2)) {
|
||||||
throw new NotFoundError(
|
throw new NotFoundError(
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
import config from '@/config';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { DataSource, Repository, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class UsageMetrics {}
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class UsageMetricsRepository extends Repository<UsageMetrics> {
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
super(UsageMetrics, dataSource.manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
toTableName(name: string) {
|
||||||
|
const tablePrefix = config.getEnv('database.tablePrefix');
|
||||||
|
|
||||||
|
let tableName =
|
||||||
|
config.getEnv('database.type') === 'mysqldb'
|
||||||
|
? `\`${tablePrefix}${name}\``
|
||||||
|
: `"${tablePrefix}${name}"`;
|
||||||
|
|
||||||
|
const pgSchema = config.getEnv('database.postgresdb.schema');
|
||||||
|
|
||||||
|
if (pgSchema !== 'public') tableName = [pgSchema, tablePrefix + name].join('.');
|
||||||
|
|
||||||
|
return tableName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLicenseRenewalMetrics() {
|
||||||
|
type Row = {
|
||||||
|
enabled_user_count: string | number;
|
||||||
|
active_workflow_count: string | number;
|
||||||
|
total_workflow_count: string | number;
|
||||||
|
total_credentials_count: string | number;
|
||||||
|
production_executions_count: string | number;
|
||||||
|
manual_executions_count: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const userTable = this.toTableName('user');
|
||||||
|
const workflowTable = this.toTableName('workflow_entity');
|
||||||
|
const credentialTable = this.toTableName('credentials_entity');
|
||||||
|
const workflowStatsTable = this.toTableName('workflow_statistics');
|
||||||
|
|
||||||
|
const [
|
||||||
|
{
|
||||||
|
enabled_user_count: enabledUsers,
|
||||||
|
active_workflow_count: activeWorkflows,
|
||||||
|
total_workflow_count: totalWorkflows,
|
||||||
|
total_credentials_count: totalCredentials,
|
||||||
|
production_executions_count: productionExecutions,
|
||||||
|
manual_executions_count: manualExecutions,
|
||||||
|
},
|
||||||
|
] = (await this.query(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM ${userTable} WHERE disabled = false) AS enabled_user_count,
|
||||||
|
(SELECT COUNT(*) FROM ${workflowTable} WHERE active = true) AS active_workflow_count,
|
||||||
|
(SELECT COUNT(*) FROM ${workflowTable}) AS total_workflow_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 ('manual_success', 'manual_error')) AS manual_executions_count;
|
||||||
|
`)) as Row[];
|
||||||
|
|
||||||
|
const toNumber = (value: string | number) =>
|
||||||
|
typeof value === 'number' ? value : parseInt(value, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabledUsers: toNumber(enabledUsers),
|
||||||
|
activeWorkflows: toNumber(activeWorkflows),
|
||||||
|
totalWorkflows: toNumber(totalWorkflows),
|
||||||
|
totalCredentials: toNumber(totalCredentials),
|
||||||
|
productionExecutions: toNumber(productionExecutions),
|
||||||
|
manualExecutions: toNumber(manualExecutions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ export class UserRepository extends Repository<User> {
|
||||||
super(User, dataSource.manager);
|
super(User, dataSource.manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findManybyIds(userIds: string[]) {
|
async findManyByIds(userIds: string[]) {
|
||||||
return await this.find({
|
return await this.find({
|
||||||
where: { id: In(userIds) },
|
where: { id: In(userIds) },
|
||||||
relations: ['globalRole'],
|
relations: ['globalRole'],
|
||||||
|
|
27
packages/cli/src/services/usageMetrics.service.ts
Normal file
27
packages/cli/src/services/usageMetrics.service.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { UsageMetricsRepository } from '@/databases/repositories/usageMetrics.repository';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class UsageMetricsService {
|
||||||
|
constructor(private readonly usageMetricsRepository: UsageMetricsRepository) {}
|
||||||
|
|
||||||
|
async collectUsageMetrics() {
|
||||||
|
const {
|
||||||
|
activeWorkflows,
|
||||||
|
totalWorkflows,
|
||||||
|
enabledUsers,
|
||||||
|
totalCredentials,
|
||||||
|
productionExecutions,
|
||||||
|
manualExecutions,
|
||||||
|
} = await this.usageMetricsRepository.getLicenseRenewalMetrics();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: 'activeWorkflows', value: activeWorkflows },
|
||||||
|
{ name: 'totalWorkflows', value: totalWorkflows },
|
||||||
|
{ name: 'enabledUsers', value: enabledUsers },
|
||||||
|
{ name: 'totalCredentials', value: totalCredentials },
|
||||||
|
{ name: 'productionExecutions', value: productionExecutions },
|
||||||
|
{ name: 'manualExecutions', value: manualExecutions },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,31 @@ async function encryptCredentialData(credential: CredentialsEntity) {
|
||||||
return coreCredential.getDataToSave() as ICredentialsDb;
|
return coreCredential.getDataToSave() as ICredentialsDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyAttributes = {
|
||||||
|
name: 'test',
|
||||||
|
type: 'test',
|
||||||
|
data: '',
|
||||||
|
nodesAccess: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createManyCredentials(
|
||||||
|
amount: number,
|
||||||
|
attributes: Partial<CredentialsEntity> = emptyAttributes,
|
||||||
|
) {
|
||||||
|
return await Promise.all(
|
||||||
|
Array(amount)
|
||||||
|
.fill(0)
|
||||||
|
.map(async () => await createCredentials(attributes)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCredentials(attributes: Partial<CredentialsEntity> = emptyAttributes) {
|
||||||
|
const credentialsRepository = Container.get(CredentialsRepository);
|
||||||
|
const entity = credentialsRepository.create(attributes);
|
||||||
|
|
||||||
|
return await credentialsRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a credential to the test DB, sharing it with a user.
|
* Save a credential to the test DB, sharing it with a user.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { UsageMetricsRepository } from '@/databases/repositories/usageMetrics.repository';
|
||||||
|
import { createAdmin, createMember, createOwner, createUser } from './shared/db/users';
|
||||||
|
import * as testDb from './shared/testDb';
|
||||||
|
import Container from 'typedi';
|
||||||
|
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||||
|
import { createManyWorkflows } from './shared/db/workflows';
|
||||||
|
import { createManyCredentials } from './shared/db/credentials';
|
||||||
|
import { WorkflowStatisticsRepository } from '@/databases/repositories/workflowStatistics.repository';
|
||||||
|
import { StatisticsNames } from '@/databases/entities/WorkflowStatistics';
|
||||||
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
|
||||||
|
describe('UsageMetricsRepository', () => {
|
||||||
|
let usageMetricsRepository: UsageMetricsRepository;
|
||||||
|
let credentialsRepository: CredentialsRepository;
|
||||||
|
let workflowStatisticsRepository: WorkflowStatisticsRepository;
|
||||||
|
let workflowRepository: WorkflowRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await testDb.init();
|
||||||
|
|
||||||
|
usageMetricsRepository = Container.get(UsageMetricsRepository);
|
||||||
|
credentialsRepository = Container.get(CredentialsRepository);
|
||||||
|
workflowStatisticsRepository = Container.get(WorkflowStatisticsRepository);
|
||||||
|
workflowRepository = Container.get(WorkflowRepository);
|
||||||
|
|
||||||
|
await testDb.truncate(['User', 'Credentials', 'Workflow', 'Execution', 'WorkflowStatistics']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testDb.terminate();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLicenseRenewalMetrics()', () => {
|
||||||
|
test('should return license renewal metrics', async () => {
|
||||||
|
const [firstWorkflow, secondWorkflow] = await createManyWorkflows(2, { active: false });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
createOwner(),
|
||||||
|
createAdmin(),
|
||||||
|
createMember(),
|
||||||
|
createMember(),
|
||||||
|
createUser({ disabled: true }),
|
||||||
|
createManyCredentials(2),
|
||||||
|
createManyWorkflows(3, { active: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
workflowStatisticsRepository.insertWorkflowStatistics(
|
||||||
|
StatisticsNames.productionSuccess,
|
||||||
|
firstWorkflow.id,
|
||||||
|
),
|
||||||
|
workflowStatisticsRepository.insertWorkflowStatistics(
|
||||||
|
StatisticsNames.productionError,
|
||||||
|
firstWorkflow.id,
|
||||||
|
),
|
||||||
|
workflowStatisticsRepository.insertWorkflowStatistics(
|
||||||
|
StatisticsNames.manualSuccess,
|
||||||
|
secondWorkflow.id,
|
||||||
|
),
|
||||||
|
workflowStatisticsRepository.insertWorkflowStatistics(
|
||||||
|
StatisticsNames.manualError,
|
||||||
|
secondWorkflow.id,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const metrics = await usageMetricsRepository.getLicenseRenewalMetrics();
|
||||||
|
|
||||||
|
expect(metrics).toStrictEqual({
|
||||||
|
enabledUsers: 4,
|
||||||
|
totalCredentials: 2,
|
||||||
|
totalWorkflows: 5,
|
||||||
|
activeWorkflows: 3,
|
||||||
|
productionExecutions: 2,
|
||||||
|
manualExecutions: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue