Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: Project insights subdomain (#6634)
- Loading branch information
Showing
8 changed files
with
373 additions
and
66 deletions.
There are no files selected for viewing
67 changes: 67 additions & 0 deletions
67
src/lib/features/project-insights/createProjectInsightsService.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
); | ||
}; |
70 changes: 70 additions & 0 deletions
70
src/lib/features/project-insights/project-insights-controller.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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), | ||
); | ||
} | ||
} |
164 changes: 164 additions & 0 deletions
164
src/lib/features/project-insights/project-insights-service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}; | ||
} | ||
} |
57 changes: 57 additions & 0 deletions
57
src/lib/features/project-insights/projects-insights.e2e.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}, | ||
}); | ||
}); |
Oops, something went wrong.