diff --git a/src/lib/db/client-instance-store.ts b/src/lib/db/client-instance-store.ts index faa8ce4da7..e68ac8b075 100644 --- a/src/lib/db/client-instance-store.ts +++ b/src/lib/db/client-instance-store.ts @@ -205,6 +205,20 @@ export default class ClientInstanceStore implements IClientInstanceStore { return rows.map(mapRow); } + async groupApplicationsBySdk(): Promise< + { sdkVersion: string; applications: string[] }[] + > { + const rows = await this.db + .select([ + 'sdk_version as sdkVersion', + this.db.raw('ARRAY_AGG(DISTINCT app_name) as applications'), + ]) + .from(TABLE) + .groupBy('sdk_version'); + + return rows; + } + async getDistinctApplications(): Promise { const rows = await this.db .distinct('app_name') diff --git a/src/lib/features/metrics/instance/findOutdatedSdks.ts b/src/lib/features/metrics/instance/findOutdatedSdks.ts index b5769ab64b..29ab857558 100644 --- a/src/lib/features/metrics/instance/findOutdatedSdks.ts +++ b/src/lib/features/metrics/instance/findOutdatedSdks.ts @@ -14,16 +14,18 @@ const config: SDKConfig = { 'unleash-client-php': '1.13.0', }; +export const isOutdatedSdk = (sdkVersion: string) => { + const result = sdkVersion.split(':'); + if (result.length !== 2) return false; + const [sdkName, version] = result; + const minVersion = config[sdkName]; + if (!minVersion) return false; + if (semver.lt(version, minVersion)) return true; + return false; +}; + export function findOutdatedSDKs(sdkVersions: string[]): string[] { const uniqueSdkVersions = Array.from(new Set(sdkVersions)); - return uniqueSdkVersions.filter((sdkVersion) => { - const result = sdkVersion.split(':'); - if (result.length !== 2) return false; - const [sdkName, version] = result; - const minVersion = config[sdkName]; - if (!minVersion) return false; - if (semver.lt(version, minVersion)) return true; - return false; - }); + return uniqueSdkVersions.filter(isOutdatedSdk); } diff --git a/src/lib/features/metrics/instance/instance-service.ts b/src/lib/features/metrics/instance/instance-service.ts index 4ef842faf8..52938e05a5 100644 --- a/src/lib/features/metrics/instance/instance-service.ts +++ b/src/lib/features/metrics/instance/instance-service.ts @@ -22,7 +22,8 @@ import { IPrivateProjectChecker } from '../../private-project/privateProjectChec import { IFlagResolver, SYSTEM_USER } from '../../../types'; import { ALL_PROJECTS, parseStrictSemVer } from '../../../util'; import { Logger } from '../../../logger'; -import { findOutdatedSDKs } from './findOutdatedSdks'; +import { findOutdatedSDKs, isOutdatedSdk } from './findOutdatedSdks'; +import { OutdatedSdksSchema } from '../../../openapi/spec/outdated-sdks-schema'; export default class ClientInstanceService { apps = {}; @@ -261,6 +262,12 @@ export default class ClientInstanceService { return this.clientInstanceStore.removeInstancesOlderThanTwoDays(); } + async getOutdatedSdks(): Promise { + const sdkApps = await this.clientInstanceStore.groupApplicationsBySdk(); + + return sdkApps.filter((sdkApp) => isOutdatedSdk(sdkApp.sdkVersion)); + } + async usesSdkOlderThan( sdkName: string, sdkVersion: string, diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index df5ddd5a61..0fea90eee5 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -112,6 +112,7 @@ export * from './login-schema'; export * from './maintenance-schema'; export * from './me-schema'; export * from './name-schema'; +export * from './outdated-sdks-schema'; export * from './override-schema'; export * from './parameters-schema'; export * from './parent-feature-options-schema'; diff --git a/src/lib/openapi/spec/outdated-sdks-schema.ts b/src/lib/openapi/spec/outdated-sdks-schema.ts new file mode 100644 index 0000000000..63e1a27567 --- /dev/null +++ b/src/lib/openapi/spec/outdated-sdks-schema.ts @@ -0,0 +1,41 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const outdatedSdksSchema = { + $id: '#/components/schemas/outdatedSdksSchema', + type: 'object', + description: 'Data about outdated SDKs that should be upgraded.', + additionalProperties: false, + required: ['sdks'], + properties: { + sdks: { + type: 'array', + description: 'A list of SDKs', + items: { + type: 'object', + required: ['sdkVersion', 'applications'], + additionalProperties: false, + properties: { + sdkVersion: { + type: 'string', + description: + 'An outdated SDK version identifier. Usually formatted as "unleash-client-:"', + example: 'unleash-client-java:7.0.0', + }, + applications: { + type: 'array', + items: { + description: 'Name of the application', + type: 'string', + example: 'accounting', + }, + description: + 'A list of applications using the SDK version', + }, + }, + }, + }, + }, + components: {}, +} as const; + +export type OutdatedSdksSchema = FromSchema; diff --git a/src/lib/routes/admin-api/metrics.ts b/src/lib/routes/admin-api/metrics.ts index adc3430aae..a7bf1ed4f9 100644 --- a/src/lib/routes/admin-api/metrics.ts +++ b/src/lib/routes/admin-api/metrics.ts @@ -29,6 +29,10 @@ import { applicationEnvironmentInstancesSchema, ApplicationEnvironmentInstancesSchema, } from '../../openapi/spec/application-environment-instances-schema'; +import { + outdatedSdksSchema, + OutdatedSdksSchema, +} from '../../openapi/spec/outdated-sdks-schema'; class MetricsController extends Controller { private logger: Logger; @@ -177,6 +181,25 @@ class MetricsController extends Controller { }), ], }); + this.route({ + method: 'get', + path: '/sdks/outdated', + handler: this.getOutdatedSdks, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Unstable'], + operationId: 'getOutdatedSdks', + summary: 'Get outdated SDKs', + description: + 'Returns a list of the outdated SDKS with the applications using them.', + responses: { + 200: createResponseSchema('outdatedSdksSchema'), + ...getStandardResponses(404), + }, + }), + ], + }); } async deprecated(req: Request, res: Response): Promise { @@ -277,6 +300,20 @@ class MetricsController extends Controller { ); } + async getOutdatedSdks(req: Request, res: Response) { + if (!this.flagResolver.isEnabled('sdkReporting')) { + throw new NotFoundError(); + } + const outdatedSdks = await this.clientInstanceService.getOutdatedSdks(); + + this.openApiService.respondWithValidation( + 200, + res, + outdatedSdksSchema.$id, + { sdks: outdatedSdks }, + ); + } + async getApplicationEnvironmentInstances( req: Request<{ appName: string; environment: string }>, res: Response, diff --git a/src/lib/types/stores/client-instance-store.ts b/src/lib/types/stores/client-instance-store.ts index 62fb5935e2..274ad1e8ff 100644 --- a/src/lib/types/stores/client-instance-store.ts +++ b/src/lib/types/stores/client-instance-store.ts @@ -26,6 +26,9 @@ export interface IClientInstanceStore environment: string, ): Promise; getBySdkName(sdkName: string): Promise; + groupApplicationsBySdk(): Promise< + { sdkVersion: string; applications: string[] }[] + >; getDistinctApplications(): Promise; getDistinctApplicationsCount(daysBefore?: number): Promise; deleteForApplication(appName: string): Promise; diff --git a/src/test/e2e/api/admin/applications.e2e.test.ts b/src/test/e2e/api/admin/applications.e2e.test.ts index fa207c6d13..570964c893 100644 --- a/src/test/e2e/api/admin/applications.e2e.test.ts +++ b/src/test/e2e/api/admin/applications.e2e.test.ts @@ -85,7 +85,7 @@ afterAll(async () => { await db.destroy(); }); -test('should show correct number of total', async () => { +test('should show correct application metrics', async () => { await Promise.all([ app.createFeature('toggle-name-1'), app.createFeature('toggle-name-2'), @@ -94,7 +94,7 @@ test('should show correct number of total', async () => { appName: metrics.appName, instanceId: metrics.instanceId, strategies: ['default'], - sdkVersion: 'unleash-client-test', + sdkVersion: 'unleash-client-node:3.2.1', started: Date.now(), interval: 10, }), @@ -102,7 +102,7 @@ test('should show correct number of total', async () => { appName: metrics.appName, instanceId: 'another-instance', strategies: ['default'], - sdkVersion: 'unleash-client-test2', + sdkVersion: 'unleash-client-node:3.2.2', started: Date.now(), interval: 10, }), @@ -129,7 +129,10 @@ test('should show correct number of total', async () => { { instanceCount: 2, name: 'default', - sdks: ['unleash-client-test', 'unleash-client-test2'], + sdks: [ + 'unleash-client-node:3.2.1', + 'unleash-client-node:3.2.2', + ], }, ], featureCount: 3, @@ -143,12 +146,31 @@ test('should show correct number of total', async () => { ) .expect(200); - expect(instancesBody).toMatchObject({ - instances: [ - { instanceId: 'instanceId', sdkVersion: 'unleash-client-test' }, + expect( + instancesBody.instances.sort((a, b) => + a.instanceId.localeCompare(b.instanceId), + ), + ).toMatchObject([ + { + instanceId: 'another-instance', + sdkVersion: 'unleash-client-node:3.2.2', + }, + { instanceId: 'instanceId', sdkVersion: 'unleash-client-node:3.2.1' }, + ]); + + const { body: outdatedSdks } = await app.request + .get(`/api/admin/metrics/sdks/outdated`) + .expect(200); + + expect(outdatedSdks).toMatchObject({ + sdks: [ { - instanceId: 'another-instance', - sdkVersion: 'unleash-client-test2', + sdkVersion: 'unleash-client-node:3.2.1', + applications: ['appName'], + }, + { + sdkVersion: 'unleash-client-node:3.2.2', + applications: ['appName'], }, ], }); diff --git a/src/test/fixtures/fake-client-instance-store.ts b/src/test/fixtures/fake-client-instance-store.ts index ca27317c11..a24f2c8bbf 100644 --- a/src/test/fixtures/fake-client-instance-store.ts +++ b/src/test/fixtures/fake-client-instance-store.ts @@ -4,6 +4,7 @@ import { INewClientInstance, } from '../../lib/types/stores/client-instance-store'; import NotFoundError from '../../lib/error/notfound-error'; +import groupBy from 'lodash.groupby'; export default class FakeClientInstanceStore implements IClientInstanceStore { instances: IClientInstance[] = []; @@ -32,10 +33,19 @@ export default class FakeClientInstanceStore implements IClientInstanceStore { } async getBySdkName(sdkName: string): Promise { - return Promise.resolve( - this.instances.filter((instance) => - instance.sdkVersion?.startsWith(sdkName), - ), + return this.instances.filter((instance) => + instance.sdkVersion?.startsWith(sdkName), + ); + } + + async groupApplicationsBySdk(): Promise< + { sdkVersion: string; applications: string[] }[] + > { + return Object.entries(groupBy(this.instances, 'sdkVersion')).map( + ([sdkVersion, apps]) => ({ + sdkVersion, + applications: apps.map((item) => item.appName), + }), ); }