1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-08 01:15:49 +02:00

refactor: Project insights subdomain (#6634)

This commit is contained in:
Mateusz Kwasniewski 2024-03-20 15:06:11 +01:00 committed by GitHub
parent efb2df78c2
commit 87b9f4f713
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 373 additions and 66 deletions

View File

@ -0,0 +1,67 @@
import type { Db, IUnleashConfig } from '../../server-impl';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
import ProjectStatsStore from '../../db/project-stats-store';
import {
createFakeFeatureToggleService,
createFeatureToggleService,
} from '../feature-toggle/createFeatureToggleService';
import FakeProjectStore from '../../../test/fixtures/fake-project-store';
import FakeFeatureToggleStore from '../feature-toggle/fakes/fake-feature-toggle-store';
import FakeProjectStatsStore from '../../../test/fixtures/fake-project-stats-store';
import FeatureTypeStore from '../../db/feature-type-store';
import FakeFeatureTypeStore from '../../../test/fixtures/fake-feature-type-store';
import { ProjectInsightsService } from './project-insights-service';
import ProjectStore from '../project/project-store';
export const createProjectInsightsService = (
db: Db,
config: IUnleashConfig,
): ProjectInsightsService => {
const { eventBus, getLogger, flagResolver } = config;
const projectStore = new ProjectStore(
db,
eventBus,
getLogger,
flagResolver,
);
const featureToggleStore = new FeatureToggleStore(
db,
eventBus,
getLogger,
flagResolver,
);
const featureTypeStore = new FeatureTypeStore(db, getLogger);
const projectStatsStore = new ProjectStatsStore(db, eventBus, getLogger);
const featureToggleService = createFeatureToggleService(db, config);
return new ProjectInsightsService(
{
projectStore,
featureToggleStore,
featureTypeStore,
projectStatsStore,
},
featureToggleService,
);
};
export const createFakeProjectInsightsService = (
config: IUnleashConfig,
): ProjectInsightsService => {
const projectStore = new FakeProjectStore();
const featureToggleStore = new FakeFeatureToggleStore();
const featureTypeStore = new FakeFeatureTypeStore();
const projectStatsStore = new FakeProjectStatsStore();
const featureToggleService = createFakeFeatureToggleService(config);
return new ProjectInsightsService(
{
projectStore,
featureToggleStore,
featureTypeStore,
projectStatsStore,
},
featureToggleService,
);
};

View File

@ -0,0 +1,70 @@
import type { Response } from 'express';
import Controller from '../../routes/controller';
import {
type IFlagResolver,
type IProjectParam,
type IUnleashConfig,
type IUnleashServices,
NONE,
serializeDates,
} from '../../types';
import type { ProjectInsightsService } from './project-insights-service';
import {
createResponseSchema,
projectInsightsSchema,
type ProjectInsightsSchema,
} from '../../openapi';
import { getStandardResponses } from '../../openapi/util/standard-responses';
import type { OpenApiService } from '../../services';
import type { IAuthRequest } from '../../routes/unleash-types';
export default class ProjectInsightsController extends Controller {
private projectInsightsService: ProjectInsightsService;
private openApiService: OpenApiService;
private flagResolver: IFlagResolver;
constructor(config: IUnleashConfig, services: IUnleashServices) {
super(config);
this.projectInsightsService = services.projectInsightsService;
this.openApiService = services.openApiService;
this.flagResolver = config.flagResolver;
this.route({
method: 'get',
path: '/:projectId/insights',
handler: this.getProjectInsights,
permission: NONE,
middleware: [
this.openApiService.validPath({
tags: ['Unstable'],
operationId: 'getProjectInsights',
summary: 'Get an overview of a project insights.',
description:
'This endpoint returns insights into the specified projects stats, health, lead time for changes, feature types used, members and change requests.',
responses: {
200: createResponseSchema('projectInsightsSchema'),
...getStandardResponses(401, 403, 404),
},
}),
],
});
}
async getProjectInsights(
req: IAuthRequest<IProjectParam, unknown, unknown, unknown>,
res: Response<ProjectInsightsSchema>,
): Promise<void> {
const { projectId } = req.params;
const insights =
await this.projectInsightsService.getProjectInsights(projectId);
this.openApiService.respondWithValidation(
200,
res,
projectInsightsSchema.$id,
serializeDates(insights),
);
}
}

View File

@ -0,0 +1,164 @@
import type {
IFeatureToggleStore,
IFeatureTypeStore,
IProjectHealth,
IProjectStore,
IUnleashStores,
} from '../../types';
import type FeatureToggleService from '../feature-toggle/feature-toggle-service';
import { calculateAverageTimeToProd } from '../feature-toggle/time-to-production/time-to-production';
import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type';
import type { ProjectDoraMetricsSchema } from '../../openapi';
import { calculateProjectHealth } from '../../domain/project-health/project-health';
export class ProjectInsightsService {
private projectStore: IProjectStore;
private featureToggleStore: IFeatureToggleStore;
private featureTypeStore: IFeatureTypeStore;
private featureToggleService: FeatureToggleService;
private projectStatsStore: IProjectStatsStore;
constructor(
{
projectStore,
featureToggleStore,
featureTypeStore,
projectStatsStore,
}: Pick<
IUnleashStores,
| 'projectStore'
| 'featureToggleStore'
| 'projectStatsStore'
| 'featureTypeStore'
>,
featureToggleService: FeatureToggleService,
) {
this.projectStore = projectStore;
this.featureToggleStore = featureToggleStore;
this.featureTypeStore = featureTypeStore;
this.featureToggleService = featureToggleService;
this.projectStatsStore = projectStatsStore;
}
private async getDoraMetrics(
projectId: string,
): Promise<ProjectDoraMetricsSchema> {
const activeFeatureToggles = (
await this.featureToggleStore.getAll({ project: projectId })
).map((feature) => feature.name);
const archivedFeatureToggles = (
await this.featureToggleStore.getAll({
project: projectId,
archived: true,
})
).map((feature) => feature.name);
const featureToggleNames = [
...activeFeatureToggles,
...archivedFeatureToggles,
];
const projectAverage = calculateAverageTimeToProd(
await this.projectStatsStore.getTimeToProdDates(projectId),
);
const toggleAverage =
await this.projectStatsStore.getTimeToProdDatesForFeatureToggles(
projectId,
featureToggleNames,
);
return {
features: toggleAverage,
projectAverage: projectAverage,
};
}
private async getHealthInsights(projectId: string) {
const [overview, featureTypes] = await Promise.all([
this.getProjectHealth(projectId, false, undefined),
this.featureTypeStore.getAll(),
]);
const { activeCount, potentiallyStaleCount, staleCount } =
calculateProjectHealth(overview.features, featureTypes);
return {
activeCount,
potentiallyStaleCount,
staleCount,
rating: overview.health,
};
}
async getProjectInsights(projectId: string) {
const result = {
members: {
active: 20,
inactive: 3,
totalPreviousMonth: 15,
},
changeRequests: {
total: 24,
approved: 5,
applied: 2,
rejected: 4,
reviewRequired: 10,
scheduled: 3,
},
};
const [stats, featureTypeCounts, health, leadTime] = await Promise.all([
this.projectStatsStore.getProjectStats(projectId),
this.featureToggleService.getFeatureTypeCounts({
projectId,
archived: false,
}),
this.getHealthInsights(projectId),
this.getDoraMetrics(projectId),
]);
return { ...result, stats, featureTypeCounts, health, leadTime };
}
private async getProjectHealth(
projectId: string,
archived: boolean = false,
userId?: number,
): Promise<IProjectHealth> {
const [project, environments, features, members, projectStats] =
await Promise.all([
this.projectStore.get(projectId),
this.projectStore.getEnvironmentsForProject(projectId),
this.featureToggleService.getFeatureOverview({
projectId,
archived,
userId,
}),
this.projectStore.getMembersCountByProject(projectId),
this.projectStatsStore.getProjectStats(projectId),
]);
return {
stats: projectStats,
name: project.name,
description: project.description!,
mode: project.mode,
featureLimit: project.featureLimit,
featureNaming: project.featureNaming,
defaultStickiness: project.defaultStickiness,
health: project.health || 0,
updatedAt: project.updatedAt,
createdAt: project.createdAt,
environments,
features: features,
members,
version: 1,
};
}
}

View File

@ -0,0 +1,57 @@
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
import {
type IUnleashTest,
setupAppWithCustomConfig,
} from '../../../test/e2e/helpers/test-helper';
import getLogger from '../../../test/fixtures/no-logger';
let app: IUnleashTest;
let db: ITestDb;
beforeAll(async () => {
db = await dbInit('projects_insights', getLogger);
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
},
},
},
db.rawDatabase,
);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('project insights happy path', async () => {
const { body } = await app.request
.get('/api/admin/projects/default/insights')
.expect('Content-Type', /json/)
.expect(200);
expect(body).toMatchObject({
stats: {
avgTimeToProdCurrentWindow: 0,
createdCurrentWindow: 0,
createdPastWindow: 0,
archivedCurrentWindow: 0,
archivedPastWindow: 0,
projectActivityCurrentWindow: 0,
projectActivityPastWindow: 0,
projectMembersAddedCurrentWindow: 0,
},
leadTime: { features: [], projectAverage: 0 },
featureTypeCounts: [],
health: {
activeCount: 0,
potentiallyStaleCount: 0,
staleCount: 0,
rating: 100,
},
});
});

View File

@ -20,8 +20,6 @@ import {
deprecatedProjectOverviewSchema,
type ProjectDoraMetricsSchema,
projectDoraMetricsSchema,
projectInsightsSchema,
type ProjectInsightsSchema,
projectOverviewSchema,
type ProjectsSchema,
projectsSchema,
@ -42,6 +40,7 @@ import {
import { NotFoundError } from '../../error';
import { projectApplicationsQueryParameters } from '../../openapi/spec/project-applications-query-parameters';
import { normalizeQueryParams } from '../feature-search/search-utils';
import ProjectInsightsController from '../project-insights/project-insights-controller';
export default class ProjectController extends Controller {
private projectService: ProjectService;
@ -119,26 +118,6 @@ export default class ProjectController extends Controller {
],
});
this.route({
method: 'get',
path: '/:projectId/insights',
handler: this.getProjectInsights,
permission: NONE,
middleware: [
this.openApiService.validPath({
tags: ['Unstable'],
operationId: 'getProjectInsights',
summary: 'Get an overview of a project insights.',
description:
'This endpoint returns insights into the specified projects stats, health, lead time for changes, feature types used, members and change requests.',
responses: {
200: createResponseSchema('projectInsightsSchema'),
...getStandardResponses(401, 403, 404),
},
}),
],
});
this.route({
method: 'get',
path: '/:projectId/dora',
@ -201,6 +180,7 @@ export default class ProjectController extends Controller {
createKnexTransactionStarter(db),
).router,
);
this.use('/', new ProjectInsightsController(config, services).router);
}
async getProjects(
@ -244,22 +224,6 @@ export default class ProjectController extends Controller {
);
}
async getProjectInsights(
req: IAuthRequest<IProjectParam, unknown, unknown, unknown>,
res: Response<ProjectInsightsSchema>,
): Promise<void> {
const { projectId } = req.params;
const insights =
await this.projectService.getProjectInsights(projectId);
this.openApiService.respondWithValidation(
200,
res,
projectInsightsSchema.$id,
serializeDates(insights),
);
}
async getProjectOverview(
req: IAuthRequest<IProjectParam, unknown, unknown, IArchivedQuery>,
res: Response<ProjectOverviewSchema>,

View File

@ -1,8 +1,8 @@
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init';
import {
type IUnleashTest,
insertFeatureEnvironmentsLastSeen,
insertLastSeenAt,
type IUnleashTest,
setupAppWithCustomConfig,
} from '../../../test/e2e/helpers/test-helper';
import getLogger from '../../../test/fixtures/no-logger';
@ -287,30 +287,3 @@ test('response should include last seen at per environment for multiple environm
expect(body.features[1].lastSeenAt).toBe('2023-10-01T12:34:56.000Z');
});
test('project insights happy path', async () => {
const { body } = await app.request
.get('/api/admin/projects/default/insights')
.expect('Content-Type', /json/)
.expect(200);
expect(body).toMatchObject({
stats: {
avgTimeToProdCurrentWindow: 0,
createdCurrentWindow: 0,
createdPastWindow: 0,
archivedCurrentWindow: 0,
archivedPastWindow: 0,
projectActivityCurrentWindow: 0,
projectActivityPastWindow: 0,
projectMembersAddedCurrentWindow: 0,
},
featureTypeCounts: [],
health: {
activeCount: 0,
potentiallyStaleCount: 0,
staleCount: 0,
rating: 100,
},
});
});

View File

@ -45,6 +45,7 @@ import { FavoritesService } from './favorites-service';
import MaintenanceService from '../features/maintenance/maintenance-service';
import { AccountService } from './account-service';
import { SchedulerService } from '../features/scheduler/scheduler-service';
import { ProjectInsightsService } from '../features/project-insights/project-insights-service';
import type { Knex } from 'knex';
import {
createExportImportTogglesService,
@ -119,6 +120,10 @@ import {
createFakeFrontendApiService,
createFrontendApiService,
} from '../features/frontend-api/createFrontendApiService';
import {
createFakeProjectInsightsService,
createProjectInsightsService,
} from '../features/project-insights/createProjectInsightsService';
export const createServices = (
stores: IUnleashStores,
@ -263,6 +268,9 @@ export const createServices = (
const projectService = db
? createProjectService(db, config)
: createFakeProjectService(config);
const projectInsightsService = db
? createProjectInsightsService(db, config)
: createFakeProjectInsightsService(config);
const projectHealthService = new ProjectHealthService(
stores,
@ -397,6 +405,7 @@ export const createServices = (
clientFeatureToggleService,
featureSearchService,
inactiveUsersService,
projectInsightsService,
};
};
@ -443,4 +452,5 @@ export {
DependentFeaturesService,
ClientFeatureToggleService,
FeatureSearchService,
ProjectInsightsService,
};

View File

@ -52,6 +52,7 @@ import type { WithTransactional } from '../db/transaction';
import type { ClientFeatureToggleService } from '../features/client-feature-toggles/client-feature-toggle-service';
import type { FeatureSearchService } from '../features/feature-search/feature-search-service';
import type { InactiveUsersService } from '../users/inactive/inactive-users-service';
import type { ProjectInsightsService } from '../features/project-insights/project-insights-service';
export interface IUnleashServices {
accessService: AccessService;
@ -113,4 +114,5 @@ export interface IUnleashServices {
clientFeatureToggleService: ClientFeatureToggleService;
featureSearchService: FeatureSearchService;
inactiveUsersService: InactiveUsersService;
projectInsightsService: ProjectInsightsService;
}