From 91c08593a667157518f30feaf1a85538fa039b31 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 26 Feb 2024 14:27:44 +0100 Subject: [PATCH] feat: app env instances api (#6339) --- src/lib/db/client-instance-store.ts | 15 ++++++ .../metrics/instance/instance-service.ts | 18 +++++++ src/lib/openapi/index.ts | 2 + ...pplication-environment-instances-schema.ts | 53 +++++++++++++++++++ src/lib/openapi/spec/index.ts | 1 + src/lib/routes/admin-api/metrics.ts | 51 +++++++++++++++++- src/lib/types/stores/client-instance-store.ts | 4 ++ .../e2e/api/admin/applications.e2e.test.ts | 14 +++++ .../fixtures/fake-client-instance-store.ts | 9 ++++ 9 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/lib/openapi/spec/application-environment-instances-schema.ts diff --git a/src/lib/db/client-instance-store.ts b/src/lib/db/client-instance-store.ts index 01cd271fce..faa8ce4da7 100644 --- a/src/lib/db/client-instance-store.ts +++ b/src/lib/db/client-instance-store.ts @@ -180,6 +180,21 @@ export default class ClientInstanceStore implements IClientInstanceStore { return rows.map(mapRow); } + async getByAppNameAndEnvironment( + appName: string, + environment: string, + ): Promise { + const rows = await this.db + .select() + .from(TABLE) + .where('app_name', appName) + .where('environment', environment) + .orderBy('last_seen', 'desc') + .limit(1000); + + return rows.map(mapRow); + } + async getBySdkName(sdkName: string): Promise { const sdkPrefix = `${sdkName}%`; const rows = await this.db diff --git a/src/lib/features/metrics/instance/instance-service.ts b/src/lib/features/metrics/instance/instance-service.ts index 0db69cf88a..34d54c9c8e 100644 --- a/src/lib/features/metrics/instance/instance-service.ts +++ b/src/lib/features/metrics/instance/instance-service.ts @@ -222,6 +222,24 @@ export default class ClientInstanceService { return this.clientApplicationsStore.getApplicationOverview(appName); } + async getApplicationEnvironmentInstances( + appName: string, + environment: string, + ) { + const instances = + await this.clientInstanceStore.getByAppNameAndEnvironment( + appName, + environment, + ); + + return instances.map((instance) => ({ + instanceId: instance.instanceId, + clientIp: instance.clientIp, + sdkVersion: instance.sdkVersion, + lastSeen: instance.lastSeen, + })); + } + async deleteApplication(appName: string): Promise { await this.clientInstanceStore.deleteForApplication(appName); await this.clientApplicationsStore.delete(appName); diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index fcbd3f42d2..191601fc7f 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -201,6 +201,7 @@ import { rolesSchema } from './spec/roles-schema'; import { applicationOverviewSchema } from './spec/application-overview-schema'; import { applicationOverviewEnvironmentSchema } from './spec/application-overview-environment-schema'; import { applicationOverviewIssuesSchema } from './spec/application-overview-issues-schema'; +import { applicationEnvironmentInstancesSchema } from './spec/application-environment-instances-schema'; // Schemas must have an $id property on the form "#/components/schemas/mySchema". export type SchemaId = (typeof schemas)[keyof typeof schemas]['$id']; @@ -251,6 +252,7 @@ export const schemas: UnleashSchemas = { applicationOverviewSchema, applicationOverviewIssuesSchema, applicationOverviewEnvironmentSchema, + applicationEnvironmentInstancesSchema, applicationUsageSchema, applicationsSchema, batchFeaturesSchema, diff --git a/src/lib/openapi/spec/application-environment-instances-schema.ts b/src/lib/openapi/spec/application-environment-instances-schema.ts new file mode 100644 index 0000000000..9a8dcf7b72 --- /dev/null +++ b/src/lib/openapi/spec/application-environment-instances-schema.ts @@ -0,0 +1,53 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const applicationEnvironmentInstancesSchema = { + $id: '#/components/schemas/applicationEnvironmentInstanceSchema', + type: 'object', + description: + 'Data about an application environment instances that are connected to Unleash via an SDK.', + additionalProperties: false, + required: ['instances'], + properties: { + instances: { + type: 'array', + description: 'A list of instances', + items: { + type: 'object', + required: ['instanceId'], + additionalProperties: false, + properties: { + instanceId: { + description: + 'A unique identifier identifying the instance of the application running the SDK. Often changes based on execution environment. For instance: two pods in Kubernetes will have two different instanceIds', + type: 'string', + example: 'b77f3d13-5f48-4a7b-a3f4-a449b97ce43a', + }, + sdkVersion: { + type: 'string', + description: + 'An SDK version identifier. Usually formatted as "unleash-client-:"', + example: 'unleash-client-java:7.0.0', + }, + clientIp: { + type: 'string', + description: + 'An IP address identifying the instance of the application running the SDK', + example: '192.168.0.1', + }, + lastSeen: { + type: 'string', + format: 'date-time', + example: '2023-04-19T08:15:14.000Z', + description: + 'The last time the application environment instance was seen', + }, + }, + }, + }, + }, + components: {}, +} as const; + +export type ApplicationEnvironmentInstancesSchema = FromSchema< + typeof applicationEnvironmentInstancesSchema +>; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 1e290a222c..a49cd97057 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -178,3 +178,4 @@ export * from './inactive-users-schema'; export * from './record-ui-error-schema'; export * from './project-application-schema'; export * from './project-applications-schema'; +export * from './application-environment-instances-schema'; diff --git a/src/lib/routes/admin-api/metrics.ts b/src/lib/routes/admin-api/metrics.ts index 975e68868c..01ef7ccfb1 100644 --- a/src/lib/routes/admin-api/metrics.ts +++ b/src/lib/routes/admin-api/metrics.ts @@ -25,6 +25,10 @@ import { import { OpenApiService } from '../../services'; import { applicationsQueryParameters } from '../../openapi/spec/applications-query-parameters'; import { normalizeQueryParams } from '../../features/feature-search/search-utils'; +import { + applicationEnvironmentInstancesSchema, + ApplicationEnvironmentInstancesSchema, +} from '../../openapi/spec/application-environment-instances-schema'; class MetricsController extends Controller { private logger: Logger; @@ -152,6 +156,27 @@ class MetricsController extends Controller { }), ], }); + this.route({ + method: 'get', + path: '/instances/:appName/:environment', + handler: this.getApplicationEnvironmentInstances, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Unstable'], + operationId: 'getApplicationEnvironmentInstances', + summary: 'Get application environment instances', + description: + 'Returns an overview of the instances for the given `appName` and `environment` that receive traffic.', + responses: { + 200: createResponseSchema( + 'applicationEnvironmentInstancesSchema', + ), + ...getStandardResponses(404), + }, + }), + ], + }); } async deprecated(req: Request, res: Response): Promise { @@ -223,7 +248,7 @@ class MetricsController extends Controller { } async getApplication( - req: Request, + req: Request<{ appName: string }>, res: Response, ): Promise { const { appName } = req.params; @@ -234,7 +259,7 @@ class MetricsController extends Controller { } async getApplicationOverview( - req: Request, + req: Request<{ appName: string }>, res: Response, ): Promise { if (!this.flagResolver.isEnabled('sdkReporting')) { @@ -251,6 +276,28 @@ class MetricsController extends Controller { serializeDates(overview), ); } + + async getApplicationEnvironmentInstances( + req: Request<{ appName: string; environment: string }>, + res: Response, + ): Promise { + if (!this.flagResolver.isEnabled('sdkReporting')) { + throw new NotFoundError(); + } + const { appName, environment } = req.params; + const instances = + await this.clientInstanceService.getApplicationEnvironmentInstances( + appName, + environment, + ); + + this.openApiService.respondWithValidation( + 200, + res, + applicationEnvironmentInstancesSchema.$id, + serializeDates({ instances }), + ); + } } export default MetricsController; diff --git a/src/lib/types/stores/client-instance-store.ts b/src/lib/types/stores/client-instance-store.ts index eb4857da24..62fb5935e2 100644 --- a/src/lib/types/stores/client-instance-store.ts +++ b/src/lib/types/stores/client-instance-store.ts @@ -21,6 +21,10 @@ export interface IClientInstanceStore setLastSeen(INewClientInstance): Promise; insert(details: INewClientInstance): Promise; getByAppName(appName: string): Promise; + getByAppNameAndEnvironment( + appName: string, + environment: string, + ): Promise; getBySdkName(sdkName: string): Promise; getDistinctApplications(): Promise; getDistinctApplicationsCount(daysBefore?: number): Promise; diff --git a/src/test/e2e/api/admin/applications.e2e.test.ts b/src/test/e2e/api/admin/applications.e2e.test.ts index f78068fb46..e394a50c35 100644 --- a/src/test/e2e/api/admin/applications.e2e.test.ts +++ b/src/test/e2e/api/admin/applications.e2e.test.ts @@ -134,6 +134,20 @@ test('should show correct number of total', async () => { }; expect(body).toMatchObject(expected); + + const { body: instancesBody } = await app.request + .get(`/api/admin/metrics/instances/${metrics.appName}/default`) + .expect(200); + + expect(instancesBody).toMatchObject({ + instances: [ + { instanceId: 'instanceId', sdkVersion: 'unleash-client-test' }, + { + instanceId: 'another-instance', + sdkVersion: 'unleash-client-test2', + }, + ], + }); }); test('should show missing features and strategies', async () => { diff --git a/src/test/fixtures/fake-client-instance-store.ts b/src/test/fixtures/fake-client-instance-store.ts index 2d0f6f513d..ca27317c11 100644 --- a/src/test/fixtures/fake-client-instance-store.ts +++ b/src/test/fixtures/fake-client-instance-store.ts @@ -77,6 +77,15 @@ export default class FakeClientInstanceStore implements IClientInstanceStore { return this.instances.filter((i) => i.appName === appName); } + async getByAppNameAndEnvironment( + appName: string, + environment: string, + ): Promise { + return this.instances + .filter((i) => i.appName === appName) + .filter((i) => i.environment === environment); + } + async getDistinctApplications(): Promise { const apps = new Set(); this.instances.forEach((i) => apps.add(i.appName));