1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-31 00:16:47 +01:00
sveisvei 2016-11-28 17:11:11 +01:00 committed by Ivar Conradi Østhus
parent 13a93dcf43
commit e55378e1c4
15 changed files with 380 additions and 275 deletions

View File

@ -4,15 +4,17 @@ const { test } = require('ava');
const UnleashClientMetrics = require('./index');
const sinon = require('sinon');
const { EventEmitter } = require('events');
const appName = 'appName';
const instanceId = 'instanceId';
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.toJSON());
metrics.destroy();
});
@ -20,7 +22,8 @@ test('should work without state', (t) => {
test.cb('data should expire', (t) => {
const clock = sinon.useFakeTimers();
const metrics = new UnleashClientMetrics();
const store = new EventEmitter();
const metrics = new UnleashClientMetrics(store);
metrics.addPayload({
appName,
@ -59,9 +62,10 @@ test.cb('data should expire', (t) => {
t.end();
});
test('addPayload', t => {
const metrics = new UnleashClientMetrics();
metrics.addPayload({
test('should listen to metrics from store', t => {
const store = new EventEmitter();
const metrics = new UnleashClientMetrics(store);
store.emit('metrics', {
appName,
instanceId,
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.globalCount === 123);
@ -100,7 +102,6 @@ test('addPayload', t => {
},
});
t.truthy(metrics.clients[instanceId].count === 143);
t.truthy(metrics.globalCount === 143);
t.deepEqual(metrics.getTogglesMetrics().lastHour.toggleX, { yes: 133, no: 10 });
t.deepEqual(metrics.getTogglesMetrics().lastMinute.toggleX, { yes: 133, no: 10 });
@ -108,10 +109,13 @@ test('addPayload', t => {
metrics.destroy();
});
test('addBucket', t => {
const metrics = new UnleashClientMetrics();
metrics.addClient(appName, instanceId);
metrics.addBucket(appName, instanceId, {
test('should build up list of seend toggles when new metrics arrives', t => {
const store = new EventEmitter();
const metrics = new UnleashClientMetrics(store);
store.emit('metrics', {
appName,
instanceId,
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
@ -119,39 +123,51 @@ test('addBucket', t => {
yes: 123,
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();
});
test('addClient', t => {
const metrics = new UnleashClientMetrics();
metrics.addClient(appName, instanceId);
metrics.addClient(appName, instanceId, new Date());
t.truthy(metrics.clients[instanceId].count === 0);
t.truthy(metrics.globalCount === 0);
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);
test('should handle a lot of toggles', t => {
const store = new EventEmitter();
const metrics = new UnleashClientMetrics(store);
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();
});

View File

@ -4,10 +4,10 @@ const Projection = require('./projection.js');
const TTLList = require('./ttl-list.js');
module.exports = class UnleashClientMetrics {
constructor () {
constructor (clientMetricsStore) {
this.globalCount = 0;
this.apps = {};
this.clients = {};
this.lastHourProjection = new Projection();
this.lastMinuteProjection = new Projection();
@ -15,6 +15,7 @@ module.exports = class UnleashClientMetrics {
this.lastHourList = new TTLList({
interval: 10000,
});
this.lastMinuteList = new TTLList({
interval: 10000,
expireType: 'minutes',
@ -31,18 +32,20 @@ module.exports = class UnleashClientMetrics {
this.lastMinuteProjection.substract(toggleName, toggles[toggleName]);
});
});
clientMetricsStore.on('metrics', (m) => this.addPayload(m));
}
toJSON () {
return JSON.stringify(this.getMetricsOverview(), null, 4);
getAppsWitToggles () {
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;
}
getMetricsOverview () {
return {
globalCount: this.globalCount,
apps: this.apps,
clients: this.clients,
};
getSeenTogglesByAppName(appName) {
return this.apps[appName] ? Object.keys(this.apps[appName].seenToggles) : [];
}
getTogglesMetrics () {
@ -53,16 +56,24 @@ module.exports = class UnleashClientMetrics {
}
addPayload (data) {
this.addClient(data.appName, data.instanceId);
this.addBucket(data.appName, data.instanceId, data.bucket);
const { appName, bucket } = data;
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;
// TODO stop should be createdAt
const { stop, toggles } = bucket;
Object.keys(toggles).forEach((n) => {
const toggleNames = Object.keys(toggles);
toggleNames.forEach((n) => {
const entry = toggles[n];
this.lastHourProjection.add(n, entry);
this.lastMinuteProjection.add(n, entry);
@ -72,49 +83,13 @@ module.exports = class UnleashClientMetrics {
this.lastHourList.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;
if (this.clients[instanceId]) {
this.clients[instanceId].count += count;
}
if (this.apps[appName]) {
this.apps[appName].count += count;
}
}
app.count += count;
this.addSeenToggles(app, toggleNames);
}
addClient (appName, instanceId, started = new Date()) {
this.addApp(appName, instanceId);
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);
}
addSeenToggles (app, toggleNames) {
toggleNames.forEach(t => app.seenToggles[t] = true);
}
destroy () {

View File

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

View File

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

View File

@ -27,10 +27,7 @@ test.cb('should slice off list', (t) => {
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: '2' }, moment().add(50, 'milliseconds'));
list.add({ n: '3' }, moment().add(200, 'milliseconds'));

View File

@ -1,6 +1,7 @@
/* eslint camelcase: "off" */
'use strict';
const logger = require('../logger');
const COLUMNS = ['app_name', 'instance_id', 'client_ip', 'last_seen', 'created_at'];
const TABLE = 'client_instances';
@ -12,10 +13,24 @@ const mapRow = (row) => ({
createdAt: row.created_at,
});
const mapAppsRow = (row) => ({
appName: row.app_name,
createdAt: row.created_at,
});
class ClientInstanceStore {
constructor (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) {
@ -58,6 +73,24 @@ class ClientInstanceStore {
.orderBy('last_seen', 'desc')
.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;

View 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;

View File

@ -1,55 +1,53 @@
'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,
});
const { EventEmitter } = require('events');
class ClientMetricsStore {
const TEN_SECONDS = 10 * 1000;
constructor (db) {
this.db = db;
setTimeout(() => this._removeMetricsOlderThanOneHour(), 10).unref();
setInterval(() => this._removeMetricsOlderThanOneHour(), 60 * 60 * 1000).unref();
class ClientMetricsStore extends EventEmitter {
constructor (metricsDb, pollInterval = TEN_SECONDS) {
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 () {
this.db(TABLE)
.whereRaw('created_at < now() - interval \'1 hour\'')
.del()
.then((res) => logger.info(`Deleted ${res} metrics`));
_startPoller (pollInterval) {
this.timer = setInterval(() => this._fetchNewAndEmit(), pollInterval);
this.timer.unref();
}
_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 (metrics) {
return this.db(TABLE).insert({ metrics });
return this.metricsDb.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);
destroy () {
try {
clearInterval(this.timer);
} catch (e) {}
}
};

View 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();
});
});
});

View File

@ -49,6 +49,14 @@ class ClientStrategyStore {
.from(TABLE)
.map(mapRow);
}
getByAppName (appName) {
return this.db
.select(COLUMNS)
.where('app_name', appName)
.from(TABLE)
.map(mapRow);
}
};
module.exports = ClientStrategyStore;

View File

@ -5,12 +5,14 @@ const EventStore = require('./event-store');
const FeatureToggleStore = require('./feature-toggle-store');
const StrategyStore = require('./strategy-store');
const ClientInstanceStore = require('./client-instance-store');
const ClientMetricsDb = require('./client-metrics-db');
const ClientMetricsStore = require('./client-metrics-store');
const ClientStrategyStore = require('./client-strategy-store');
module.exports.createStores = (config) => {
const db = createDb(config);
const eventStore = new EventStore(db);
const clientMetricsDb = new ClientMetricsDb(db);
return {
db,
@ -18,7 +20,7 @@ module.exports.createStores = (config) => {
featureToggleStore: new FeatureToggleStore(db, eventStore),
strategyStore: new StrategyStore(db, eventStore),
clientInstanceStore: new ClientInstanceStore(db),
clientMetricsStore: new ClientMetricsStore(db),
clientMetricsStore: new ClientMetricsStore(clientMetricsDb),
clientStrategyStore: new ClientStrategyStore(db),
};
};

View File

@ -2,10 +2,16 @@
const logger = require('../logger');
const ClientMetrics = require('../client-metrics');
const ClientMetricsService = require('../client-metrics/service');
const joi = require('joi');
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) {
const {
clientMetricsStore,
@ -13,17 +19,17 @@ module.exports = function (app, config) {
clientInstanceStore,
} = config.stores;
const metrics = new ClientMetrics();
const service = new ClientMetricsService(clientMetricsStore);
const metrics = new ClientMetrics(clientMetricsStore);
service.on('metrics', (entries) => {
entries.forEach((m) => {
metrics.addPayload(m.metrics);
});
});
app.get('/metrics', (req, res) => {
res.json(metrics.getMetricsOverview());
/**
* [{
"appName": "mfinn",
"toggles": ["toggle1", "toggle2"],
}]
*/
app.get('/client/seen-toggles', (req, res) => {
const seenAppToggles = metrics.getAppsWitToggles();
res.json(seenAppToggles);
});
app.get('/metrics/features', (req, res) => {
@ -32,11 +38,20 @@ module.exports = function (app, config) {
app.post('/client/metrics', (req, res) => {
const data = req.body;
const clientIp = req.ip;
joi.validate(data, clientMetricsSchema, (err, cleaned) => {
if (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));
res.status(202).end();
@ -52,7 +67,8 @@ module.exports = function (app, config) {
return res.status(400).json(err);
}
clientStrategyStore.insert(cleaned.appName, cleaned.strategies)
clientStrategyStore
.insert(cleaned.appName, cleaned.strategies)
.then(() => clientInstanceStore.insert({
appName: cleaned.appName,
instanceId: cleaned.instanceId,
@ -66,12 +82,40 @@ module.exports = function (app, config) {
});
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) => {
clientInstanceStore.getAll()
.then(data => res.json(data))
app.get('/client/applications/', (req, res) => {
clientInstanceStore.getApplications()
.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));
});
};

View File

@ -78,6 +78,13 @@ function createClientInstance (stores) {
started: Date.now(),
interval: 10,
},
{
appName: 'demo-seed-2',
instanceId: 'test-2',
strategies: ['default'],
started: Date.now(),
interval: 10,
},
].map(client => stores.clientInstanceStore.insert(client));
}

View File

@ -51,14 +51,27 @@ test.serial('should get client strategies', async t => {
.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');
return request
.get('/api/client/instances')
.get('/api/client/applications/demo-seed')
.expect('Content-Type', /json/)
.expect((res) => {
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);
});

View File

@ -3,4 +3,5 @@
module.exports = () => ({
getMetricsLastHour: () => Promise.resolve([]),
insert: () => Promise.resolve(),
on: () => {}
});