import EventEmitter from 'events'; import ClientMetricsService from './index'; import getLogger from '../../../test/fixtures/no-logger'; import { IClientApp } from '../../types/model'; import { addHours, addMinutes, hoursToMilliseconds, minutesToMilliseconds, secondsToMilliseconds, subHours, subMinutes, subSeconds, } from 'date-fns'; /** * A utility to wait for any pending promises in the test subject code. * For instance, if the test needs to wait for a timeout/interval handler, * and that handler does something async, advancing the timers is not enough: * We have to explicitly wait for the second promise. * For more info, see https://stackoverflow.com/a/51045733/2868829 * * Usage in test code after advancing timers, but before making assertions: * * test('hello', async () => { * jest.useFakeTimers('modern'); * * // Schedule a timeout with a callback that does something async * // before calling our spy * const spy = jest.fn(); * setTimeout(async () => { * await Promise.resolve(); * spy(); * }, 1000); * * expect(spy).not.toHaveBeenCalled(); * * jest.advanceTimersByTime(1500); * await flushPromises(); // this is required to make it work! * * expect(spy).toHaveBeenCalledTimes(1); * * jest.useRealTimers(); * }); */ function flushPromises() { return Promise.resolve(setImmediate); } const appName = 'appName'; const instanceId = 'instanceId'; const createMetricsService = (cms) => new ClientMetricsService( { clientMetricsStore: cms, strategyStore: null, featureToggleStore: null, clientApplicationsStore: null, clientInstanceStore: null, eventStore: null, }, { getLogger }, ); test('should work without state', () => { const clientMetricsStore = new EventEmitter(); const metrics = createMetricsService(clientMetricsStore); expect(metrics.getAppsWithToggles()).toBeTruthy(); expect(metrics.getTogglesMetrics()).toBeTruthy(); metrics.destroy(); }); test('data should expire', () => { jest.useFakeTimers('modern'); const clientMetricsStore = new EventEmitter(); const metrics = createMetricsService(clientMetricsStore); metrics.addPayload({ appName, instanceId, bucket: { start: subSeconds(Date.now(), 2), stop: subSeconds(Date.now(), 1), toggles: { toggleX: { yes: 123, no: 0, }, }, }, }); let lastHourExpires = 0; metrics.lastHourList.on('expire', () => { lastHourExpires++; }); let lastMinExpires = 0; metrics.lastMinuteList.on('expire', () => { lastMinExpires++; }); jest.advanceTimersByTime(minutesToMilliseconds(1)); expect(lastMinExpires).toBe(1); expect(lastHourExpires).toBe(0); jest.advanceTimersByTime(hoursToMilliseconds(1)); expect(lastMinExpires).toBe(1); expect(lastHourExpires).toBe(1); jest.useRealTimers(); metrics.destroy(); }); test('should listen to metrics from store', () => { const clientMetricsStore = new EventEmitter(); const metrics = createMetricsService(clientMetricsStore); clientMetricsStore.emit('metrics', { appName, instanceId, bucket: { start: new Date(), stop: new Date(), toggles: { toggleX: { yes: 123, no: 0, }, }, }, }); expect(metrics.apps[appName].count).toBe(123); expect(metrics.globalCount).toBe(123); expect(metrics.getTogglesMetrics().lastHour.toggleX).toEqual({ yes: 123, no: 0, }); expect(metrics.getTogglesMetrics().lastMinute.toggleX).toEqual({ yes: 123, no: 0, }); metrics.addPayload({ appName, instanceId, bucket: { start: new Date(), stop: new Date(), toggles: { toggleX: { yes: 10, no: 10, }, }, }, }); expect(metrics.globalCount).toBe(143); expect(metrics.getTogglesMetrics().lastHour.toggleX).toEqual({ yes: 133, no: 10, }); expect(metrics.getTogglesMetrics().lastMinute.toggleX).toEqual({ yes: 133, no: 10, }); metrics.destroy(); }); test('should build up list of seen toggles when new metrics arrives', () => { const clientMetricsStore = new EventEmitter(); const metrics = createMetricsService(clientMetricsStore); clientMetricsStore.emit('metrics', { appName, instanceId, bucket: { start: new Date(), stop: new Date(), toggles: { toggleX: { yes: 123, no: 0, }, toggleY: { yes: 50, no: 50, }, }, }, }); const appToggles = metrics.getAppsWithToggles(); const togglesForApp = metrics.getSeenTogglesByAppName(appName); expect(appToggles.length).toBe(1); expect(appToggles[0].seenToggles.length).toBe(2); expect(appToggles[0].seenToggles).toContain('toggleX'); expect(appToggles[0].seenToggles).toContain('toggleY'); expect(togglesForApp.length === 2); expect(togglesForApp).toContain('toggleX'); expect(togglesForApp).toContain('toggleY'); metrics.destroy(); }); test('should handle a lot of toggles', () => { const clientMetricsStore = new EventEmitter(); const metrics = createMetricsService(clientMetricsStore); const toggleCounts = {}; for (let i = 0; i < 100; i++) { toggleCounts[`toggle${i}`] = { yes: i, no: i }; } clientMetricsStore.emit('metrics', { appName, instanceId, bucket: { start: new Date(), stop: new Date(), toggles: toggleCounts, }, }); const seenToggles = metrics.getSeenTogglesByAppName(appName); expect(seenToggles.length).toBe(100); metrics.destroy(); }); test('should have correct values for lastMinute', () => { jest.useFakeTimers('modern'); const clientMetricsStore = new EventEmitter(); const metrics = createMetricsService(clientMetricsStore); const now = new Date(); const input = [ { start: subHours(now, 1), stop: subMinutes(now, 59), toggles: { toggle: { yes: 10, no: 10 }, }, }, { start: subMinutes(now, 30), stop: subMinutes(now, 29), toggles: { toggle: { yes: 10, no: 10 }, }, }, { start: subMinutes(now, 2), stop: subMinutes(now, 1), toggles: { toggle: { yes: 10, no: 10 }, }, }, { start: subMinutes(now, 2), stop: subSeconds(now, 59), toggles: { toggle: { yes: 10, no: 10 }, }, }, { start: now, stop: subSeconds(now, 30), toggles: { toggle: { yes: 10, no: 10 }, }, }, ]; input.forEach((bucket) => { clientMetricsStore.emit('metrics', { appName, instanceId, bucket, }); }); const seenToggles = metrics.getSeenTogglesByAppName(appName); expect(seenToggles.length).toBe(1); // metrics.se let c = metrics.getTogglesMetrics(); expect(c.lastMinute.toggle).toEqual({ yes: 20, no: 20 }); jest.advanceTimersByTime(10_000); c = metrics.getTogglesMetrics(); expect(c.lastMinute.toggle).toEqual({ yes: 10, no: 10 }); jest.advanceTimersByTime(20_000); c = metrics.getTogglesMetrics(); expect(c.lastMinute.toggle).toEqual({ yes: 0, no: 0 }); metrics.destroy(); jest.useRealTimers(); }); test('should have correct values for lastHour', () => { jest.useFakeTimers('modern'); const clientMetricsStore = new EventEmitter(); const metrics = createMetricsService(clientMetricsStore); const now = Date.now(); const input = [ { start: subHours(now, 1), stop: subMinutes(now, 59), toggles: { toggle: { yes: 10, no: 10 }, }, }, { start: subMinutes(now, 30), stop: subMinutes(now, 29), toggles: { toggle: { yes: 10, no: 10 }, }, }, { start: subMinutes(now, 15), stop: subMinutes(now, 14), toggles: { toggle: { yes: 10, no: 10 }, }, }, { start: addMinutes(now, 59), stop: addHours(now, 1), toggles: { toggle: { yes: 11, no: 11 }, }, }, ]; input.forEach((bucket) => { clientMetricsStore.emit('metrics', { appName, instanceId, bucket, }); }); const seenToggles = metrics.getSeenTogglesByAppName(appName); expect(seenToggles.length).toBe(1); // metrics.se let c = metrics.getTogglesMetrics(); expect(c.lastHour.toggle).toEqual({ yes: 41, no: 41 }); jest.advanceTimersByTime(10_000); c = metrics.getTogglesMetrics(); expect(c.lastHour.toggle).toEqual({ yes: 41, no: 41 }); // at 30 jest.advanceTimersByTime(minutesToMilliseconds(30)); c = metrics.getTogglesMetrics(); expect(c.lastHour.toggle).toEqual({ yes: 31, no: 31 }); // at 45 jest.advanceTimersByTime(minutesToMilliseconds(15)); c = metrics.getTogglesMetrics(); expect(c.lastHour.toggle).toEqual({ yes: 21, no: 21 }); // at 1:15 jest.advanceTimersByTime(minutesToMilliseconds(30)); c = metrics.getTogglesMetrics(); expect(c.lastHour.toggle).toEqual({ yes: 11, no: 11 }); // at 2:00 jest.advanceTimersByTime(minutesToMilliseconds(45)); c = metrics.getTogglesMetrics(); expect(c.lastHour.toggle).toEqual({ yes: 0, no: 0 }); metrics.destroy(); jest.useRealTimers(); }); test('should not fail when toggle metrics is missing yes/no field', () => { const clientMetricsStore = new EventEmitter(); const metrics = createMetricsService(clientMetricsStore); clientMetricsStore.emit('metrics', { appName, instanceId, bucket: { start: new Date(), stop: new Date(), toggles: { toggleX: { yes: 123, no: 0, }, }, }, }); metrics.addPayload({ appName, instanceId, bucket: { start: new Date(), stop: new Date(), toggles: { toggleX: { blue: 10, green: 10, }, }, }, }); expect(metrics.globalCount).toBe(123); expect(metrics.getTogglesMetrics().lastMinute.toggleX).toEqual({ yes: 123, no: 0, }); metrics.destroy(); }); test('Multiple registrations of same appname and instanceid within same time period should only cause one registration', async () => { jest.useFakeTimers('modern'); const clientMetricsStore: any = new EventEmitter(); const appStoreSpy = jest.fn(); const bulkSpy = jest.fn(); const clientApplicationsStore: any = { bulkUpsert: appStoreSpy, }; const clientInstanceStore: any = { bulkUpsert: bulkSpy, }; const clientMetrics = new ClientMetricsService( { clientMetricsStore, strategyStore: null, featureToggleStore: null, clientApplicationsStore, clientInstanceStore, eventStore: null, }, { getLogger }, ); const client1: IClientApp = { appName: 'test_app', instanceId: 'ava', strategies: [{ name: 'defaullt' }], started: new Date(), interval: 10, }; await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); jest.advanceTimersByTime(7000); await flushPromises(); expect(appStoreSpy).toHaveBeenCalledTimes(1); expect(bulkSpy).toHaveBeenCalledTimes(1); const registrations = appStoreSpy.mock.calls[0][0]; expect(registrations.length).toBe(1); expect(registrations[0].appName).toBe(client1.appName); expect(registrations[0].instanceId).toBe(client1.instanceId); expect(registrations[0].started).toBe(client1.started); expect(registrations[0].interval).toBe(client1.interval); jest.useRealTimers(); }); test('Multiple unique clients causes multiple registrations', async () => { jest.useFakeTimers('modern'); const clientMetricsStore: any = new EventEmitter(); const appStoreSpy = jest.fn(); const bulkSpy = jest.fn(); const clientApplicationsStore: any = { bulkUpsert: appStoreSpy, }; const clientInstanceStore: any = { bulkUpsert: bulkSpy, }; const clientMetrics = new ClientMetricsService( { clientMetricsStore, strategyStore: null, featureToggleStore: null, clientApplicationsStore, clientInstanceStore, eventStore: null, }, { getLogger }, ); const client1 = { appName: 'test_app', instanceId: 'client1', strategies: [{ name: 'defaullt' }], started: new Date(), interval: 10, }; const client2 = { appName: 'test_app_2', instanceId: 'client2', strategies: [{ name: 'defaullt' }], started: new Date(), interval: 10, }; await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client2, '127.0.0.1'); await clientMetrics.registerClient(client2, '127.0.0.1'); await clientMetrics.registerClient(client2, '127.0.0.1'); jest.advanceTimersByTime(7000); await flushPromises(); const registrations = appStoreSpy.mock.calls[0][0]; expect(registrations.length).toBe(2); jest.useRealTimers(); }); test('Same client registered outside of dedup interval will be registered twice', async () => { jest.useFakeTimers('modern'); const clientMetricsStore: any = new EventEmitter(); const appStoreSpy = jest.fn(); const bulkSpy = jest.fn(); const clientApplicationsStore: any = { bulkUpsert: appStoreSpy, }; const clientInstanceStore: any = { bulkUpsert: bulkSpy, }; const bulkInterval = secondsToMilliseconds(2); const clientMetrics = new ClientMetricsService( { clientMetricsStore, strategyStore: null, featureToggleStore: null, clientApplicationsStore, clientInstanceStore, eventStore: null, }, { getLogger }, bulkInterval, ); const client1 = { appName: 'test_app', instanceId: 'client1', strategies: [{ name: 'defaullt' }], started: new Date(), interval: 10, }; await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); jest.advanceTimersByTime(3000); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); await clientMetrics.registerClient(client1, '127.0.0.1'); jest.advanceTimersByTime(3000); await flushPromises(); expect(appStoreSpy).toHaveBeenCalledTimes(2); expect(bulkSpy).toHaveBeenCalledTimes(2); const firstRegistrations = appStoreSpy.mock.calls[0][0][0]; const secondRegistrations = appStoreSpy.mock.calls[1][0][0]; expect(firstRegistrations.appName).toBe(secondRegistrations.appName); expect(firstRegistrations.instanceId).toBe(secondRegistrations.instanceId); jest.useRealTimers(); }); test('No registrations during a time period will not call stores', async () => { jest.useFakeTimers('modern'); const clientMetricsStore: any = new EventEmitter(); const appStoreSpy = jest.fn(); const bulkSpy = jest.fn(); const clientApplicationsStore: any = { bulkUpsert: appStoreSpy, }; const clientInstanceStore: any = { bulkUpsert: bulkSpy, }; new ClientMetricsService( { clientMetricsStore, strategyStore: null, featureToggleStore: null, clientApplicationsStore, clientInstanceStore, eventStore: null, }, { getLogger }, ); jest.advanceTimersByTime(6000); expect(appStoreSpy).toHaveBeenCalledTimes(0); expect(bulkSpy).toHaveBeenCalledTimes(0); jest.useRealTimers(); });