mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-31 00:16:47 +01:00
Better client informations from the API.
Impelements: - http://unleash.host.com/api/client/seen-toggles - http://unleash.host.com/api/metrics/feature-toggles - http://localhost:4242/api/client/applications - http://localhost:4242/api/client/applications/:appName
This commit is contained in:
parent
13a93dcf43
commit
e55378e1c4
@ -4,15 +4,17 @@ const { test } = require('ava');
|
|||||||
const UnleashClientMetrics = require('./index');
|
const UnleashClientMetrics = require('./index');
|
||||||
const sinon = require('sinon');
|
const sinon = require('sinon');
|
||||||
|
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
const appName = 'appName';
|
const appName = 'appName';
|
||||||
const instanceId = 'instanceId';
|
const instanceId = 'instanceId';
|
||||||
|
|
||||||
test('should work without state', (t) => {
|
test('should work without state', (t) => {
|
||||||
const metrics = new UnleashClientMetrics();
|
const store = new EventEmitter();
|
||||||
|
const metrics = new UnleashClientMetrics(store);
|
||||||
|
|
||||||
t.truthy(metrics.getMetricsOverview());
|
t.truthy(metrics.getAppsWitToggles());
|
||||||
t.truthy(metrics.getTogglesMetrics());
|
t.truthy(metrics.getTogglesMetrics());
|
||||||
t.truthy(metrics.toJSON());
|
|
||||||
|
|
||||||
metrics.destroy();
|
metrics.destroy();
|
||||||
});
|
});
|
||||||
@ -20,7 +22,8 @@ test('should work without state', (t) => {
|
|||||||
test.cb('data should expire', (t) => {
|
test.cb('data should expire', (t) => {
|
||||||
const clock = sinon.useFakeTimers();
|
const clock = sinon.useFakeTimers();
|
||||||
|
|
||||||
const metrics = new UnleashClientMetrics();
|
const store = new EventEmitter();
|
||||||
|
const metrics = new UnleashClientMetrics(store);
|
||||||
|
|
||||||
metrics.addPayload({
|
metrics.addPayload({
|
||||||
appName,
|
appName,
|
||||||
@ -59,9 +62,10 @@ test.cb('data should expire', (t) => {
|
|||||||
t.end();
|
t.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addPayload', t => {
|
test('should listen to metrics from store', t => {
|
||||||
const metrics = new UnleashClientMetrics();
|
const store = new EventEmitter();
|
||||||
metrics.addPayload({
|
const metrics = new UnleashClientMetrics(store);
|
||||||
|
store.emit('metrics', {
|
||||||
appName,
|
appName,
|
||||||
instanceId,
|
instanceId,
|
||||||
bucket: {
|
bucket: {
|
||||||
@ -76,8 +80,6 @@ test('addPayload', t => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
t.truthy(metrics.clients[instanceId].appName === appName);
|
|
||||||
t.truthy(metrics.clients[instanceId].count === 123);
|
|
||||||
t.truthy(metrics.apps[appName].count === 123);
|
t.truthy(metrics.apps[appName].count === 123);
|
||||||
t.truthy(metrics.globalCount === 123);
|
t.truthy(metrics.globalCount === 123);
|
||||||
|
|
||||||
@ -100,7 +102,6 @@ test('addPayload', t => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
t.truthy(metrics.clients[instanceId].count === 143);
|
|
||||||
t.truthy(metrics.globalCount === 143);
|
t.truthy(metrics.globalCount === 143);
|
||||||
t.deepEqual(metrics.getTogglesMetrics().lastHour.toggleX, { yes: 133, no: 10 });
|
t.deepEqual(metrics.getTogglesMetrics().lastHour.toggleX, { yes: 133, no: 10 });
|
||||||
t.deepEqual(metrics.getTogglesMetrics().lastMinute.toggleX, { yes: 133, no: 10 });
|
t.deepEqual(metrics.getTogglesMetrics().lastMinute.toggleX, { yes: 133, no: 10 });
|
||||||
@ -108,10 +109,13 @@ test('addPayload', t => {
|
|||||||
metrics.destroy();
|
metrics.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addBucket', t => {
|
test('should build up list of seend toggles when new metrics arrives', t => {
|
||||||
const metrics = new UnleashClientMetrics();
|
const store = new EventEmitter();
|
||||||
metrics.addClient(appName, instanceId);
|
const metrics = new UnleashClientMetrics(store);
|
||||||
metrics.addBucket(appName, instanceId, {
|
store.emit('metrics', {
|
||||||
|
appName,
|
||||||
|
instanceId,
|
||||||
|
bucket: {
|
||||||
start: new Date(),
|
start: new Date(),
|
||||||
stop: new Date(),
|
stop: new Date(),
|
||||||
toggles: {
|
toggles: {
|
||||||
@ -119,39 +123,51 @@ test('addBucket', t => {
|
|||||||
yes: 123,
|
yes: 123,
|
||||||
no: 0,
|
no: 0,
|
||||||
},
|
},
|
||||||
|
toggleY: {
|
||||||
|
yes: 50,
|
||||||
|
no: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
t.truthy(metrics.clients[instanceId].count === 123);
|
|
||||||
t.truthy(metrics.globalCount === 123);
|
|
||||||
t.deepEqual(metrics.getTogglesMetrics().lastMinute.toggleX, { yes: 123, no: 0 });
|
|
||||||
|
|
||||||
|
const appToggles = metrics.getAppsWitToggles();
|
||||||
|
const togglesForApp = metrics.getSeenTogglesByAppName(appName);
|
||||||
|
|
||||||
|
t.truthy(appToggles.length === 1);
|
||||||
|
t.truthy(appToggles[0].seenToggles.length === 2);
|
||||||
|
t.truthy(appToggles[0].seenToggles.includes('toggleX'));
|
||||||
|
t.truthy(appToggles[0].seenToggles.includes('toggleY'));
|
||||||
|
|
||||||
|
t.truthy(togglesForApp.length === 2);
|
||||||
|
t.truthy(togglesForApp.includes('toggleX'));
|
||||||
|
t.truthy(togglesForApp.includes('toggleY'));
|
||||||
metrics.destroy();
|
metrics.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addClient', t => {
|
|
||||||
const metrics = new UnleashClientMetrics();
|
|
||||||
|
|
||||||
metrics.addClient(appName, instanceId);
|
|
||||||
metrics.addClient(appName, instanceId, new Date());
|
|
||||||
|
|
||||||
t.truthy(metrics.clients[instanceId].count === 0);
|
test('should handle a lot of toggles', t => {
|
||||||
t.truthy(metrics.globalCount === 0);
|
const store = new EventEmitter();
|
||||||
|
const metrics = new UnleashClientMetrics(store);
|
||||||
metrics.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('addApp', t => {
|
|
||||||
const metrics = new UnleashClientMetrics();
|
|
||||||
|
|
||||||
metrics.addApp(appName, instanceId);
|
|
||||||
t.truthy(metrics.apps[appName].clients.length === 1);
|
|
||||||
metrics.addApp(appName, 'instanceId2');
|
|
||||||
t.truthy(metrics.apps[appName].clients.length === 2);
|
|
||||||
|
|
||||||
metrics.addApp('appName2', 'instanceId2');
|
|
||||||
t.truthy(metrics.apps.appName2.clients.length === 1);
|
|
||||||
metrics.addApp('appName2', instanceId);
|
|
||||||
t.truthy(metrics.apps.appName2.clients.length === 2);
|
|
||||||
|
|
||||||
|
const toggleCounts = {};
|
||||||
|
for (let i=0; i<100; i++) {
|
||||||
|
toggleCounts[`toggle${i}`] = {yes: i, no: i}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.emit('metrics', {
|
||||||
|
appName,
|
||||||
|
instanceId,
|
||||||
|
bucket: {
|
||||||
|
start: new Date(),
|
||||||
|
stop: new Date(),
|
||||||
|
toggles: toggleCounts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const seenToggles = metrics.getSeenTogglesByAppName(appName);
|
||||||
|
|
||||||
|
t.truthy(seenToggles.length === 100);
|
||||||
metrics.destroy();
|
metrics.destroy();
|
||||||
});
|
});
|
@ -4,10 +4,10 @@ const Projection = require('./projection.js');
|
|||||||
const TTLList = require('./ttl-list.js');
|
const TTLList = require('./ttl-list.js');
|
||||||
|
|
||||||
module.exports = class UnleashClientMetrics {
|
module.exports = class UnleashClientMetrics {
|
||||||
constructor () {
|
constructor (clientMetricsStore) {
|
||||||
|
|
||||||
this.globalCount = 0;
|
this.globalCount = 0;
|
||||||
this.apps = {};
|
this.apps = {};
|
||||||
this.clients = {};
|
|
||||||
|
|
||||||
this.lastHourProjection = new Projection();
|
this.lastHourProjection = new Projection();
|
||||||
this.lastMinuteProjection = new Projection();
|
this.lastMinuteProjection = new Projection();
|
||||||
@ -15,6 +15,7 @@ module.exports = class UnleashClientMetrics {
|
|||||||
this.lastHourList = new TTLList({
|
this.lastHourList = new TTLList({
|
||||||
interval: 10000,
|
interval: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.lastMinuteList = new TTLList({
|
this.lastMinuteList = new TTLList({
|
||||||
interval: 10000,
|
interval: 10000,
|
||||||
expireType: 'minutes',
|
expireType: 'minutes',
|
||||||
@ -31,18 +32,20 @@ module.exports = class UnleashClientMetrics {
|
|||||||
this.lastMinuteProjection.substract(toggleName, toggles[toggleName]);
|
this.lastMinuteProjection.substract(toggleName, toggles[toggleName]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
clientMetricsStore.on('metrics', (m) => this.addPayload(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON () {
|
getAppsWitToggles () {
|
||||||
return JSON.stringify(this.getMetricsOverview(), null, 4);
|
const apps = [];
|
||||||
|
Object.keys(this.apps).forEach(appName => {
|
||||||
|
const seenToggles = Object.keys(this.apps[appName].seenToggles);
|
||||||
|
const metricsCount = this.apps[appName].count;
|
||||||
|
apps.push({appName, seenToggles, metricsCount})
|
||||||
|
});
|
||||||
|
return apps;
|
||||||
}
|
}
|
||||||
|
getSeenTogglesByAppName(appName) {
|
||||||
getMetricsOverview () {
|
return this.apps[appName] ? Object.keys(this.apps[appName].seenToggles) : [];
|
||||||
return {
|
|
||||||
globalCount: this.globalCount,
|
|
||||||
apps: this.apps,
|
|
||||||
clients: this.clients,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getTogglesMetrics () {
|
getTogglesMetrics () {
|
||||||
@ -53,16 +56,24 @@ module.exports = class UnleashClientMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addPayload (data) {
|
addPayload (data) {
|
||||||
this.addClient(data.appName, data.instanceId);
|
const { appName, bucket } = data;
|
||||||
this.addBucket(data.appName, data.instanceId, data.bucket);
|
const app = this.getApp(appName)
|
||||||
|
this.addBucket(app, data.bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
addBucket (appName, instanceId, bucket) {
|
getApp(appName) {
|
||||||
|
this.apps[appName] = this.apps[appName] || {seenToggles: {}, count: 0};
|
||||||
|
return this.apps[appName];
|
||||||
|
}
|
||||||
|
|
||||||
|
addBucket (app, bucket) {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
// TODO stop should be createdAt
|
// TODO stop should be createdAt
|
||||||
const { stop, toggles } = bucket;
|
const { stop, toggles } = bucket;
|
||||||
|
|
||||||
Object.keys(toggles).forEach((n) => {
|
const toggleNames = Object.keys(toggles);
|
||||||
|
|
||||||
|
toggleNames.forEach((n) => {
|
||||||
const entry = toggles[n];
|
const entry = toggles[n];
|
||||||
this.lastHourProjection.add(n, entry);
|
this.lastHourProjection.add(n, entry);
|
||||||
this.lastMinuteProjection.add(n, entry);
|
this.lastMinuteProjection.add(n, entry);
|
||||||
@ -72,49 +83,13 @@ module.exports = class UnleashClientMetrics {
|
|||||||
this.lastHourList.add(toggles, stop);
|
this.lastHourList.add(toggles, stop);
|
||||||
this.lastMinuteList.add(toggles, stop);
|
this.lastMinuteList.add(toggles, stop);
|
||||||
|
|
||||||
this.addClientCount(appName, instanceId, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
addClientCount (appName, instanceId, count) {
|
|
||||||
if (typeof count === 'number' && count > 0) {
|
|
||||||
this.globalCount += count;
|
this.globalCount += count;
|
||||||
if (this.clients[instanceId]) {
|
app.count += count;
|
||||||
this.clients[instanceId].count += count;
|
this.addSeenToggles(app, toggleNames);
|
||||||
}
|
|
||||||
if (this.apps[appName]) {
|
|
||||||
this.apps[appName].count += count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addClient (appName, instanceId, started = new Date()) {
|
addSeenToggles (app, toggleNames) {
|
||||||
this.addApp(appName, instanceId);
|
toggleNames.forEach(t => app.seenToggles[t] = true);
|
||||||
if (instanceId) {
|
|
||||||
if (this.clients[instanceId]) {
|
|
||||||
this.clients[instanceId].ping = new Date();
|
|
||||||
} else {
|
|
||||||
this.clients[instanceId] = {
|
|
||||||
appName,
|
|
||||||
count: 0,
|
|
||||||
started,
|
|
||||||
init: new Date(),
|
|
||||||
ping: new Date(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addApp (appName, instanceId) {
|
|
||||||
if (appName && !this.apps[appName]) {
|
|
||||||
this.apps[appName] = {
|
|
||||||
count: 0,
|
|
||||||
clients: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instanceId && !this.apps[appName].clients.includes(instanceId)) {
|
|
||||||
this.apps[appName].clients.push(instanceId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy () {
|
destroy () {
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { EventEmitter } = require('events');
|
|
||||||
|
|
||||||
module.exports = class UnleashClientMetrics extends EventEmitter {
|
|
||||||
constructor (metricsDb, interval = 10000) {
|
|
||||||
super();
|
|
||||||
this.interval = interval;
|
|
||||||
this.db = metricsDb;
|
|
||||||
this.highestIdSeen = 0;
|
|
||||||
this.db.getMetricsLastHour().then(metrics => {
|
|
||||||
this.addMetrics(metrics);
|
|
||||||
this.startPoller();
|
|
||||||
this.emit('ready');
|
|
||||||
});
|
|
||||||
this.timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
addMetrics (metrics) {
|
|
||||||
if (metrics && metrics.length > 0) {
|
|
||||||
this.highestIdSeen = metrics[metrics.length - 1].id;
|
|
||||||
}
|
|
||||||
this.emit('metrics', metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
startPoller () {
|
|
||||||
this.timer = setInterval(() => {
|
|
||||||
this.db.getNewMetrics(this.highestIdSeen)
|
|
||||||
.then(metrics => this.addMetrics(metrics));
|
|
||||||
}, this.interval);
|
|
||||||
this.timer.unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
insert (metrics) {
|
|
||||||
return this.db.insert(metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy () {
|
|
||||||
try {
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,66 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { test } = require('ava');
|
|
||||||
const MetricsService = require('./service');
|
|
||||||
const sinon = require('sinon');
|
|
||||||
|
|
||||||
function getMockDb () {
|
|
||||||
const list = [{ id: 2 }, { id: 3 }, { id: 4 }];
|
|
||||||
const db = {
|
|
||||||
getMetricsLastHour () {
|
|
||||||
return Promise.resolve([{ id: 1 }]);
|
|
||||||
},
|
|
||||||
|
|
||||||
getNewMetrics () {
|
|
||||||
return Promise.resolve([list.pop() || { id: 0 }]);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
db,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test.cb('should call database on startup', (t) => {
|
|
||||||
const mock = getMockDb();
|
|
||||||
const service = new MetricsService(mock.db);
|
|
||||||
t.plan(2);
|
|
||||||
|
|
||||||
service.on('metrics', ([metric]) => {
|
|
||||||
t.true(service.highestIdSeen === 1);
|
|
||||||
t.true(metric.id === 1);
|
|
||||||
t.end();
|
|
||||||
service.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.cb('should poll for updates', (t) => {
|
|
||||||
const clock = sinon.useFakeTimers();
|
|
||||||
|
|
||||||
const mock = getMockDb();
|
|
||||||
const service = new MetricsService(mock.db, 100);
|
|
||||||
|
|
||||||
const metrics = [];
|
|
||||||
service.on('metrics', (_metrics) => {
|
|
||||||
_metrics.forEach(m => m && metrics.push(m));
|
|
||||||
});
|
|
||||||
|
|
||||||
t.true(metrics.length === 0);
|
|
||||||
|
|
||||||
service.on('ready', () => {
|
|
||||||
t.true(metrics.length === 1);
|
|
||||||
|
|
||||||
clock.tick(300);
|
|
||||||
clock.restore();
|
|
||||||
|
|
||||||
process.nextTick(() => {
|
|
||||||
t.true(metrics.length === 4);
|
|
||||||
t.true(metrics[0].id === 1);
|
|
||||||
t.true(metrics[1].id === 4);
|
|
||||||
t.true(metrics[2].id === 3);
|
|
||||||
t.true(metrics[3].id === 2);
|
|
||||||
service.destroy();
|
|
||||||
t.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -27,10 +27,7 @@ test.cb('should slice off list', (t) => {
|
|||||||
expireType: 'milliseconds',
|
expireType: 'milliseconds',
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.time('4');
|
|
||||||
// console.time('3');
|
|
||||||
// console.time('2');
|
|
||||||
// console.time('1');
|
|
||||||
list.add({ n: '1' }, moment().add(1, 'milliseconds'));
|
list.add({ n: '1' }, moment().add(1, 'milliseconds'));
|
||||||
list.add({ n: '2' }, moment().add(50, 'milliseconds'));
|
list.add({ n: '2' }, moment().add(50, 'milliseconds'));
|
||||||
list.add({ n: '3' }, moment().add(200, 'milliseconds'));
|
list.add({ n: '3' }, moment().add(200, 'milliseconds'));
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
/* eslint camelcase: "off" */
|
/* eslint camelcase: "off" */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const logger = require('../logger');
|
||||||
const COLUMNS = ['app_name', 'instance_id', 'client_ip', 'last_seen', 'created_at'];
|
const COLUMNS = ['app_name', 'instance_id', 'client_ip', 'last_seen', 'created_at'];
|
||||||
const TABLE = 'client_instances';
|
const TABLE = 'client_instances';
|
||||||
|
|
||||||
@ -12,10 +13,24 @@ const mapRow = (row) => ({
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mapAppsRow = (row) => ({
|
||||||
|
appName: row.app_name,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
class ClientInstanceStore {
|
class ClientInstanceStore {
|
||||||
|
|
||||||
constructor (db) {
|
constructor (db) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
|
setTimeout(() => this._removeInstancesOlderThanTwoDays(), 10).unref();
|
||||||
|
setInterval(() => this._removeInstancesOlderThanTwoDays(), 24 * 61 * 60 * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeInstancesOlderThanTwoDays () {
|
||||||
|
this.db(TABLE)
|
||||||
|
.whereRaw('created_at < now() - interval \'2 days\'')
|
||||||
|
.del()
|
||||||
|
.then((res) => logger.info(`Deleted ${res} instances`));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRow (details) {
|
updateRow (details) {
|
||||||
@ -58,6 +73,24 @@ class ClientInstanceStore {
|
|||||||
.orderBy('last_seen', 'desc')
|
.orderBy('last_seen', 'desc')
|
||||||
.map(mapRow);
|
.map(mapRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getByAppName (appName) {
|
||||||
|
return this.db
|
||||||
|
.select(COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.where('app_name', appName)
|
||||||
|
.orderBy('last_seen', 'desc')
|
||||||
|
.map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
getApplications () {
|
||||||
|
return this.db
|
||||||
|
.distinct('app_name')
|
||||||
|
.select(['app_name'])
|
||||||
|
.from(TABLE)
|
||||||
|
.orderBy('app_name', 'desc')
|
||||||
|
.map(mapRow);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = ClientInstanceStore;
|
module.exports = ClientInstanceStore;
|
||||||
|
58
lib/db/client-metrics-db.js
Normal file
58
lib/db/client-metrics-db.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
const METRICS_COLUMNS = ['id', 'created_at', 'metrics'];
|
||||||
|
const TABLE = 'client_metrics';
|
||||||
|
|
||||||
|
const mapRow = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
metrics: row.metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
class ClientMetricsDb {
|
||||||
|
constructor (db) {
|
||||||
|
this.db = db;
|
||||||
|
|
||||||
|
//Clear old metrics regulary
|
||||||
|
setTimeout(() => this.removeMetricsOlderThanOneHour(), 10).unref();
|
||||||
|
setInterval(() => this.removeMetricsOlderThanOneHour(), 60 * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMetricsOlderThanOneHour () {
|
||||||
|
this.db(TABLE)
|
||||||
|
.whereRaw('created_at < now() - interval \'1 hour\'')
|
||||||
|
.del()
|
||||||
|
.then((res) => logger.info(`Deleted ${res} metrics`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new client metrics
|
||||||
|
insert (metrics) {
|
||||||
|
return this.db(TABLE).insert({ metrics });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used at startup to load all metrics last week into memory!
|
||||||
|
getMetricsLastHour () {
|
||||||
|
return this.db
|
||||||
|
.select(METRICS_COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.limit(2000)
|
||||||
|
.whereRaw('created_at > now() - interval \'1 hour\'')
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to poll for new metrics
|
||||||
|
getNewMetrics (lastKnownId) {
|
||||||
|
return this.db
|
||||||
|
.select(METRICS_COLUMNS)
|
||||||
|
.from(TABLE)
|
||||||
|
.limit(1000)
|
||||||
|
.where('id', '>', lastKnownId)
|
||||||
|
.orderBy('created_at', 'asc')
|
||||||
|
.map(mapRow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ClientMetricsDb;
|
@ -1,55 +1,53 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const logger = require('../logger');
|
const logger = require('../logger');
|
||||||
const METRICS_COLUMNS = ['id', 'created_at', 'metrics'];
|
|
||||||
const TABLE = 'client_metrics';
|
|
||||||
|
|
||||||
const mapRow = (row) => ({
|
const { EventEmitter } = require('events');
|
||||||
id: row.id,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
metrics: row.metrics,
|
|
||||||
});
|
|
||||||
|
|
||||||
class ClientMetricsStore {
|
const TEN_SECONDS = 10 * 1000;
|
||||||
|
|
||||||
constructor (db) {
|
class ClientMetricsStore extends EventEmitter {
|
||||||
this.db = db;
|
|
||||||
setTimeout(() => this._removeMetricsOlderThanOneHour(), 10).unref();
|
constructor (metricsDb, pollInterval = TEN_SECONDS) {
|
||||||
setInterval(() => this._removeMetricsOlderThanOneHour(), 60 * 60 * 1000).unref();
|
super();
|
||||||
|
this.metricsDb = metricsDb;
|
||||||
|
this.highestIdSeen = 0;
|
||||||
|
this.timer;
|
||||||
|
|
||||||
|
//Build internal state
|
||||||
|
metricsDb.getMetricsLastHour()
|
||||||
|
.then((metrics) => this._emitMetrics(metrics))
|
||||||
|
.then(() => this._startPoller(pollInterval))
|
||||||
|
.then(() => this.emit('ready'))
|
||||||
|
.catch((err) => logger.error(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
_removeMetricsOlderThanOneHour () {
|
_startPoller (pollInterval) {
|
||||||
this.db(TABLE)
|
this.timer = setInterval(() => this._fetchNewAndEmit(), pollInterval);
|
||||||
.whereRaw('created_at < now() - interval \'1 hour\'')
|
this.timer.unref();
|
||||||
.del()
|
}
|
||||||
.then((res) => logger.info(`Deleted ${res} metrics`));
|
|
||||||
|
_fetchNewAndEmit() {
|
||||||
|
this.metricsDb.getNewMetrics(this.highestIdSeen)
|
||||||
|
.then((metrics) => this._emitMetrics(metrics))
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitMetrics (metrics) {
|
||||||
|
if (metrics && metrics.length > 0) {
|
||||||
|
this.highestIdSeen = metrics[metrics.length - 1].id;
|
||||||
|
metrics.forEach(m => this.emit('metrics', m.metrics));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new client metrics
|
// Insert new client metrics
|
||||||
insert (metrics) {
|
insert (metrics) {
|
||||||
return this.db(TABLE).insert({ metrics });
|
return this.metricsDb.insert(metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used at startup to load all metrics last week into memory!
|
destroy () {
|
||||||
getMetricsLastHour () {
|
try {
|
||||||
return this.db
|
clearInterval(this.timer);
|
||||||
.select(METRICS_COLUMNS)
|
} catch (e) {}
|
||||||
.from(TABLE)
|
|
||||||
.limit(2000)
|
|
||||||
.whereRaw('created_at > now() - interval \'1 hour\'')
|
|
||||||
.orderBy('created_at', 'asc')
|
|
||||||
.map(mapRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Used to poll for new metrics
|
|
||||||
getNewMetrics (lastKnownId) {
|
|
||||||
return this.db
|
|
||||||
.select(METRICS_COLUMNS)
|
|
||||||
.from(TABLE)
|
|
||||||
.limit(1000)
|
|
||||||
.where('id', '>', lastKnownId)
|
|
||||||
.orderBy('created_at', 'asc')
|
|
||||||
.map(mapRow);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
62
lib/db/client-metrics-store.test.js
Normal file
62
lib/db/client-metrics-store.test.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('ava');
|
||||||
|
const ClientMetricStore = require('./client-metrics-store');
|
||||||
|
const sinon = require('sinon');
|
||||||
|
|
||||||
|
function getMockDb () {
|
||||||
|
const list = [{ id: 4, metrics: {appName: 'test'} }, { id: 3, metrics: {appName: 'test'} }, { id: 2, metrics: {appName: 'test'} }];
|
||||||
|
return {
|
||||||
|
getMetricsLastHour () {
|
||||||
|
return Promise.resolve([{ id: 1, metrics: {appName: 'test'} }]);
|
||||||
|
},
|
||||||
|
|
||||||
|
getNewMetrics (v) {
|
||||||
|
return Promise.resolve([list.pop() || { id: 0 }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
test.cb('should call database on startup', (t) => {
|
||||||
|
const mock = getMockDb();
|
||||||
|
|
||||||
|
const store = new ClientMetricStore(mock);
|
||||||
|
|
||||||
|
t.plan(2);
|
||||||
|
|
||||||
|
|
||||||
|
store.on('metrics', (metrics) => {
|
||||||
|
t.true(store.highestIdSeen === 1);
|
||||||
|
t.true(metrics.appName === 'test');
|
||||||
|
store.destroy();
|
||||||
|
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test.cb('should poll for updates', (t) => {
|
||||||
|
const clock = sinon.useFakeTimers();
|
||||||
|
|
||||||
|
const mock = getMockDb();
|
||||||
|
const store = new ClientMetricStore(mock, 100);
|
||||||
|
|
||||||
|
const metrics = [];
|
||||||
|
store.on('metrics', (m) => metrics.push(m));
|
||||||
|
|
||||||
|
t.true(metrics.length === 0);
|
||||||
|
|
||||||
|
store.on('ready', () => {
|
||||||
|
t.true(metrics.length === 1);
|
||||||
|
clock.tick(300);
|
||||||
|
process.nextTick(() => {
|
||||||
|
t.true(metrics.length === 4);
|
||||||
|
t.true(store.highestIdSeen === 4);
|
||||||
|
store.destroy();
|
||||||
|
clock.restore();
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -49,6 +49,14 @@ class ClientStrategyStore {
|
|||||||
.from(TABLE)
|
.from(TABLE)
|
||||||
.map(mapRow);
|
.map(mapRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getByAppName (appName) {
|
||||||
|
return this.db
|
||||||
|
.select(COLUMNS)
|
||||||
|
.where('app_name', appName)
|
||||||
|
.from(TABLE)
|
||||||
|
.map(mapRow);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = ClientStrategyStore;
|
module.exports = ClientStrategyStore;
|
||||||
|
@ -5,12 +5,14 @@ const EventStore = require('./event-store');
|
|||||||
const FeatureToggleStore = require('./feature-toggle-store');
|
const FeatureToggleStore = require('./feature-toggle-store');
|
||||||
const StrategyStore = require('./strategy-store');
|
const StrategyStore = require('./strategy-store');
|
||||||
const ClientInstanceStore = require('./client-instance-store');
|
const ClientInstanceStore = require('./client-instance-store');
|
||||||
|
const ClientMetricsDb = require('./client-metrics-db');
|
||||||
const ClientMetricsStore = require('./client-metrics-store');
|
const ClientMetricsStore = require('./client-metrics-store');
|
||||||
const ClientStrategyStore = require('./client-strategy-store');
|
const ClientStrategyStore = require('./client-strategy-store');
|
||||||
|
|
||||||
module.exports.createStores = (config) => {
|
module.exports.createStores = (config) => {
|
||||||
const db = createDb(config);
|
const db = createDb(config);
|
||||||
const eventStore = new EventStore(db);
|
const eventStore = new EventStore(db);
|
||||||
|
const clientMetricsDb = new ClientMetricsDb(db);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
@ -18,7 +20,7 @@ module.exports.createStores = (config) => {
|
|||||||
featureToggleStore: new FeatureToggleStore(db, eventStore),
|
featureToggleStore: new FeatureToggleStore(db, eventStore),
|
||||||
strategyStore: new StrategyStore(db, eventStore),
|
strategyStore: new StrategyStore(db, eventStore),
|
||||||
clientInstanceStore: new ClientInstanceStore(db),
|
clientInstanceStore: new ClientInstanceStore(db),
|
||||||
clientMetricsStore: new ClientMetricsStore(db),
|
clientMetricsStore: new ClientMetricsStore(clientMetricsDb),
|
||||||
clientStrategyStore: new ClientStrategyStore(db),
|
clientStrategyStore: new ClientStrategyStore(db),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
const logger = require('../logger');
|
const logger = require('../logger');
|
||||||
const ClientMetrics = require('../client-metrics');
|
const ClientMetrics = require('../client-metrics');
|
||||||
const ClientMetricsService = require('../client-metrics/service');
|
|
||||||
const joi = require('joi');
|
const joi = require('joi');
|
||||||
const { clientMetricsSchema, clientRegisterSchema } = require('./metrics-schema');
|
const { clientMetricsSchema, clientRegisterSchema } = require('./metrics-schema');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO:
|
||||||
|
* - always catch errors and always return a response to client!
|
||||||
|
* - clean up and document uri endpoint
|
||||||
|
* - always json response (middleware?)
|
||||||
|
* - fix failing tests
|
||||||
|
*/
|
||||||
module.exports = function (app, config) {
|
module.exports = function (app, config) {
|
||||||
const {
|
const {
|
||||||
clientMetricsStore,
|
clientMetricsStore,
|
||||||
@ -13,17 +19,17 @@ module.exports = function (app, config) {
|
|||||||
clientInstanceStore,
|
clientInstanceStore,
|
||||||
} = config.stores;
|
} = config.stores;
|
||||||
|
|
||||||
const metrics = new ClientMetrics();
|
const metrics = new ClientMetrics(clientMetricsStore);
|
||||||
const service = new ClientMetricsService(clientMetricsStore);
|
|
||||||
|
|
||||||
service.on('metrics', (entries) => {
|
/**
|
||||||
entries.forEach((m) => {
|
* [{
|
||||||
metrics.addPayload(m.metrics);
|
"appName": "mfinn",
|
||||||
});
|
"toggles": ["toggle1", "toggle2"],
|
||||||
});
|
}]
|
||||||
|
*/
|
||||||
app.get('/metrics', (req, res) => {
|
app.get('/client/seen-toggles', (req, res) => {
|
||||||
res.json(metrics.getMetricsOverview());
|
const seenAppToggles = metrics.getAppsWitToggles();
|
||||||
|
res.json(seenAppToggles);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/metrics/features', (req, res) => {
|
app.get('/metrics/features', (req, res) => {
|
||||||
@ -32,11 +38,20 @@ module.exports = function (app, config) {
|
|||||||
|
|
||||||
app.post('/client/metrics', (req, res) => {
|
app.post('/client/metrics', (req, res) => {
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
|
const clientIp = req.ip;
|
||||||
|
|
||||||
joi.validate(data, clientMetricsSchema, (err, cleaned) => {
|
joi.validate(data, clientMetricsSchema, (err, cleaned) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(400).json(err);
|
return res.status(400).json(err);
|
||||||
}
|
}
|
||||||
service.insert(cleaned)
|
|
||||||
|
clientMetricsStore
|
||||||
|
.insert(cleaned)
|
||||||
|
.then(() => clientInstanceStore.insert({
|
||||||
|
appName: cleaned.appName,
|
||||||
|
instanceId: cleaned.instanceId,
|
||||||
|
clientIp,
|
||||||
|
}))
|
||||||
.catch(e => logger.error('Error inserting metrics data', e));
|
.catch(e => logger.error('Error inserting metrics data', e));
|
||||||
|
|
||||||
res.status(202).end();
|
res.status(202).end();
|
||||||
@ -52,7 +67,8 @@ module.exports = function (app, config) {
|
|||||||
return res.status(400).json(err);
|
return res.status(400).json(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
clientStrategyStore.insert(cleaned.appName, cleaned.strategies)
|
clientStrategyStore
|
||||||
|
.insert(cleaned.appName, cleaned.strategies)
|
||||||
.then(() => clientInstanceStore.insert({
|
.then(() => clientInstanceStore.insert({
|
||||||
appName: cleaned.appName,
|
appName: cleaned.appName,
|
||||||
instanceId: cleaned.instanceId,
|
instanceId: cleaned.instanceId,
|
||||||
@ -66,12 +82,40 @@ module.exports = function (app, config) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/client/strategies', (req, res) => {
|
app.get('/client/strategies', (req, res) => {
|
||||||
clientStrategyStore.getAll().then(data => res.json(data));
|
const appName = req.query.appName;
|
||||||
|
if(appName) {
|
||||||
|
clientStrategyStore.getByAppName(appName)
|
||||||
|
.then(data => res.json(data))
|
||||||
|
.catch(err => logger.error(err));
|
||||||
|
} else {
|
||||||
|
clientStrategyStore.getAll()
|
||||||
|
.then(data => res.json(data))
|
||||||
|
.catch(err => logger.error(err));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/client/instances', (req, res) => {
|
app.get('/client/applications/', (req, res) => {
|
||||||
clientInstanceStore.getAll()
|
clientInstanceStore.getApplications()
|
||||||
.then(data => res.json(data))
|
.then(apps => {
|
||||||
|
const applications = apps.map(({appName}) => ({
|
||||||
|
appName: appName,
|
||||||
|
links: {
|
||||||
|
appDetails: `/api/client/applications/${appName}`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
res.json({applications})
|
||||||
|
})
|
||||||
|
.catch(err => logger.error(err));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/client/applications/:appName', (req, res) => {
|
||||||
|
const appName = req.params.appName;
|
||||||
|
const seenToggles = metrics.getSeenTogglesByAppName(appName);
|
||||||
|
Promise.all([
|
||||||
|
clientInstanceStore.getByAppName(appName),
|
||||||
|
clientStrategyStore.getByAppName(appName)
|
||||||
|
])
|
||||||
|
.then(([instances, strategies]) => res.json({appName, instances, strategies, seenToggles}))
|
||||||
.catch(err => logger.error(err));
|
.catch(err => logger.error(err));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -78,6 +78,13 @@ function createClientInstance (stores) {
|
|||||||
started: Date.now(),
|
started: Date.now(),
|
||||||
interval: 10,
|
interval: 10,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
appName: 'demo-seed-2',
|
||||||
|
instanceId: 'test-2',
|
||||||
|
strategies: ['default'],
|
||||||
|
started: Date.now(),
|
||||||
|
interval: 10,
|
||||||
|
},
|
||||||
].map(client => stores.clientInstanceStore.insert(client));
|
].map(client => stores.clientInstanceStore.insert(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,14 +51,27 @@ test.serial('should get client strategies', async t => {
|
|||||||
.then(destroy);
|
.then(destroy);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.serial('should get client instances', async t => {
|
test.serial('should get application details', async t => {
|
||||||
const { request, destroy } = await setupApp('metrics_serial');
|
const { request, destroy } = await setupApp('metrics_serial');
|
||||||
return request
|
return request
|
||||||
.get('/api/client/instances')
|
.get('/api/client/applications/demo-seed')
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect((res) => {
|
.expect((res) => {
|
||||||
t.true(res.status === 200);
|
t.true(res.status === 200);
|
||||||
t.true(res.body.length === 1);
|
t.true(res.body.appName === 'demo-seed');
|
||||||
|
t.true(res.body.instances.length === 1);
|
||||||
|
})
|
||||||
|
.then(destroy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.serial('should get list of applications', async t => {
|
||||||
|
const { request, destroy } = await setupApp('metrics_serial');
|
||||||
|
return request
|
||||||
|
.get('/api/client/applications')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect((res) => {
|
||||||
|
t.true(res.status === 200);
|
||||||
|
t.true(res.body.applications.length === 2);
|
||||||
})
|
})
|
||||||
.then(destroy);
|
.then(destroy);
|
||||||
});
|
});
|
||||||
|
@ -3,4 +3,5 @@
|
|||||||
module.exports = () => ({
|
module.exports = () => ({
|
||||||
getMetricsLastHour: () => Promise.resolve([]),
|
getMetricsLastHour: () => Promise.resolve([]),
|
||||||
insert: () => Promise.resolve(),
|
insert: () => Promise.resolve(),
|
||||||
|
on: () => {}
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user