diff --git a/lib/db/client-instance-store.js b/lib/db/client-instance-store.js index 950e20240a..409f72afb2 100644 --- a/lib/db/client-instance-store.js +++ b/lib/db/client-instance-store.js @@ -38,7 +38,7 @@ class ClientInstanceStore { }); const clearer = () => this._removeInstancesOlderThanTwoDays(); setTimeout(clearer, 10).unref(); - setInterval(clearer, ONE_DAY).unref(); + this.timer = setInterval(clearer, ONE_DAY).unref(); } async _removeInstancesOlderThanTwoDays() { @@ -133,6 +133,10 @@ class ClientInstanceStore { .where('app_name', appName) .del(); } + + destroy() { + clearInterval(this.timer); + } } module.exports = ClientInstanceStore; diff --git a/lib/db/client-metrics-db.js b/lib/db/client-metrics-db.js index 47a49f383b..be23dd1310 100644 --- a/lib/db/client-metrics-db.js +++ b/lib/db/client-metrics-db.js @@ -19,7 +19,7 @@ class ClientMetricsDb { // Clear old metrics regulary const clearer = () => this.removeMetricsOlderThanOneHour(); setTimeout(clearer, 10).unref(); - setInterval(clearer, ONE_MINUTE).unref(); + this.timer = setInterval(clearer, ONE_MINUTE).unref(); } async removeMetricsOlderThanOneHour() { @@ -60,6 +60,10 @@ class ClientMetricsDb { return result.map(mapRow); } + + destroy() { + clearInterval(this.timer); + } } module.exports = ClientMetricsDb; diff --git a/lib/db/client-metrics-store.js b/lib/db/client-metrics-store.js index c5ece6d17b..02fce50e70 100644 --- a/lib/db/client-metrics-store.js +++ b/lib/db/client-metrics-store.js @@ -62,11 +62,8 @@ class ClientMetricsStore extends EventEmitter { } destroy() { - try { - clearInterval(this.timer); - } catch (e) { - // empty - } + clearInterval(this.timer); + this.metricsDb.destroy(); } } diff --git a/lib/db/client-metrics-store.test.js b/lib/db/client-metrics-store.test.js index 6e3548c03c..e73ce6b011 100644 --- a/lib/db/client-metrics-store.test.js +++ b/lib/db/client-metrics-store.test.js @@ -20,6 +20,9 @@ function getMockDb() { getNewMetrics() { return Promise.resolve([list.pop() || { id: 0 }]); }, + destroy() { + // noop + }, }; } diff --git a/lib/metrics.js b/lib/metrics.js index 5fdaf79f33..ada1fed5a8 100644 --- a/lib/metrics.js +++ b/lib/metrics.js @@ -11,78 +11,106 @@ const { const THREE_HOURS = 3 * 60 * 60 * 1000; -exports.startMonitoring = ({ serverMetrics, eventBus, stores, version }) => { - if (!serverMetrics) { - return; +class MetricsMonitor { + constructor() { + this.timer = null; } - const { eventStore, clientMetricsStore, featureToggleStore } = stores; - - client.collectDefaultMetrics(); - - const requestDuration = new client.Summary({ - name: 'http_request_duration_milliseconds', - help: 'App response time', - labelNames: ['path', 'method', 'status'], - percentiles: [0.1, 0.5, 0.9, 0.99], - }); - const dbDuration = new client.Summary({ - name: 'db_query_duration_seconds', - help: 'DB query duration time', - labelNames: ['store', 'action'], - percentiles: [0.1, 0.5, 0.9, 0.99], - }); - const featureToggleUpdateTotal = new client.Counter({ - name: 'feature_toggle_update_total', - help: 'Number of times a toggle has been updated', - labelNames: ['toggle'], - }); - const featureToggleUsageTotal = new client.Counter({ - name: 'feature_toggle_usage_total', - help: 'Number of times a feature toggle has been used', - labelNames: ['toggle', 'active', 'appName'], - }); - const featureTogglesTotal = new client.Gauge({ - name: 'feature_toggles_total', - help: 'Number of feature toggles', - labelNames: ['version'], - }); - - async function collectFeatureToggleMetrics() { - featureTogglesTotal.reset(); - const togglesCount = await featureToggleStore.count(); - featureTogglesTotal.labels(version).set(togglesCount); - } - - collectFeatureToggleMetrics(); - setInterval(() => collectFeatureToggleMetrics(), THREE_HOURS); - - eventBus.on(events.REQUEST_TIME, ({ path, method, time, statusCode }) => { - requestDuration.labels(path, method, statusCode).observe(time); - }); - - eventBus.on(events.DB_TIME, ({ store, action, time }) => { - dbDuration.labels(store, action).observe(time); - }); - - eventStore.on(FEATURE_CREATED, ({ data }) => { - featureToggleUpdateTotal.labels(data.name).inc(); - }); - eventStore.on(FEATURE_UPDATED, ({ data }) => { - featureToggleUpdateTotal.labels(data.name).inc(); - }); - eventStore.on(FEATURE_ARCHIVED, ({ data }) => { - featureToggleUpdateTotal.labels(data.name).inc(); - }); - eventStore.on(FEATURE_REVIVED, ({ data }) => { - featureToggleUpdateTotal.labels(data.name).inc(); - }); - - clientMetricsStore.on('metrics', m => { - // eslint-disable-next-line no-restricted-syntax - for (const [feature, { yes, no }] of Object.entries(m.bucket.toggles)) { - featureToggleUsageTotal.labels(feature, true, m.appName).inc(yes); - featureToggleUsageTotal.labels(feature, false, m.appName).inc(no); + startMonitoring({ serverMetrics, eventBus, stores, version }) { + if (!serverMetrics) { + return; } - }); + + const { eventStore, clientMetricsStore, featureToggleStore } = stores; + + client.collectDefaultMetrics(); + + const requestDuration = new client.Summary({ + name: 'http_request_duration_milliseconds', + help: 'App response time', + labelNames: ['path', 'method', 'status'], + percentiles: [0.1, 0.5, 0.9, 0.99], + }); + const dbDuration = new client.Summary({ + name: 'db_query_duration_seconds', + help: 'DB query duration time', + labelNames: ['store', 'action'], + percentiles: [0.1, 0.5, 0.9, 0.99], + }); + const featureToggleUpdateTotal = new client.Counter({ + name: 'feature_toggle_update_total', + help: 'Number of times a toggle has been updated', + labelNames: ['toggle'], + }); + const featureToggleUsageTotal = new client.Counter({ + name: 'feature_toggle_usage_total', + help: 'Number of times a feature toggle has been used', + labelNames: ['toggle', 'active', 'appName'], + }); + const featureTogglesTotal = new client.Gauge({ + name: 'feature_toggles_total', + help: 'Number of feature toggles', + labelNames: ['version'], + }); + + async function collectFeatureToggleMetrics() { + featureTogglesTotal.reset(); + const togglesCount = await featureToggleStore.count(); + featureTogglesTotal.labels(version).set(togglesCount); + } + + collectFeatureToggleMetrics(); + this.timer = setInterval( + () => collectFeatureToggleMetrics(), + THREE_HOURS, + ).unref(); + + eventBus.on( + events.REQUEST_TIME, + ({ path, method, time, statusCode }) => { + requestDuration.labels(path, method, statusCode).observe(time); + }, + ); + + eventBus.on(events.DB_TIME, ({ store, action, time }) => { + dbDuration.labels(store, action).observe(time); + }); + + eventStore.on(FEATURE_CREATED, ({ data }) => { + featureToggleUpdateTotal.labels(data.name).inc(); + }); + eventStore.on(FEATURE_UPDATED, ({ data }) => { + featureToggleUpdateTotal.labels(data.name).inc(); + }); + eventStore.on(FEATURE_ARCHIVED, ({ data }) => { + featureToggleUpdateTotal.labels(data.name).inc(); + }); + eventStore.on(FEATURE_REVIVED, ({ data }) => { + featureToggleUpdateTotal.labels(data.name).inc(); + }); + + clientMetricsStore.on('metrics', m => { + // eslint-disable-next-line no-restricted-syntax + for (const [feature, { yes, no }] of Object.entries( + m.bucket.toggles, + )) { + featureToggleUsageTotal + .labels(feature, true, m.appName) + .inc(yes); + featureToggleUsageTotal + .labels(feature, false, m.appName) + .inc(no); + } + }); + } + + stopMonitoring() { + clearInterval(this.timer); + } +} + +module.exports = { + createMetricsMonitor() { + return new MetricsMonitor(); + }, }; diff --git a/lib/metrics.test.js b/lib/metrics.test.js index 3d5e425f08..27b0ec9427 100644 --- a/lib/metrics.test.js +++ b/lib/metrics.test.js @@ -9,7 +9,9 @@ const clientMetricsStore = new EventEmitter(); const { register: prometheusRegister } = require('prom-client'); const { REQUEST_TIME, DB_TIME } = require('./events'); const { FEATURE_UPDATED } = require('./event-type'); -const { startMonitoring } = require('./metrics'); +const { createMetricsMonitor } = require('./metrics'); + +const monitor = createMetricsMonitor(); test.before(() => { const featureToggleStore = { @@ -25,7 +27,11 @@ test.before(() => { }, version: '3.4.1', }; - startMonitoring(config); + monitor.startMonitoring(config); +}); + +test.after(() => { + monitor.stopMonitoring(); }); test('should collect metrics for requests', t => { diff --git a/lib/server-impl.js b/lib/server-impl.js index 4468a5596f..a68302c2d6 100644 --- a/lib/server-impl.js +++ b/lib/server-impl.js @@ -5,7 +5,7 @@ const { EventEmitter } = require('events'); const migrator = require('../migrator'); const getApp = require('./app'); -const { startMonitoring } = require('./metrics'); +const { createMetricsMonitor } = require('./metrics'); const { createStores } = require('./db'); const { createServices } = require('./services'); const { createOptions } = require('./options'); @@ -15,6 +15,27 @@ const AuthenticationRequired = require('./authentication-required'); const { addEventHook } = require('./event-hook'); const eventType = require('./event-type'); +async function closeServer(opts) { + const { server, metricsMonitor } = opts; + + metricsMonitor.stopMonitoring(); + + return new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }); +} + +async function destroyDatabase(stores) { + const { db, clientInstanceStore, clientMetricsStore } = stores; + + return new Promise((resolve, reject) => { + clientInstanceStore.destroy(); + clientMetricsStore.destroy(); + + db.destroy(error => (error ? reject(error) : resolve())); + }); +} + async function createApp(options) { // Database dependencies (stateful) const logger = options.getLogger('server-impl.js'); @@ -33,7 +54,8 @@ async function createApp(options) { }; const app = getApp(config); - startMonitoring(config); + const metricsMonitor = createMetricsMonitor(); + metricsMonitor.startMonitoring(config); if (typeof config.eventHook === 'function') { addEventHook(config.eventHook, stores.eventStore); @@ -61,14 +83,28 @@ async function createApp(options) { const server = app.listen(options.listen, () => logger.info('Unleash has started.', server.address()), ); + const stop = () => { + logger.info('Shutting down Unleash...'); + + return closeServer({ server, metricsMonitor }).then(() => { + return destroyDatabase(stores); + }); + }; + server.keepAliveTimeout = options.keepAliveTimeout; server.headersTimeout = options.headersTimeout; server.on('listening', () => { - resolve({ ...payload, server }); + resolve({ ...payload, server, stop }); }); server.on('error', reject); } else { - resolve({ ...payload }); + const stop = () => { + logger.info('Shutting down Unleash...'); + metricsMonitor.stopMonitoring(); + return destroyDatabase(stores); + }; + + resolve({ ...payload, stop }); } }); } diff --git a/lib/server-impl.test.js b/lib/server-impl.test.js index 98bc42a2f0..4b05bf5e8b 100644 --- a/lib/server-impl.test.js +++ b/lib/server-impl.test.js @@ -14,6 +14,8 @@ const getApp = proxyquire('./app', { }, }); +const noop = () => {}; + const eventStore = new EventEmitter(); const settingStore = { get: () => { @@ -24,13 +26,19 @@ const settingStore = { const serverImpl = proxyquire('./server-impl', { './app': getApp, './metrics': { - startMonitoring(o) { - return o; + createMetricsMonitor() { + return { + startMonitoring: noop, + stopMonitoring: noop, + }; }, }, './db': { createStores() { return { + db: { destroy: cb => cb() }, + clientInstanceStore: { destroy: noop }, + clientMetricsStore: { destroy: noop }, eventStore, settingStore, }; @@ -99,3 +107,13 @@ test('should not create a server using create()', async t => { }); t.true(typeof server === 'undefined'); }); + +test.only('should shutdown the server when calling stop()', async t => { + const { server, stop } = await serverImpl.start({ + port: 0, + getLogger, + start: true, + }); + await stop(); + t.is(server.address(), null); +});