1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

feat: feature changes counted in new table (#4958)

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?
This commit is contained in:
Christopher Kolstad 2023-10-10 12:32:23 +02:00 committed by GitHub
parent fa4d6b211a
commit 1edd73db45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 561 additions and 60 deletions

View File

@ -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}`);
}

View File

@ -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,
});
});

View File

@ -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);

View File

@ -3,6 +3,7 @@ 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;
@ -10,12 +11,18 @@ 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');

View File

@ -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';
@ -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'> & {
@ -88,6 +90,8 @@ export class InstanceStatsService {
private getActiveUsers: GetActiveUsers;
private getProductionChanges: GetProductionChanges;
constructor(
{
featureToggleStore,
@ -120,6 +124,7 @@ export class InstanceStatsService {
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
versionService: VersionService,
getActiveUsers: GetActiveUsers,
getProductionChanges: GetProductionChanges,
) {
this.strategyStore = strategyStore;
this.userStore = userStore;
@ -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> {
@ -203,6 +209,7 @@ export class InstanceStatsService {
clientApps,
featureExports,
featureImports,
productionChanges,
] = await Promise.all([
this.getToggleCount(),
this.userStore.count(),
@ -221,6 +228,7 @@ export class InstanceStatsService {
this.getLabeledAppCounts(),
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }),
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }),
this.getProductionChanges(),
]);
return {
@ -245,6 +253,7 @@ export class InstanceStatsService {
clientApps,
featureExports,
featureImports,
productionChanges,
};
}

View File

@ -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();
@ -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 = {

View File

@ -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();
@ -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

View File

@ -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',

View File

@ -113,6 +113,11 @@ class InstanceAdminController extends Controller {
last30: 10,
last7: 5,
},
productionChanges: {
last30: 100,
last60: 200,
last90: 200,
},
};
}

View File

@ -85,6 +85,10 @@ import {
createFakeLastSeenService,
createLastSeenService,
} from './client-metrics/last-seen/createLastSeenService';
import {
createFakeGetProductionChanges,
createGetProductionChanges,
} from '../features/instance-stats/getProductionChanges';
// TODO: will be moved to scheduler feature directory
export const scheduleServices = async (
@ -226,7 +230,19 @@ export const createServices = (
const accountService = new AccountService(stores, config, {
accessService,
});
const versionService = new VersionService(stores, config);
const getActiveUsers = db
? createGetActiveUsers(db)
: createFakeGetActiveUsers();
const getProductionChanges = db
? createGetProductionChanges(db)
: createFakeGetProductionChanges();
const versionService = new VersionService(
stores,
config,
getActiveUsers,
getProductionChanges,
);
const healthService = new HealthService(stores, config);
const userFeedbackService = new UserFeedbackService(stores, config);
const changeRequestAccessReadModel = db
@ -330,6 +346,7 @@ export const createServices = (
config,
versionService,
db ? createGetActiveUsers(db) : createFakeGetActiveUsers(),
db ? createGetProductionChanges(db) : createFakeGetProductionChanges(),
);
const schedulerService = new SchedulerService(config.getLogger);

View File

@ -5,6 +5,8 @@ import getLogger from '../../test/fixtures/no-logger';
import VersionService from './version-service';
import { v4 as uuidv4 } from 'uuid';
import { randomId } from '../util/random-id';
import { createFakeGetActiveUsers } from '../features/instance-stats/getActiveUsers';
import { createFakeGetProductionChanges } from '../features/instance-stats/getProductionChanges';
beforeAll(() => {
nock.disableNetConnect();
@ -31,11 +33,16 @@ test('yields current versions', async () => {
versions: latest,
}),
]);
const service = new VersionService(stores, {
getLogger,
versionCheck: { url, enable: true },
telemetry: true,
});
const service = new VersionService(
stores,
{
getLogger,
versionCheck: { url, enable: true },
telemetry: true,
},
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
await service.checkLatestVersion();
const versionInfo = service.getVersionInfo();
expect(scope.isDone()).toEqual(true);
@ -64,12 +71,17 @@ test('supports setting enterprise version as well', async () => {
}),
]);
const service = new VersionService(stores, {
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
});
const service = new VersionService(
stores,
{
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
},
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
await service.checkLatestVersion();
const versionInfo = service.getVersionInfo();
expect(scope.isDone()).toEqual(true);
@ -98,12 +110,17 @@ test('if version check is not enabled should not make any calls', async () => {
}),
]);
const service = new VersionService(stores, {
getLogger,
versionCheck: { url, enable: false },
enterpriseVersion,
telemetry: true,
});
const service = new VersionService(
stores,
{
getLogger,
versionCheck: { url, enable: false },
enterpriseVersion,
telemetry: true,
},
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
await service.checkLatestVersion();
const versionInfo = service.getVersionInfo();
expect(scope.isDone()).toEqual(false);
@ -140,12 +157,17 @@ test('sets featureinfo', async () => {
}),
]);
const service = new VersionService(stores, {
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
});
const service = new VersionService(
stores,
{
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
},
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
await service.checkLatestVersion();
expect(scope.isDone()).toEqual(true);
nock.cleanAll();
@ -189,12 +211,17 @@ test('counts toggles', async () => {
}),
]);
const service = new VersionService(stores, {
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
});
const service = new VersionService(
stores,
{
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
},
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
await service.checkLatestVersion();
expect(scope.isDone()).toEqual(true);
nock.cleanAll();
@ -253,12 +280,113 @@ test('counts custom strategies', async () => {
}),
]);
const service = new VersionService(stores, {
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
});
const service = new VersionService(
stores,
{
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
},
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
await service.checkLatestVersion();
expect(scope.isDone()).toEqual(true);
nock.cleanAll();
});
test('counts active users', async () => {
const url = `https://${randomId()}.example.com`;
const stores = createStores();
const enterpriseVersion = '4.0.0';
await stores.settingStore.insert('instanceInfo', { id: '1234abc' });
const latest = {
oss: '4.0.0',
enterprise: '4.0.0',
};
const fakeActiveUsers = createFakeGetActiveUsers({
last7: 2,
last30: 5,
last60: 10,
last90: 20,
});
const fakeProductionChanges = createFakeGetProductionChanges();
const scope = nock(url)
.post(
'/',
(body) =>
body.featureInfo &&
body.featureInfo.activeUsers30 === 5 &&
body.featureInfo.activeUsers60 === 10 &&
body.featureInfo.activeUsers90 === 20,
)
.reply(() => [
200,
JSON.stringify({
latest: true,
versions: latest,
}),
]);
const service = new VersionService(
stores,
{
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
},
fakeActiveUsers,
fakeProductionChanges,
);
await service.checkLatestVersion();
expect(scope.isDone()).toEqual(true);
nock.cleanAll();
});
test('Counts production changes', async () => {
const url = `https://${randomId()}.example.com`;
const stores = createStores();
const enterpriseVersion = '4.0.0';
await stores.settingStore.insert('instanceInfo', { id: '1234abc' });
const latest = {
oss: '4.0.0',
enterprise: '4.0.0',
};
const fakeActiveUsers = createFakeGetActiveUsers();
const fakeProductionChanges = createFakeGetProductionChanges({
last30: 5,
last60: 10,
last90: 20,
});
const scope = nock(url)
.post(
'/',
(body) =>
body.featureInfo &&
body.featureInfo.productionChanges30 === 5 &&
body.featureInfo.productionChanges60 === 10 &&
body.featureInfo.productionChanges90 === 20,
)
.reply(() => [
200,
JSON.stringify({
latest: true,
versions: latest,
}),
]);
const service = new VersionService(
stores,
{
getLogger,
versionCheck: { url, enable: true },
enterpriseVersion,
telemetry: true,
},
fakeActiveUsers,
fakeProductionChanges,
);
await service.checkLatestVersion();
expect(scope.isDone()).toEqual(true);
nock.cleanAll();

View File

@ -20,6 +20,14 @@ import { hoursToMilliseconds } from 'date-fns';
import { IStrategyStore } from 'lib/types';
import { FEATURES_EXPORTED, FEATURES_IMPORTED } from '../types';
import { CUSTOM_ROOT_ROLE_TYPE } from '../util';
import {
createGetActiveUsers,
GetActiveUsers,
} from '../features/instance-stats/getActiveUsers';
import {
createGetProductionChanges,
GetProductionChanges,
} from '../features/instance-stats/getProductionChanges';
export interface IVersionInfo {
oss: string;
@ -58,6 +66,12 @@ export interface IFeatureUsageInfo {
OIDCenabled: boolean;
customStrategies: number;
customStrategiesInUse: number;
activeUsers30: number;
activeUsers60: number;
activeUsers90: number;
productionChanges30: number;
productionChanges60: number;
productionChanges90: number;
}
export default class VersionService {
@ -87,6 +101,10 @@ export default class VersionService {
private featureStrategiesStore: IFeatureStrategiesStore;
private getActiveUsers: GetActiveUsers;
private getProductionChanges: GetProductionChanges;
private current: IVersionInfo;
private latest?: IVersionInfo;
@ -95,7 +113,7 @@ export default class VersionService {
private telemetryEnabled: boolean;
private versionCheckUrl: string;
private versionCheckUrl?: string;
private instanceId?: string;
@ -141,6 +159,8 @@ export default class VersionService {
IUnleashConfig,
'getLogger' | 'versionCheck' | 'enterpriseVersion' | 'telemetry'
>,
getActiveUsers: GetActiveUsers,
getProductionChanges: GetProductionChanges,
) {
this.logger = getLogger('lib/services/version-service.js');
this.settingStore = settingStore;
@ -154,12 +174,14 @@ export default class VersionService {
this.roleStore = roleStore;
this.segmentStore = segmentStore;
this.eventStore = eventStore;
this.getActiveUsers = getActiveUsers;
this.getProductionChanges = getProductionChanges;
this.featureStrategiesStore = featureStrategiesStore;
this.current = {
oss: version,
enterprise: enterpriseVersion || '',
};
this.enabled = versionCheck.enable;
this.enabled = versionCheck.enable || false;
this.telemetryEnabled = telemetry;
this.versionCheckUrl = versionCheck.url;
this.isLatest = true;
@ -194,25 +216,29 @@ export default class VersionService {
};
if (this.telemetryEnabled) {
const featureInfo = await this.getFeatureUsageInfo();
versionPayload.featureInfo = featureInfo;
versionPayload.featureInfo =
await this.getFeatureUsageInfo();
}
const res = await fetch(this.versionCheckUrl, {
method: 'POST',
body: JSON.stringify(versionPayload),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
const data = (await res.json()) as IVersionResponse;
this.latest = {
oss: data.versions.oss,
enterprise: data.versions.enterprise,
};
this.isLatest = data.latest;
if (this.versionCheckUrl) {
const res = await fetch(this.versionCheckUrl, {
method: 'POST',
body: JSON.stringify(versionPayload),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
const data = (await res.json()) as IVersionResponse;
this.latest = {
oss: data.versions.oss,
enterprise: data.versions.enterprise,
};
this.isLatest = data.latest;
} else {
this.logger.info(
`Could not check newest version. Status: ${res.status}`,
);
}
} else {
this.logger.info(
`Could not check newest version. Status: ${res.status}`,
);
this.logger.info('Had no URL to check newest version');
}
} catch (err) {
this.logger.info('Could not check newest version', err);
@ -237,6 +263,8 @@ export default class VersionService {
OIDCenabled,
featureExports,
featureImports,
userActive,
productionChanges,
] = await Promise.all([
this.featureToggleStore.count({
archived: false,
@ -257,6 +285,8 @@ export default class VersionService {
this.hasOIDC(),
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }),
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }),
this.userStats(),
this.productionChanges(),
]);
const versionInfo = this.getVersionInfo();
const customStrategies =
@ -284,10 +314,33 @@ export default class VersionService {
instanceId: versionInfo.instanceId,
versionOSS: versionInfo.current.oss,
versionEnterprise: versionInfo.current.enterprise,
activeUsers30: userActive.last30,
activeUsers60: userActive.last60,
activeUsers90: userActive.last90,
productionChanges30: productionChanges.last30,
productionChanges60: productionChanges.last60,
productionChanges90: productionChanges.last90,
};
return featureInfo;
}
async userStats(): Promise<{
last30: number;
last60: number;
last90: number;
}> {
const { last30, last60, last90 } = await this.getActiveUsers();
return { last30, last60, last90 };
}
async productionChanges(): Promise<{
last30: number;
last60: number;
last90: number;
}> {
return this.getProductionChanges();
}
async hasOIDC(): Promise<boolean> {
const settings = await this.settingStore.get(
'unleash.enterprise.auth.oidc',

View File

@ -0,0 +1,32 @@
exports.up = function(db, cb) {
db.runSql(`
CREATE TABLE stat_environment_updates(
day DATE NOT NULL,
environment TEXT,
updates BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (day, environment)
);
CREATE FUNCTION unleash_update_stat_environment_changes_counter() RETURNS trigger AS $unleash_update_changes_counter$
BEGIN
IF NEW.environment IS NOT NULL THEN
INSERT INTO stat_environment_updates(day, environment, updates) SELECT DATE_TRUNC('Day', NEW.created_at), NEW.environment, 1 ON CONFLICT (day, environment) DO UPDATE SET updates = stat_environment_updates.updates + 1;
END IF;
return null;
END;
$unleash_update_changes_counter$ LANGUAGE plpgsql;
CREATE TRIGGER unleash_update_stat_environment_changes
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION unleash_update_stat_environment_changes_counter();
`, cb);
};
exports.down = function(db, cb) {
db.runSql(`
DROP TRIGGER IF EXISTS unleash_update_stat_environment_changes ON events;
DROP FUNCTION IF EXISTS unleash_update_stat_environment_changes_counter;
DROP TABLE IF EXISTS stat_environment_updates;
`, cb);
};