mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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 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,50 +109,65 @@ test('addPayload', t => {
 | 
			
		||||
    metrics.destroy();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('addBucket', t => {
 | 
			
		||||
    const metrics = new UnleashClientMetrics();
 | 
			
		||||
    metrics.addClient(appName, instanceId);
 | 
			
		||||
    metrics.addBucket(appName, instanceId, {
 | 
			
		||||
        start: new Date(),
 | 
			
		||||
        stop: new Date(),
 | 
			
		||||
        toggles: {
 | 
			
		||||
            toggleX: {
 | 
			
		||||
                yes: 123,
 | 
			
		||||
                no: 0,
 | 
			
		||||
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: {
 | 
			
		||||
                toggleX: {
 | 
			
		||||
                    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);
 | 
			
		||||
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();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
 | 
			
		||||
    metrics.destroy();
 | 
			
		||||
});
 | 
			
		||||
});
 | 
			
		||||
@ -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);
 | 
			
		||||
        this.globalCount += count;
 | 
			
		||||
        app.count  += count;
 | 
			
		||||
        this.addSeenToggles(app, toggleNames);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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 () {
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // 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'));
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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';
 | 
			
		||||
 | 
			
		||||
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) {}
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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)
 | 
			
		||||
            .map(mapRow);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getByAppName (appName) {
 | 
			
		||||
        return this.db
 | 
			
		||||
            .select(COLUMNS)
 | 
			
		||||
            .where('app_name', appName)
 | 
			
		||||
            .from(TABLE)
 | 
			
		||||
            .map(mapRow);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = ClientStrategyStore;
 | 
			
		||||
 | 
			
		||||
@ -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),
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
    service.on('metrics', (entries) => {
 | 
			
		||||
        entries.forEach((m) => {
 | 
			
		||||
            metrics.addPayload(m.metrics);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    app.get('/metrics', (req, res) => {
 | 
			
		||||
        res.json(metrics.getMetricsOverview());
 | 
			
		||||
    const metrics = new ClientMetrics(clientMetricsStore);
 | 
			
		||||
    
 | 
			
		||||
    /**
 | 
			
		||||
     * [{
 | 
			
		||||
            "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));
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -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));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -3,4 +3,5 @@
 | 
			
		||||
module.exports = () => ({
 | 
			
		||||
    getMetricsLastHour: () => Promise.resolve([]),
 | 
			
		||||
    insert: () => Promise.resolve(),
 | 
			
		||||
    on: () => {}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user