Skip to content

Commit

Permalink
feat: feature changes counted in new table (#4958)
Browse files Browse the repository at this point in the history
As part of more telemetry on the usage of Unleash. 

This PR adds a new `stat_` prefixed table as well as a trigger on the
events table trigger on each insert to increment a counter per
environment per day.

The trigger will trigger on every insert into the events base, but will
filter and only increment the counter for events that actually have the
environment set. (there are events, like user-created, that does not
relate to a specific environment).

Bit wary on this, but since we truncate down to row per (day,
environment) combo, finding conflict and incrementing shouldn't take too
long here.

@ivarconr was it something like this you were considering?
  • Loading branch information
Christopher Kolstad committed Oct 10, 2023
1 parent fa4d6b2 commit 1edd73d
Show file tree
Hide file tree
Showing 13 changed files with 560 additions and 59 deletions.
4 changes: 1 addition & 3 deletions src/lib/db/event-store.ts
Expand Up @@ -146,9 +146,7 @@ class EventStore implements IEventStore {

async batchStore(events: IBaseEvent[]): Promise<void> {
try {
await this.db(TABLE)
.insert(events.map(this.eventToDbRow))
.returning(EVENT_COLUMNS);
await this.db(TABLE).insert(events.map(this.eventToDbRow));
} catch (error: unknown) {
this.logger.warn(`Failed to store events: ${error}`);
}
Expand Down
153 changes: 153 additions & 0 deletions src/lib/features/instance-stats/getProductionChanges.e2e.test.ts
@@ -0,0 +1,153 @@
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
import getLogger from '../../../test/fixtures/no-logger';
import {
createGetProductionChanges,
GetProductionChanges,
} from './getProductionChanges';
import subDays from 'date-fns/subDays';
let db: ITestDb;
let getProductionChanges: GetProductionChanges;

const mockEventDaysAgo = (days: number, environment: string = 'production') => {
const result = new Date();
result.setDate(result.getDate() - days);
return {
day: result,
environment,
updates: 1,
};
};

const mockRawEventDaysAgo = (
days: number,
environment: string = 'production',
) => {
const date = subDays(new Date(), days);
return {
type: 'FEATURE_UPDATED',
created_at: date,
created_by: 'testrunner',
environment,
feature_name: 'test.feature',
announced: true,
};
};

const noEnvironmentEvent = (days: number) => {
return {
type: 'FEATURE_UPDATED',
created_by: 'testrunner',
feature_name: 'test.feature',
announced: true,
};
};

beforeAll(async () => {
db = await dbInit('product_changes_serial', getLogger);
await db.rawDatabase('environments').insert({
name: 'production',
type: 'production',
enabled: true,
protected: false,
});
getProductionChanges = createGetProductionChanges(db.rawDatabase);
});

afterEach(async () => {
await db.rawDatabase('stat_environment_updates').truncate();
});

afterAll(async () => {
await db.destroy();
});

test('should return 0 changes from an empty database', async () => {
await expect(getProductionChanges()).resolves.toEqual({
last30: 0,
last60: 0,
last90: 0,
});
});

test('should return 1 change', async () => {
await db
.rawDatabase('stat_environment_updates')
.insert(mockEventDaysAgo(1));

await expect(getProductionChanges()).resolves.toEqual({
last30: 1,
last60: 1,
last90: 1,
});
});

test('should handle intervals of activity', async () => {
await db
.rawDatabase('stat_environment_updates')
.insert([
mockEventDaysAgo(5),
mockEventDaysAgo(10),
mockEventDaysAgo(20),
mockEventDaysAgo(40),
mockEventDaysAgo(70),
mockEventDaysAgo(100),
]);

await expect(getProductionChanges()).resolves.toEqual({
last30: 3,
last60: 4,
last90: 5,
});
});

test('an event being saved should add a count to the table', async () => {
await db.rawDatabase
.table('events')
.insert(mockRawEventDaysAgo(70))
.returning('id');

await expect(getProductionChanges()).resolves.toEqual({
last30: 0,
last60: 0,
last90: 1,
});
});

test('an event with no environment should not be counted', async () => {
await db.rawDatabase('events').insert(noEnvironmentEvent(30));
await expect(getProductionChanges()).resolves.toEqual({
last30: 0,
last60: 0,
last90: 0,
});
});

test('five events per day should be counted correctly', async () => {
for (let i = 0; i < 100; i++) {
for (let j: number = 0; j < 5; j++) {
await db.rawDatabase.table('events').insert(mockRawEventDaysAgo(i));
}
}
await expect(getProductionChanges()).resolves.toEqual({
last30: 150,
last60: 300,
last90: 450,
});
});

test('Events posted to a non production environment should not be included in count', async () => {
await db.rawDatabase('environments').insert({
name: 'development',
type: 'development',
enabled: true,
protected: false,
});
await db.rawDatabase
.table('events')
.insert(mockRawEventDaysAgo(1, 'development'));
await expect(getProductionChanges()).resolves.toEqual({
last30: 0,
last60: 0,
last90: 0,
});
});
41 changes: 41 additions & 0 deletions src/lib/features/instance-stats/getProductionChanges.ts
@@ -0,0 +1,41 @@
import { type Db } from 'lib/server-impl';
import { GetActiveUsers } from './getActiveUsers';

export type GetProductionChanges = () => Promise<{
last30: number;
last60: number;
last90: number;
}>;

export const createGetProductionChanges =
(db: Db): GetProductionChanges =>
async () => {
const productionChanges = await db.raw(`SELECT SUM(CASE WHEN seu.day > NOW() - INTERVAL '30 days' THEN seu.updates END) AS last_month,
SUM(CASE WHEN seu.day > NOW() - INTERVAL '60 days' THEN seu.updates END) AS last_two_months,
SUM(CASE WHEN seu.day > NOW() - INTERVAL '90 days' THEN seu.updates END) AS last_quarter
FROM stat_environment_updates seu
LEFT JOIN environments e
ON e.name = seu.environment
WHERE e.type = 'production';`);
return {
last30: parseInt(productionChanges.rows[0]?.last_month || '0', 10),
last60: parseInt(
productionChanges.rows[0]?.last_two_months || '0',
10,
),
last90: parseInt(
productionChanges.rows[0]?.last_quarter || '0',
10,
),
};
};
export const createFakeGetProductionChanges =
(
changesInProduction: Awaited<ReturnType<GetProductionChanges>> = {
last30: 0,
last60: 0,
last90: 0,
},
): GetProductionChanges =>
() =>
Promise.resolve(changesInProduction);
Expand Up @@ -3,19 +3,26 @@ import { InstanceStatsService } from './instance-stats-service';
import createStores from '../../../test/fixtures/store';
import VersionService from '../../services/version-service';
import { createFakeGetActiveUsers } from './getActiveUsers';
import { createFakeGetProductionChanges } from './getProductionChanges';

let instanceStatsService: InstanceStatsService;
let versionService: VersionService;

beforeEach(() => {
const config = createTestConfig();
const stores = createStores();
versionService = new VersionService(stores, config);
versionService = new VersionService(
stores,
config,
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
instanceStatsService = new InstanceStatsService(
stores,
config,
versionService,
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);

jest.spyOn(instanceStatsService, 'refreshStatsSnapshot');
Expand Down
9 changes: 9 additions & 0 deletions src/lib/features/instance-stats/instance-stats-service.ts
Expand Up @@ -21,6 +21,7 @@ import { FEATURES_EXPORTED, FEATURES_IMPORTED } from '../../types';
import { CUSTOM_ROOT_ROLE_TYPE } from '../../util';
import { type GetActiveUsers } from './getActiveUsers';
import { ProjectModeCount } from '../../db/project-store';
import { GetProductionChanges } from './getProductionChanges';

export type TimeRange = 'allTime' | '30d' | '7d';

Expand All @@ -46,6 +47,7 @@ export interface InstanceStats {
OIDCenabled: boolean;
clientApps: { range: TimeRange; count: number }[];
activeUsers: Awaited<ReturnType<GetActiveUsers>>;
productionChanges: Awaited<ReturnType<GetProductionChanges>>;
}

export type InstanceStatsSigned = Omit<InstanceStats, 'projects'> & {
Expand Down Expand Up @@ -88,6 +90,8 @@ export class InstanceStatsService {

private getActiveUsers: GetActiveUsers;

private getProductionChanges: GetProductionChanges;

constructor(
{
featureToggleStore,
Expand Down Expand Up @@ -120,6 +124,7 @@ export class InstanceStatsService {
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
versionService: VersionService,
getActiveUsers: GetActiveUsers,
getProductionChanges: GetProductionChanges,
) {
this.strategyStore = strategyStore;
this.userStore = userStore;
Expand All @@ -136,6 +141,7 @@ export class InstanceStatsService {
this.clientInstanceStore = clientInstanceStore;
this.logger = getLogger('services/stats-service.js');
this.getActiveUsers = getActiveUsers;
this.getProductionChanges = getProductionChanges;
}

async refreshStatsSnapshot(): Promise<void> {
Expand Down Expand Up @@ -203,6 +209,7 @@ export class InstanceStatsService {
clientApps,
featureExports,
featureImports,
productionChanges,
] = await Promise.all([
this.getToggleCount(),
this.userStore.count(),
Expand All @@ -221,6 +228,7 @@ export class InstanceStatsService {
this.getLabeledAppCounts(),
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }),
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }),
this.getProductionChanges(),
]);

return {
Expand All @@ -245,6 +253,7 @@ export class InstanceStatsService {
clientApps,
featureExports,
featureImports,
productionChanges,
};
}

Expand Down
9 changes: 8 additions & 1 deletion src/lib/metrics.test.ts
Expand Up @@ -13,6 +13,7 @@ import createStores from '../test/fixtures/store';
import { InstanceStatsService } from './features/instance-stats/instance-stats-service';
import VersionService from './services/version-service';
import { createFakeGetActiveUsers } from './features/instance-stats/getActiveUsers';
import { createFakeGetProductionChanges } from './features/instance-stats/getProductionChanges';

const monitor = createMetricsMonitor();
const eventBus = new EventEmitter();
Expand All @@ -28,12 +29,18 @@ beforeAll(() => {
});
stores = createStores();
eventStore = stores.eventStore;
const versionService = new VersionService(stores, config);
const versionService = new VersionService(
stores,
config,
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
statsService = new InstanceStatsService(
stores,
config,
versionService,
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);

const db = {
Expand Down
23 changes: 23 additions & 0 deletions src/lib/metrics.ts
Expand Up @@ -174,6 +174,22 @@ export default class MetricsMonitor {
labelNames: ['status'],
});

const productionChanges30 = new client.Gauge({
name: 'production_changes_30',
help: 'Changes made to production environment last 30 days',
labelNames: ['environment'],
});
const productionChanges60 = new client.Gauge({
name: 'production_changes_60',
help: 'Changes made to production environment last 60 days',
labelNames: ['environment'],
});
const productionChanges90 = new client.Gauge({
name: 'production_changes_90',
help: 'Changes made to production environment last 90 days',
labelNames: ['environment'],
});

async function collectStaticCounters() {
try {
const stats = await instanceStatsService.getStats();
Expand All @@ -193,6 +209,13 @@ export default class MetricsMonitor {
usersActive90days.reset();
usersActive90days.set(stats.activeUsers.last90);

productionChanges30.reset();
productionChanges30.set(stats.productionChanges.last30);
productionChanges60.reset();
productionChanges60.set(stats.productionChanges.last60);
productionChanges90.reset();
productionChanges90.set(stats.productionChanges.last90);

projectsTotal.reset();
stats.projects.forEach((projectStat) => {
projectsTotal
Expand Down
28 changes: 28 additions & 0 deletions src/lib/openapi/spec/instance-admin-stats-schema.ts
Expand Up @@ -74,6 +74,34 @@ export const instanceAdminStatsSchema = {
},
},
},
productionChanges: {
type: 'object',
description:
'The number of changes to the production environment in the last 30, 60 and 90 days',
properties: {
last30: {
type: 'number',
description:
'The number of changes in production in the last 30 days',
example: 10,
minimum: 0,
},
last60: {
type: 'number',
description:
'The number of changes in production in the last 60 days',
example: 12,
minimum: 0,
},
last90: {
type: 'number',
description:
'The number of changes in production in the last 90 days',
example: 15,
minimum: 0,
},
},
},
featureToggles: {
type: 'number',
description: 'The number of feature-toggles this instance has',
Expand Down

0 comments on commit 1edd73d

Please sign in to comment.