Skip to content

Commit

Permalink
feat: Extend collection of usage metrics during license renewal (no-c…
Browse files Browse the repository at this point in the history
…hangelog) (n8n-io#8369)

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
  • Loading branch information
csuermann and ivov authored Jan 22, 2024
1 parent ebf2b0d commit d597c2a
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 16 deletions.
15 changes: 3 additions & 12 deletions packages/cli/src/License.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import {
UNLIMITED_LICENSE_QUOTA,
} from './constants';
import { SettingsRepository } from '@db/repositories/settings.repository';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
import type { BooleanLicenseFeature, N8nInstanceType, NumericLicenseFeature } from './Interfaces';
import type { RedisServicePubSubPublisher } from './services/redis/RedisServicePubSubPublisher';
import { RedisService } from './services/redis.service';
import { OrchestrationService } from '@/services/orchestration.service';
import { OnShutdown } from '@/decorators/OnShutdown';
import { UsageMetricsService } from './services/usageMetrics.service';

type FeatureReturnType = Partial<
{
Expand All @@ -38,7 +38,7 @@ export class License {
private readonly instanceSettings: InstanceSettings,
private readonly orchestrationService: OrchestrationService,
private readonly settingsRepository: SettingsRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly usageMetricsService: UsageMetricsService,
) {}

async init(instanceType: N8nInstanceType = 'main') {
Expand All @@ -63,7 +63,7 @@ export class License {
? async (features: TFeatures) => await this.onFeatureChange(features)
: async () => {};
const collectUsageMetrics = isMainInstance
? async () => await this.collectUsageMetrics()
? async () => await this.usageMetricsService.collectUsageMetrics()
: async () => [];

try {
Expand Down Expand Up @@ -91,15 +91,6 @@ export class License {
}
}

async collectUsageMetrics() {
return [
{
name: 'activeWorkflows',
value: await this.workflowRepository.count({ where: { active: true } }),
},
];
}

async loadCertStr(): Promise<TLicenseBlock> {
// if we have an ephemeral license, we don't want to load it from the database
const ephemeralLicense = config.get('license.cert');
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
this.logger.debug(
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/controllers/invitation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class InvitationController {

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) {
this.logger.debug(
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/controllers/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export class UsersController {

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)) {
throw new NotFoundError(
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/databases/repositories/usageMetrics.repository.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
}
2 changes: 1 addition & 1 deletion packages/cli/src/databases/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class UserRepository extends Repository<User> {
super(User, dataSource.manager);
}

async findManybyIds(userIds: string[]) {
async findManyByIds(userIds: string[]) {
return await this.find({
where: { id: In(userIds) },
relations: ['globalRole'],
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/services/usageMetrics.service.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
}
}
25 changes: 25 additions & 0 deletions packages/cli/test/integration/shared/db/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ async function encryptCredentialData(credential: CredentialsEntity) {
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.
*/
Expand Down
78 changes: 78 additions & 0 deletions packages/cli/test/integration/usageMetrics.repository.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
});

0 comments on commit d597c2a

Please sign in to comment.