diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 7d16e8f53e..af39a6df66 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -73,6 +73,7 @@ exports[`should create default config 1`] = ` "cloneEnvironment": false, "embedProxy": false, "embedProxyFrontend": false, + "fixHourMetrics": false, "publicSignup": false, "responseTimeWithAppName": false, "syncSSOGroups": false, @@ -87,6 +88,7 @@ exports[`should create default config 1`] = ` "cloneEnvironment": false, "embedProxy": false, "embedProxyFrontend": false, + "fixHourMetrics": false, "publicSignup": false, "responseTimeWithAppName": false, "syncSSOGroups": false, diff --git a/src/lib/services/client-metrics/metrics-service-v2.ts b/src/lib/services/client-metrics/metrics-service-v2.ts index c2f9b03f7d..2e52f31640 100644 --- a/src/lib/services/client-metrics/metrics-service-v2.ts +++ b/src/lib/services/client-metrics/metrics-service-v2.ts @@ -8,7 +8,11 @@ import { IClientMetricsStoreV2, } from '../../types/stores/client-metrics-store-v2'; import { clientMetricsSchema } from './schema'; -import { hoursToMilliseconds, secondsToMilliseconds } from 'date-fns'; +import { + compareAsc, + hoursToMilliseconds, + secondsToMilliseconds, +} from 'date-fns'; import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store'; import { CLIENT_METRICS } from '../../types/events'; import ApiUser from '../../types/api-user'; @@ -16,6 +20,8 @@ import { ALL } from '../../types/models/api-token'; import User from '../../types/user'; import { collapseHourlyMetrics } from '../../util/collapseHourlyMetrics'; import { LastSeenService } from './last-seen-service'; +import { generateHourBuckets } from '../../util/time-utils'; +import { IFlagResolver } from '../../types/experimental'; export default class ClientMetricsServiceV2 { private config: IUnleashConfig; @@ -30,6 +36,8 @@ export default class ClientMetricsServiceV2 { private lastSeenService: LastSeenService; + private flagResolver: IFlagResolver; + private logger: Logger; constructor( @@ -45,6 +53,7 @@ export default class ClientMetricsServiceV2 { this.clientMetricsStoreV2 = clientMetricsStoreV2; this.lastSeenService = lastSeenService; this.config = config; + this.flagResolver = config.flagResolver; this.logger = config.getLogger( '/services/client-metrics/client-metrics-service-v2.ts', ); @@ -149,13 +158,56 @@ export default class ClientMetricsServiceV2 { } async getClientMetricsForToggle( - toggleName: string, - hoursBack?: number, + featureName: string, + hoursBack: number = 24, ): Promise { - return this.clientMetricsStoreV2.getMetricsForFeatureToggle( - toggleName, - hoursBack, - ); + const metrics = + await this.clientMetricsStoreV2.getMetricsForFeatureToggle( + featureName, + hoursBack, + ); + + if (this.flagResolver.isEnabled('fixHourMetrics')) { + const hours = generateHourBuckets(hoursBack); + + const environments = [ + ...new Set(metrics.map((x) => x.environment)), + ]; + + const applications = [ + ...new Set(metrics.map((x) => x.appName)), + ].slice(0, 100); + + const result = environments.flatMap((environment) => + applications.flatMap((appName) => + hours.flatMap((hourBucket) => { + const metric = metrics.find( + (item) => + compareAsc( + hourBucket.timestamp, + item.timestamp, + ) === 0 && + item.appName === appName && + item.environment === environment, + ); + return ( + metric || { + timestamp: hourBucket.timestamp, + no: 0, + yes: 0, + appName, + environment, + featureName, + } + ); + }), + ), + ); + + return result.sort((a, b) => compareAsc(a.timestamp, b.timestamp)); + } else { + return metrics; + } } resolveMetricsEnvironment(user: User | ApiUser, data: IClientApp): string { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index a2c26b0ea7..e2975af151 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -38,6 +38,10 @@ export const defaultExperimentalOptions = { process.env.UNLEASH_EXPERIMENTAL_CLONE_ENVIRONMENT, false, ), + fixHourMetrics: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_FIX_HOUR_METRICS, + false, + ), }, externalResolver: { isEnabled: (): boolean => false }, }; diff --git a/src/lib/util/time-utils.test.ts b/src/lib/util/time-utils.test.ts new file mode 100644 index 0000000000..80765cf544 --- /dev/null +++ b/src/lib/util/time-utils.test.ts @@ -0,0 +1,7 @@ +import { generateHourBuckets } from './time-utils'; + +test('generateHourBuckets', () => { + const result = generateHourBuckets(24); + + expect(result).toHaveLength(24); +}); diff --git a/src/lib/util/time-utils.ts b/src/lib/util/time-utils.ts new file mode 100644 index 0000000000..ee1041ec94 --- /dev/null +++ b/src/lib/util/time-utils.ts @@ -0,0 +1,16 @@ +import { startOfHour, subHours } from 'date-fns'; + +export interface HourBucket { + timestamp: Date; +} + +export function generateHourBuckets(hours: number): HourBucket[] { + const start = startOfHour(new Date()); + + const result = []; + + for (let i = 0; i < hours; i++) { + result.push({ timestamp: subHours(start, i) }); + } + return result; +} diff --git a/src/server-dev.ts b/src/server-dev.ts index d94bd6b73b..7be97a81de 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -41,6 +41,7 @@ process.nextTick(async () => { syncSSOGroups: true, changeRequests: true, cloneEnvironment: true, + fixHourMetrics: true, }, }, authentication: { diff --git a/src/test/config/test-config.ts b/src/test/config/test-config.ts index 79029869e3..5c792a31d8 100644 --- a/src/test/config/test-config.ts +++ b/src/test/config/test-config.ts @@ -30,6 +30,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig { syncSSOGroups: true, changeRequests: true, cloneEnvironment: true, + fixHourMetrics: true, }, }, }; diff --git a/src/test/e2e/api/admin/client-metrics.e2e.test.ts b/src/test/e2e/api/admin/client-metrics.e2e.test.ts index 2294cc5c9f..d465e85c84 100644 --- a/src/test/e2e/api/admin/client-metrics.e2e.test.ts +++ b/src/test/e2e/api/admin/client-metrics.e2e.test.ts @@ -79,18 +79,18 @@ test('should return raw metrics, aggregated on key', async () => { .expect('Content-Type', /json/) .expect(200); - expect(demo.data).toHaveLength(2); - expect(demo.data[0].environment).toBe('default'); - expect(demo.data[0].yes).toBe(5); - expect(demo.data[0].no).toBe(4); - expect(demo.data[1].environment).toBe('test'); - expect(demo.data[1].yes).toBe(1); - expect(demo.data[1].no).toBe(3); + expect(demo.data).toHaveLength(48); + expect(demo.data[46].environment).toBe('default'); + expect(demo.data[46].yes).toBe(5); + expect(demo.data[46].no).toBe(4); + expect(demo.data[47].environment).toBe('test'); + expect(demo.data[47].yes).toBe(1); + expect(demo.data[47].no).toBe(3); - expect(t2.data).toHaveLength(1); - expect(t2.data[0].environment).toBe('default'); - expect(t2.data[0].yes).toBe(7); - expect(t2.data[0].no).toBe(104); + expect(t2.data).toHaveLength(24); + expect(t2.data[23].environment).toBe('default'); + expect(t2.data[23].yes).toBe(7); + expect(t2.data[23].no).toBe(104); }); test('should support the hoursBack query param for raw metrics', async () => { @@ -141,10 +141,10 @@ test('should support the hoursBack query param for raw metrics', async () => { const hoursTooMany = await fetchHoursBack(999); expect(hours1.data).toHaveLength(1); - expect(hours24.data).toHaveLength(2); - expect(hours48.data).toHaveLength(3); - expect(hoursTooFew.data).toHaveLength(2); - expect(hoursTooMany.data).toHaveLength(2); + expect(hours24.data).toHaveLength(24); + expect(hours48.data).toHaveLength(48); + expect(hoursTooFew.data).toHaveLength(24); + expect(hoursTooMany.data).toHaveLength(24); }); test('should return toggle summary', async () => {