mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: applicaton usage endpoint (#4548)
This commit is contained in:
		
							parent
							
								
									0e162362e6
								
							
						
					
					
						commit
						1fbd8b6ef8
					
				@ -7,6 +7,7 @@ import {
 | 
			
		||||
import { Logger, LogProvider } from '../logger';
 | 
			
		||||
import { IApplicationQuery } from '../types/query';
 | 
			
		||||
import { Db } from './db';
 | 
			
		||||
import { IFlagResolver } from '../types';
 | 
			
		||||
 | 
			
		||||
const COLUMNS = [
 | 
			
		||||
    'app_name',
 | 
			
		||||
@ -35,8 +36,45 @@ const mapRow: (any) => IClientApplication = (row) => ({
 | 
			
		||||
    icon: row.icon,
 | 
			
		||||
    lastSeen: row.last_seen,
 | 
			
		||||
    announced: row.announced,
 | 
			
		||||
    project: row.project,
 | 
			
		||||
    environment: row.environment,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const reduceRows = (rows: any[]): IClientApplication[] => {
 | 
			
		||||
    const appsObj = rows.reduce((acc, row) => {
 | 
			
		||||
        // extracting project and environment from usage table
 | 
			
		||||
        const { project, environment } = row;
 | 
			
		||||
        const existingApp = acc[row.app_name];
 | 
			
		||||
 | 
			
		||||
        if (existingApp) {
 | 
			
		||||
            const existingProject = existingApp.usage.find(
 | 
			
		||||
                (usage) => usage.project === project,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            if (existingProject) {
 | 
			
		||||
                existingProject.environments.push(environment);
 | 
			
		||||
            } else {
 | 
			
		||||
                existingApp.usage.push({
 | 
			
		||||
                    project: project,
 | 
			
		||||
                    environments: [environment],
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            acc[row.app_name] = {
 | 
			
		||||
                ...mapRow(row),
 | 
			
		||||
                usage:
 | 
			
		||||
                    project && environment
 | 
			
		||||
                        ? [{ project, environments: [environment] }]
 | 
			
		||||
                        : [],
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return acc;
 | 
			
		||||
    }, {});
 | 
			
		||||
 | 
			
		||||
    return Object.values(appsObj);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const remapRow = (input) => {
 | 
			
		||||
    const temp = {
 | 
			
		||||
        app_name: input.appName,
 | 
			
		||||
@ -72,10 +110,18 @@ export default class ClientApplicationsStore
 | 
			
		||||
{
 | 
			
		||||
    private db: Db;
 | 
			
		||||
 | 
			
		||||
    private flagResolver: IFlagResolver;
 | 
			
		||||
 | 
			
		||||
    private logger: Logger;
 | 
			
		||||
 | 
			
		||||
    constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
 | 
			
		||||
    constructor(
 | 
			
		||||
        db: Db,
 | 
			
		||||
        eventBus: EventEmitter,
 | 
			
		||||
        getLogger: LogProvider,
 | 
			
		||||
        flagResolver: IFlagResolver,
 | 
			
		||||
    ) {
 | 
			
		||||
        this.db = db;
 | 
			
		||||
        this.flagResolver = flagResolver;
 | 
			
		||||
        this.logger = getLogger('client-applications-store.ts');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -147,6 +193,28 @@ export default class ClientApplicationsStore
 | 
			
		||||
    async getAppsForStrategy(
 | 
			
		||||
        query: IApplicationQuery,
 | 
			
		||||
    ): Promise<IClientApplication[]> {
 | 
			
		||||
        if (this.flagResolver.isEnabled('newApplicationList')) {
 | 
			
		||||
            const rows = await this.db
 | 
			
		||||
                .select([
 | 
			
		||||
                    ...COLUMNS.map((column) => `${TABLE}.${column}`),
 | 
			
		||||
                    'project',
 | 
			
		||||
                    'environment',
 | 
			
		||||
                ])
 | 
			
		||||
                .from(TABLE)
 | 
			
		||||
                .leftJoin(
 | 
			
		||||
                    TABLE_USAGE,
 | 
			
		||||
                    `${TABLE_USAGE}.app_name`,
 | 
			
		||||
                    `${TABLE}.app_name`,
 | 
			
		||||
                );
 | 
			
		||||
            const apps = reduceRows(rows);
 | 
			
		||||
 | 
			
		||||
            if (query.strategyName) {
 | 
			
		||||
                return apps.filter((app) =>
 | 
			
		||||
                    app.strategies.includes(query.strategyName),
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            return apps;
 | 
			
		||||
        } else {
 | 
			
		||||
            const rows = await this.db.select(COLUMNS).from(TABLE);
 | 
			
		||||
            const apps = rows.map(mapRow);
 | 
			
		||||
 | 
			
		||||
@ -157,6 +225,7 @@ export default class ClientApplicationsStore
 | 
			
		||||
            }
 | 
			
		||||
            return apps;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getUnannounced(): Promise<IClientApplication[]> {
 | 
			
		||||
        const rows = await this.db(TABLE)
 | 
			
		||||
 | 
			
		||||
@ -54,6 +54,7 @@ export const createStores = (
 | 
			
		||||
            db,
 | 
			
		||||
            eventBus,
 | 
			
		||||
            getLogger,
 | 
			
		||||
            config.flagResolver,
 | 
			
		||||
        ),
 | 
			
		||||
        clientInstanceStore: new ClientInstanceStore(db, eventBus, getLogger),
 | 
			
		||||
        clientMetricsStoreV2: new ClientMetricsStoreV2(
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ import {
 | 
			
		||||
    apiTokenSchema,
 | 
			
		||||
    apiTokensSchema,
 | 
			
		||||
    applicationSchema,
 | 
			
		||||
    applicationUsageSchema,
 | 
			
		||||
    applicationsSchema,
 | 
			
		||||
    batchFeaturesSchema,
 | 
			
		||||
    bulkToggleFeaturesSchema,
 | 
			
		||||
@ -215,6 +216,7 @@ export const schemas: UnleashSchemas = {
 | 
			
		||||
    apiTokenSchema,
 | 
			
		||||
    apiTokensSchema,
 | 
			
		||||
    applicationSchema,
 | 
			
		||||
    applicationUsageSchema,
 | 
			
		||||
    applicationsSchema,
 | 
			
		||||
    batchFeaturesSchema,
 | 
			
		||||
    batchStaleSchema,
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import { FromSchema } from 'json-schema-to-ts';
 | 
			
		||||
import { applicationUsageSchema } from './application-usage-schema';
 | 
			
		||||
 | 
			
		||||
export const applicationSchema = {
 | 
			
		||||
    $id: '#/components/schemas/applicationSchema',
 | 
			
		||||
@ -50,8 +51,17 @@ export const applicationSchema = {
 | 
			
		||||
            type: 'string',
 | 
			
		||||
            example: 'https://github.com/favicon.ico',
 | 
			
		||||
        },
 | 
			
		||||
        usage: {
 | 
			
		||||
            description: 'The list of projects the application has been using.',
 | 
			
		||||
            type: 'array',
 | 
			
		||||
            items: {
 | 
			
		||||
                $ref: '#/components/schemas/applicationUsageSchema',
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
    components: {
 | 
			
		||||
        applicationUsageSchema,
 | 
			
		||||
    },
 | 
			
		||||
    components: {},
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export type ApplicationSchema = FromSchema<typeof applicationSchema>;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										28
									
								
								src/lib/openapi/spec/application-usage-schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/lib/openapi/spec/application-usage-schema.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
			
		||||
import { FromSchema } from 'json-schema-to-ts';
 | 
			
		||||
 | 
			
		||||
export const applicationUsageSchema = {
 | 
			
		||||
    $id: '#/components/schemas/applicationUsageSchema',
 | 
			
		||||
    type: 'object',
 | 
			
		||||
    description: 'Data about an project that have been used by applications.',
 | 
			
		||||
    additionalProperties: false,
 | 
			
		||||
    required: ['project', 'environments'],
 | 
			
		||||
    properties: {
 | 
			
		||||
        project: {
 | 
			
		||||
            description: 'Name of the project',
 | 
			
		||||
            type: 'string',
 | 
			
		||||
            example: 'main-project',
 | 
			
		||||
        },
 | 
			
		||||
        environments: {
 | 
			
		||||
            description:
 | 
			
		||||
                'Which environments have been accessed in this project.',
 | 
			
		||||
            type: 'array',
 | 
			
		||||
            items: {
 | 
			
		||||
                type: 'string',
 | 
			
		||||
            },
 | 
			
		||||
            example: ['development', 'production'],
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
    components: {},
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
export type ApplicationUsageSchema = FromSchema<typeof applicationUsageSchema>;
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { applicationSchema } from './application-schema';
 | 
			
		||||
import { FromSchema } from 'json-schema-to-ts';
 | 
			
		||||
import { applicationUsageSchema } from './application-usage-schema';
 | 
			
		||||
 | 
			
		||||
export const applicationsSchema = {
 | 
			
		||||
    $id: '#/components/schemas/applicationsSchema',
 | 
			
		||||
@ -20,6 +21,7 @@ export const applicationsSchema = {
 | 
			
		||||
    components: {
 | 
			
		||||
        schemas: {
 | 
			
		||||
            applicationSchema,
 | 
			
		||||
            applicationUsageSchema,
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
@ -153,3 +153,4 @@ export * from './strategy-variant-schema';
 | 
			
		||||
export * from './client-segment-schema';
 | 
			
		||||
export * from './update-feature-type-lifetime-schema';
 | 
			
		||||
export * from './create-group-schema';
 | 
			
		||||
export * from './application-usage-schema';
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,3 @@
 | 
			
		||||
import { applicationSchema } from './schema';
 | 
			
		||||
import { APPLICATION_CREATED, CLIENT_REGISTER } from '../../types/events';
 | 
			
		||||
import { IApplication } from './models';
 | 
			
		||||
import { IUnleashStores } from '../../types/stores';
 | 
			
		||||
@ -205,8 +204,7 @@ export default class ClientInstanceService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async createApplication(input: IApplication): Promise<void> {
 | 
			
		||||
        const applicationData = await applicationSchema.validateAsync(input);
 | 
			
		||||
        await this.clientApplicationsStore.upsert(applicationData);
 | 
			
		||||
        await this.clientApplicationsStore.upsert(input);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async removeInstancesOlderThanTwoDays(): Promise<void> {
 | 
			
		||||
 | 
			
		||||
@ -25,5 +25,7 @@ export interface IApplication {
 | 
			
		||||
    createdAt?: Date;
 | 
			
		||||
    instances?: IClientInstance[];
 | 
			
		||||
    seenToggles?: Record<string, any>;
 | 
			
		||||
    project?: string;
 | 
			
		||||
    environment?: string;
 | 
			
		||||
    links?: Record<string, string>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,10 @@
 | 
			
		||||
import { Store } from './store';
 | 
			
		||||
import { IApplicationQuery } from '../query';
 | 
			
		||||
 | 
			
		||||
export interface IClientApplicationUsage {
 | 
			
		||||
    project: string;
 | 
			
		||||
    environments: string[];
 | 
			
		||||
}
 | 
			
		||||
export interface IClientApplication {
 | 
			
		||||
    appName: string;
 | 
			
		||||
    updatedAt: Date;
 | 
			
		||||
@ -13,6 +17,7 @@ export interface IClientApplication {
 | 
			
		||||
    color: string;
 | 
			
		||||
    icon: string;
 | 
			
		||||
    strategies: string[];
 | 
			
		||||
    usage?: IClientApplicationUsage[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IClientApplicationsStore
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,24 @@
 | 
			
		||||
import dbInit, { ITestDb } from '../../helpers/database-init';
 | 
			
		||||
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
 | 
			
		||||
import {
 | 
			
		||||
    IUnleashTest,
 | 
			
		||||
    setupAppWithCustomConfig,
 | 
			
		||||
} from '../../helpers/test-helper';
 | 
			
		||||
import getLogger from '../../../fixtures/no-logger';
 | 
			
		||||
 | 
			
		||||
let app: IUnleashTest;
 | 
			
		||||
let db: ITestDb;
 | 
			
		||||
 | 
			
		||||
beforeAll(async () => {
 | 
			
		||||
    db = await dbInit('metrics_serial', getLogger);
 | 
			
		||||
    app = await setupApp(db.stores);
 | 
			
		||||
    db = await dbInit('metrics_serial', getLogger, {
 | 
			
		||||
        experimental: { flags: { newApplicationList: true } },
 | 
			
		||||
    });
 | 
			
		||||
    app = await setupAppWithCustomConfig(db.stores, {
 | 
			
		||||
        experimental: {
 | 
			
		||||
            flags: {
 | 
			
		||||
                newApplicationList: true,
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
beforeEach(async () => {
 | 
			
		||||
@ -44,6 +55,14 @@ beforeEach(async () => {
 | 
			
		||||
        appName: 'deletable-app',
 | 
			
		||||
        instanceId: 'inst-1',
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await app.services.clientInstanceService.createApplication({
 | 
			
		||||
        appName: 'usage-app',
 | 
			
		||||
        strategies: ['default'],
 | 
			
		||||
        description: 'Some desc',
 | 
			
		||||
        project: 'default',
 | 
			
		||||
        environment: 'dev',
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
afterAll(async () => {
 | 
			
		||||
@ -74,7 +93,7 @@ test('should get list of applications', async () => {
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect(200)
 | 
			
		||||
        .expect((res) => {
 | 
			
		||||
            expect(res.body.applications).toHaveLength(3);
 | 
			
		||||
            expect(res.body.applications).toHaveLength(4);
 | 
			
		||||
        });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -89,7 +108,7 @@ test('should delete application', async () => {
 | 
			
		||||
        .get('/api/admin/metrics/applications')
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect((res) => {
 | 
			
		||||
            expect(res.body.applications).toHaveLength(2);
 | 
			
		||||
            expect(res.body.applications).toHaveLength(3);
 | 
			
		||||
        });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -101,3 +120,17 @@ test('deleting an application should be idempotent, so expect 200', async () =>
 | 
			
		||||
            expect(res.status).toBe(200);
 | 
			
		||||
        });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should get list of application usage', async () => {
 | 
			
		||||
    const { body } = await app.request
 | 
			
		||||
        .get('/api/admin/metrics/applications')
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect(200);
 | 
			
		||||
    const application = body.applications.find(
 | 
			
		||||
        (selectableApp) => selectableApp.appName === 'usage-app',
 | 
			
		||||
    );
 | 
			
		||||
    expect(application).toMatchObject({
 | 
			
		||||
        appName: 'usage-app',
 | 
			
		||||
        usage: [{ project: 'default', environments: ['dev'] }],
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user