1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: expose lifecycle stage in project overview search (#7017)

This commit is contained in:
Mateusz Kwasniewski 2024-05-09 10:50:51 +02:00 committed by GitHub
parent 28a7797aea
commit 97d702afeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 102 additions and 5 deletions

View File

@ -44,6 +44,7 @@ import { InactiveUsersStore } from '../users/inactive/inactive-users-store';
import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store'; import { TrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store';
import { SegmentReadModel } from '../features/segment/segment-read-model'; import { SegmentReadModel } from '../features/segment/segment-read-model';
import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model'; import { ProjectOwnersReadModel } from '../features/project/project-owners-read-model';
import { FeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store';
export const createStores = ( export const createStores = (
config: IUnleashConfig, config: IUnleashConfig,
@ -145,11 +146,17 @@ export const createStores = (
privateProjectStore: new PrivateProjectStore(db, getLogger), privateProjectStore: new PrivateProjectStore(db, getLogger),
dependentFeaturesStore: new DependentFeaturesStore(db), dependentFeaturesStore: new DependentFeaturesStore(db),
lastSeenStore: new LastSeenStore(db, eventBus, getLogger), lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger), featureSearchStore: new FeatureSearchStore(
db,
eventBus,
getLogger,
config.flagResolver,
),
inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger), inactiveUsersStore: new InactiveUsersStore(db, eventBus, getLogger),
trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger), trafficDataUsageStore: new TrafficDataUsageStore(db, getLogger),
segmentReadModel: new SegmentReadModel(db), segmentReadModel: new SegmentReadModel(db),
projectOwnersReadModel: new ProjectOwnersReadModel(db), projectOwnersReadModel: new ProjectOwnersReadModel(db),
featureLifecycleStore: new FeatureLifecycleStore(db),
}; };
}; };

View File

@ -13,6 +13,7 @@ export const createFeatureSearchService =
db, db,
eventBus, eventBus,
getLogger, getLogger,
flagResolver,
); );
return new FeatureSearchService( return new FeatureSearchService(

View File

@ -7,6 +7,7 @@ import type {
IFeatureOverview, IFeatureOverview,
IFeatureSearchOverview, IFeatureSearchOverview,
IFeatureSearchStore, IFeatureSearchStore,
IFlagResolver,
ITag, ITag,
} from '../../types'; } from '../../types';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store'; import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
@ -40,9 +41,17 @@ class FeatureSearchStore implements IFeatureSearchStore {
private readonly timer: Function; private readonly timer: Function;
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) { private flagResolver: IFlagResolver;
constructor(
db: Db,
eventBus: EventEmitter,
getLogger: LogProvider,
flagResolver: IFlagResolver,
) {
this.db = db; this.db = db;
this.logger = getLogger('feature-search-store.ts'); this.logger = getLogger('feature-search-store.ts');
this.flagResolver = flagResolver;
this.timer = (action) => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-search', store: 'feature-search',
@ -65,6 +74,20 @@ class FeatureSearchStore implements IFeatureSearchStore {
}; };
} }
private getLatestLifecycleStageQuery() {
return this.db('feature_lifecycles')
.select(
'feature as stage_feature',
'stage as latest_stage',
'created_at as entered_stage_at',
)
.distinctOn('stage_feature')
.orderBy([
'stage_feature',
{ column: 'entered_stage_at', order: 'desc' },
]);
}
async searchFeatures( async searchFeatures(
{ {
userId, userId,
@ -86,6 +109,9 @@ class FeatureSearchStore implements IFeatureSearchStore {
const validatedSortOrder = const validatedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const featureLifecycleEnabled =
this.flagResolver.isEnabled('featureLifecycle');
const finalQuery = this.db const finalQuery = this.db
.with('ranked_features', (query) => { .with('ranked_features', (query) => {
query.from('features'); query.from('features');
@ -235,6 +261,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
.select(selectColumns) .select(selectColumns)
.denseRank('rank', this.db.raw(rankingSql)); .denseRank('rank', this.db.raw(rankingSql));
}) })
.with('lifecycle', this.getLatestLifecycleStageQuery())
.with( .with(
'final_ranks', 'final_ranks',
this.db.raw( this.db.raw(
@ -290,10 +317,20 @@ class FeatureSearchStore implements IFeatureSearchStore {
.joinRaw('CROSS JOIN total_features') .joinRaw('CROSS JOIN total_features')
.whereBetween('final_rank', [offset + 1, offset + limit]) .whereBetween('final_rank', [offset + 1, offset + limit])
.orderBy('final_rank'); .orderBy('final_rank');
if (featureLifecycleEnabled) {
finalQuery.leftJoin(
'lifecycle',
'ranked_features.feature_name',
'lifecycle.stage_feature',
);
}
const rows = await finalQuery; const rows = await finalQuery;
stopTimer(); stopTimer();
if (rows.length > 0) { if (rows.length > 0) {
const overview = this.getAggregatedSearchData(rows); const overview = this.getAggregatedSearchData(
rows,
featureLifecycleEnabled,
);
const features = sortEnvironments(overview); const features = sortEnvironments(overview);
return { return {
features, features,
@ -349,7 +386,10 @@ class FeatureSearchStore implements IFeatureSearchStore {
return rankingSql; return rankingSql;
} }
getAggregatedSearchData(rows): IFeatureSearchOverview[] { getAggregatedSearchData(
rows,
featureLifecycleEnabled: boolean,
): IFeatureSearchOverview[] {
const entriesMap: Map<string, IFeatureSearchOverview> = new Map(); const entriesMap: Map<string, IFeatureSearchOverview> = new Map();
const orderedEntries: IFeatureSearchOverview[] = []; const orderedEntries: IFeatureSearchOverview[] = [];
@ -372,6 +412,14 @@ class FeatureSearchStore implements IFeatureSearchStore {
environments: [], environments: [],
segments: row.segment_name ? [row.segment_name] : [], segments: row.segment_name ? [row.segment_name] : [],
}; };
if (featureLifecycleEnabled) {
entry.lifecycle = row.latest_stage
? {
stage: row.latest_stage,
enteredStageAt: row.entered_stage_at,
}
: undefined;
}
entriesMap.set(row.feature_name, entry); entriesMap.set(row.feature_name, entry);
orderedEntries.push(entry); orderedEntries.push(entry);
} }

View File

@ -21,6 +21,7 @@ beforeAll(async () => {
experimental: { experimental: {
flags: { flags: {
strictSchemaValidation: true, strictSchemaValidation: true,
featureLifecycle: true,
}, },
}, },
}, },
@ -924,7 +925,7 @@ test('should filter features by combined operators', async () => {
}); });
}); });
test('should return environment usage metrics', async () => { test('should return environment usage metrics and lifecycle', async () => {
await app.createFeature({ await app.createFeature({
name: 'my_feature_b', name: 'my_feature_b',
createdAt: '2023-01-29T15:21:39.975Z', createdAt: '2023-01-29T15:21:39.975Z',
@ -957,6 +958,13 @@ test('should return environment usage metrics', async () => {
}, },
]); ]);
await stores.featureLifecycleStore.insert([
{ feature: 'my_feature_b', stage: 'initial' },
]);
await stores.featureLifecycleStore.insert([
{ feature: 'my_feature_b', stage: 'pre-live' },
]);
const { body } = await searchFeatures({ const { body } = await searchFeatures({
query: 'my_feature_b', query: 'my_feature_b',
}); });
@ -964,6 +972,7 @@ test('should return environment usage metrics', async () => {
features: [ features: [
{ {
name: 'my_feature_b', name: 'my_feature_b',
lifecycle: { stage: 'pre-live' },
environments: [ environments: [
{ {
name: 'default', name: 'default',

View File

@ -143,6 +143,32 @@ export const featureSearchResponseSchema = {
nullable: true, nullable: true,
description: 'The list of feature tags', description: 'The list of feature tags',
}, },
lifecycle: {
type: 'object',
description: 'Current lifecycle stage of the feature',
additionalProperties: false,
required: ['stage', 'enteredStageAt'],
properties: {
stage: {
description: 'The name of the current lifecycle stage',
type: 'string',
enum: [
'initial',
'pre-live',
'live',
'completed',
'archived',
],
example: 'initial',
},
enteredStageAt: {
description: 'When the feature entered this stage',
type: 'string',
format: 'date-time',
example: '2023-01-28T15:21:39.975Z',
},
},
},
}, },
components: { components: {
schemas: { schemas: {

View File

@ -237,6 +237,7 @@ export interface IFeatureOverview {
createdAt: Date; createdAt: Date;
lastSeenAt: Date; lastSeenAt: Date;
environments: IEnvironmentOverview[]; environments: IEnvironmentOverview[];
lifecycle?: IFeatureLifecycleStage;
} }
export type IFeatureSearchOverview = Exclude< export type IFeatureSearchOverview = Exclude<

View File

@ -41,6 +41,7 @@ import type { IInactiveUsersStore } from '../users/inactive/types/inactive-users
import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type'; import { ITrafficDataUsageStore } from '../features/traffic-data-usage/traffic-data-usage-store-type';
import { ISegmentReadModel } from '../features/segment/segment-read-model-type'; import { ISegmentReadModel } from '../features/segment/segment-read-model-type';
import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type'; import { IProjectOwnersReadModel } from '../features/project/project-owners-read-model.type';
import { IFeatureLifecycleStore } from '../features/feature-lifecycle/feature-lifecycle-store-type';
export interface IUnleashStores { export interface IUnleashStores {
accessStore: IAccessStore; accessStore: IAccessStore;
@ -86,6 +87,7 @@ export interface IUnleashStores {
trafficDataUsageStore: ITrafficDataUsageStore; trafficDataUsageStore: ITrafficDataUsageStore;
segmentReadModel: ISegmentReadModel; segmentReadModel: ISegmentReadModel;
projectOwnersReadModel: IProjectOwnersReadModel; projectOwnersReadModel: IProjectOwnersReadModel;
featureLifecycleStore: IFeatureLifecycleStore;
} }
export { export {
@ -130,4 +132,5 @@ export {
ITrafficDataUsageStore, ITrafficDataUsageStore,
ISegmentReadModel, ISegmentReadModel,
IProjectOwnersReadModel, IProjectOwnersReadModel,
IFeatureLifecycleStore,
}; };

View File

@ -44,6 +44,7 @@ import { FakeInactiveUsersStore } from '../../lib/users/inactive/fakes/fake-inac
import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store'; import { FakeTrafficDataUsageStore } from '../../lib/features/traffic-data-usage/fake-traffic-data-usage-store';
import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model'; import { FakeSegmentReadModel } from '../../lib/features/segment/fake-segment-read-model';
import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model'; import { FakeProjectOwnersReadModel } from '../../lib/features/project/fake-project-owners-read-model';
import { FakeFeatureLifecycleStore } from '../../lib/features/feature-lifecycle/fake-feature-lifecycle-store';
const db = { const db = {
select: () => ({ select: () => ({
@ -97,6 +98,7 @@ const createStores: () => IUnleashStores = () => {
trafficDataUsageStore: new FakeTrafficDataUsageStore(), trafficDataUsageStore: new FakeTrafficDataUsageStore(),
segmentReadModel: new FakeSegmentReadModel(), segmentReadModel: new FakeSegmentReadModel(),
projectOwnersReadModel: new FakeProjectOwnersReadModel(), projectOwnersReadModel: new FakeProjectOwnersReadModel(),
featureLifecycleStore: new FakeFeatureLifecycleStore(),
}; };
}; };