mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-09 00:18:00 +01:00
feat: search includes feature last seen data last hour (#6677)
This commit is contained in:
parent
d22ecd0c73
commit
a2a9a84974
@ -4,8 +4,8 @@ import metricsHelper from '../../util/metrics-helper';
|
||||
import { DB_TIME } from '../../metric-events';
|
||||
import type { Logger, LogProvider } from '../../logger';
|
||||
import type {
|
||||
IEnvironmentOverview,
|
||||
IFeatureOverview,
|
||||
IFeatureSearchOverview,
|
||||
IFeatureSearchStore,
|
||||
ITag,
|
||||
} from '../../types';
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
IQueryParam,
|
||||
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||
import { applyGenericQueryParams, applySearchFilters } from './search-utils';
|
||||
import type { FeatureSearchEnvironmentSchema } from '../../openapi/spec/feature-search-environment-schema';
|
||||
|
||||
const sortEnvironments = (overview: IFeatureOverview[]) => {
|
||||
return overview.map((data: IFeatureOverview) => ({
|
||||
@ -49,7 +50,7 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
});
|
||||
}
|
||||
|
||||
private static getEnvironment(r: any): IEnvironmentOverview {
|
||||
private static getEnvironment(r: any): FeatureSearchEnvironmentSchema {
|
||||
return {
|
||||
name: r.environment,
|
||||
enabled: r.enabled,
|
||||
@ -59,6 +60,8 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
lastSeenAt: r.env_last_seen_at,
|
||||
hasStrategies: r.has_strategies,
|
||||
hasEnabledStrategies: r.has_enabled_strategies,
|
||||
yes: Number(r.yes) || 0,
|
||||
no: Number(r.no) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -87,6 +90,55 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
.with('ranked_features', (query) => {
|
||||
query.from('features');
|
||||
|
||||
let selectColumns = [
|
||||
'features.name as feature_name',
|
||||
'features.description as description',
|
||||
'features.type as type',
|
||||
'features.project as project',
|
||||
'features.created_at as created_at',
|
||||
'features.stale as stale',
|
||||
'features.last_seen_at as last_seen_at',
|
||||
'features.impression_data as impression_data',
|
||||
'feature_environments.enabled as enabled',
|
||||
'feature_environments.environment as environment',
|
||||
'feature_environments.variants as variants',
|
||||
'environments.type as environment_type',
|
||||
'environments.sort_order as environment_sort_order',
|
||||
'ft.tag_value as tag_value',
|
||||
'ft.tag_type as tag_type',
|
||||
'segments.name as segment_name',
|
||||
'client_metrics_env.yes as yes',
|
||||
'client_metrics_env.no as no',
|
||||
] as (string | Raw<any> | Knex.QueryBuilder)[];
|
||||
|
||||
const lastSeenQuery = 'last_seen_at_metrics.last_seen_at';
|
||||
selectColumns.push(`${lastSeenQuery} as env_last_seen_at`);
|
||||
|
||||
if (userId) {
|
||||
query.leftJoin(`favorite_features`, function () {
|
||||
this.on(
|
||||
'favorite_features.feature',
|
||||
'features.name',
|
||||
).andOnVal('favorite_features.user_id', '=', userId);
|
||||
});
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
this.db.raw(
|
||||
'favorite_features.feature is not null as favorite',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
this.db.raw(
|
||||
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
|
||||
),
|
||||
this.db.raw(
|
||||
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
|
||||
),
|
||||
];
|
||||
|
||||
applyQueryParams(query, queryParams);
|
||||
applySearchFilters(query, searchParams, [
|
||||
'features.name',
|
||||
@ -144,7 +196,24 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
'segments',
|
||||
'feature_strategy_segment.segment_id',
|
||||
'segments.id',
|
||||
);
|
||||
)
|
||||
.leftJoin('client_metrics_env', (qb) => {
|
||||
qb.on(
|
||||
'client_metrics_env.environment',
|
||||
'=',
|
||||
'environments.name',
|
||||
)
|
||||
.andOn(
|
||||
'client_metrics_env.feature_name',
|
||||
'=',
|
||||
'features.name',
|
||||
)
|
||||
.andOn(
|
||||
'client_metrics_env.timestamp',
|
||||
'>=',
|
||||
this.db.raw("NOW() - INTERVAL '1 hour'"),
|
||||
);
|
||||
});
|
||||
|
||||
query.leftJoin('last_seen_at_metrics', function () {
|
||||
this.on(
|
||||
@ -158,89 +227,12 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
);
|
||||
});
|
||||
|
||||
let selectColumns = [
|
||||
'features.name as feature_name',
|
||||
'features.description as description',
|
||||
'features.type as type',
|
||||
'features.project as project',
|
||||
'features.created_at as created_at',
|
||||
'features.stale as stale',
|
||||
'features.last_seen_at as last_seen_at',
|
||||
'features.impression_data as impression_data',
|
||||
'feature_environments.enabled as enabled',
|
||||
'feature_environments.environment as environment',
|
||||
'feature_environments.variants as variants',
|
||||
'environments.type as environment_type',
|
||||
'environments.sort_order as environment_sort_order',
|
||||
'ft.tag_value as tag_value',
|
||||
'ft.tag_type as tag_type',
|
||||
'segments.name as segment_name',
|
||||
] as (string | Raw<any> | Knex.QueryBuilder)[];
|
||||
|
||||
const lastSeenQuery = 'last_seen_at_metrics.last_seen_at';
|
||||
selectColumns.push(`${lastSeenQuery} as env_last_seen_at`);
|
||||
|
||||
if (userId) {
|
||||
query.leftJoin(`favorite_features`, function () {
|
||||
this.on(
|
||||
'favorite_features.feature',
|
||||
'features.name',
|
||||
).andOnVal('favorite_features.user_id', '=', userId);
|
||||
});
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
this.db.raw(
|
||||
'favorite_features.feature is not null as favorite',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
this.db.raw(
|
||||
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
|
||||
),
|
||||
this.db.raw(
|
||||
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
|
||||
),
|
||||
];
|
||||
|
||||
const sortByMapping = {
|
||||
name: 'features.name',
|
||||
type: 'features.type',
|
||||
stale: 'features.stale',
|
||||
project: 'features.project',
|
||||
};
|
||||
|
||||
let rankingSql = 'order by ';
|
||||
if (favoritesFirst) {
|
||||
rankingSql +=
|
||||
'favorite_features.feature is not null desc, ';
|
||||
}
|
||||
|
||||
if (sortBy.startsWith('environment:')) {
|
||||
const [, envName] = sortBy.split(':');
|
||||
rankingSql += this.db
|
||||
.raw(
|
||||
`CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc, features.name asc`,
|
||||
[envName],
|
||||
)
|
||||
.toString();
|
||||
} else if (sortBy === 'lastSeenAt') {
|
||||
rankingSql += `${this.db
|
||||
.raw(
|
||||
`coalesce(${lastSeenQuery}, features.last_seen_at) ${validatedSortOrder} nulls last`,
|
||||
)
|
||||
.toString()}, features.created_at asc, features.name asc`;
|
||||
} else if (sortByMapping[sortBy]) {
|
||||
rankingSql += `${this.db
|
||||
.raw(`?? ${validatedSortOrder}`, [
|
||||
sortByMapping[sortBy],
|
||||
])
|
||||
.toString()}, features.created_at asc, features.name asc`;
|
||||
} else {
|
||||
rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`;
|
||||
}
|
||||
const rankingSql = this.buildRankingSql(
|
||||
favoritesFirst,
|
||||
sortBy,
|
||||
validatedSortOrder,
|
||||
lastSeenQuery,
|
||||
);
|
||||
|
||||
query
|
||||
.select(selectColumns)
|
||||
@ -282,9 +274,51 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
||||
};
|
||||
}
|
||||
|
||||
getAggregatedSearchData(rows): IFeatureOverview[] {
|
||||
const entriesMap: Map<string, IFeatureOverview> = new Map();
|
||||
const orderedEntries: IFeatureOverview[] = [];
|
||||
private buildRankingSql(
|
||||
favoritesFirst: undefined | boolean,
|
||||
sortBy: string,
|
||||
validatedSortOrder: 'asc' | 'desc',
|
||||
lastSeenQuery: string,
|
||||
) {
|
||||
const sortByMapping = {
|
||||
name: 'features.name',
|
||||
type: 'features.type',
|
||||
stale: 'features.stale',
|
||||
project: 'features.project',
|
||||
};
|
||||
|
||||
let rankingSql = 'order by ';
|
||||
if (favoritesFirst) {
|
||||
rankingSql += 'favorite_features.feature is not null desc, ';
|
||||
}
|
||||
|
||||
if (sortBy.startsWith('environment:')) {
|
||||
const [, envName] = sortBy.split(':');
|
||||
rankingSql += this.db
|
||||
.raw(
|
||||
`CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc, features.name asc`,
|
||||
[envName],
|
||||
)
|
||||
.toString();
|
||||
} else if (sortBy === 'lastSeenAt') {
|
||||
rankingSql += `${this.db
|
||||
.raw(
|
||||
`coalesce(${lastSeenQuery}, features.last_seen_at) ${validatedSortOrder} nulls last`,
|
||||
)
|
||||
.toString()}, features.created_at asc, features.name asc`;
|
||||
} else if (sortByMapping[sortBy]) {
|
||||
rankingSql += `${this.db
|
||||
.raw(`?? ${validatedSortOrder}`, [sortByMapping[sortBy]])
|
||||
.toString()}, features.created_at asc, features.name asc`;
|
||||
} else {
|
||||
rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`;
|
||||
}
|
||||
return rankingSql;
|
||||
}
|
||||
|
||||
getAggregatedSearchData(rows): IFeatureSearchOverview[] {
|
||||
const entriesMap: Map<string, IFeatureSearchOverview> = new Map();
|
||||
const orderedEntries: IFeatureSearchOverview[] = [];
|
||||
|
||||
rows.forEach((row) => {
|
||||
let entry = entriesMap.get(row.feature_name);
|
||||
|
@ -923,3 +923,57 @@ test('should filter features by combined operators', async () => {
|
||||
features: [{ name: 'my_feature_a' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('should return environment usage metrics', async () => {
|
||||
await app.createFeature({
|
||||
name: 'my_feature_b',
|
||||
createdAt: '2023-01-29T15:21:39.975Z',
|
||||
});
|
||||
|
||||
await stores.clientMetricsStoreV2.batchInsertMetrics([
|
||||
{
|
||||
featureName: `my_feature_b`,
|
||||
appName: `web`,
|
||||
environment: 'development',
|
||||
timestamp: new Date(),
|
||||
yes: 5,
|
||||
no: 2,
|
||||
},
|
||||
{
|
||||
featureName: `my_feature_b`,
|
||||
appName: `web`,
|
||||
environment: 'production',
|
||||
timestamp: new Date(),
|
||||
yes: 2,
|
||||
no: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
const { body } = await searchFeatures({
|
||||
query: 'my_feature_b',
|
||||
});
|
||||
expect(body).toMatchObject({
|
||||
features: [
|
||||
{
|
||||
name: 'my_feature_b',
|
||||
environments: [
|
||||
{
|
||||
name: 'default',
|
||||
yes: 0,
|
||||
no: 0,
|
||||
},
|
||||
{
|
||||
name: 'development',
|
||||
yes: 5,
|
||||
no: 2,
|
||||
},
|
||||
{
|
||||
name: 'production',
|
||||
yes: 2,
|
||||
no: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
46
src/lib/openapi/spec/feature-search-environment-schema.ts
Normal file
46
src/lib/openapi/spec/feature-search-environment-schema.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
import { constraintSchema } from './constraint-schema';
|
||||
import { parametersSchema } from './parameters-schema';
|
||||
import { featureStrategySchema } from './feature-strategy-schema';
|
||||
import { variantSchema } from './variant-schema';
|
||||
import { strategyVariantSchema } from './strategy-variant-schema';
|
||||
import { featureEnvironmentSchema } from './feature-environment-schema';
|
||||
|
||||
export const featureSearchEnvironmentSchema = {
|
||||
$id: '#/components/schemas/featureSearchEnvironmentSchema',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['name', 'enabled'],
|
||||
description: 'A detailed description of the feature environment',
|
||||
properties: {
|
||||
...featureEnvironmentSchema.properties,
|
||||
yes: {
|
||||
description:
|
||||
'How many times the toggle evaluated to true in last hour bucket',
|
||||
type: 'integer',
|
||||
example: 974,
|
||||
minimum: 0,
|
||||
},
|
||||
no: {
|
||||
description:
|
||||
'How many times the toggle evaluated to false in last hour bucket',
|
||||
type: 'integer',
|
||||
example: 50,
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
constraintSchema,
|
||||
parametersSchema,
|
||||
featureStrategySchema,
|
||||
strategyVariantSchema,
|
||||
featureEnvironmentSchema,
|
||||
variantSchema,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type FeatureSearchEnvironmentSchema = FromSchema<
|
||||
typeof featureSearchEnvironmentSchema
|
||||
>;
|
@ -5,8 +5,8 @@ import { overrideSchema } from './override-schema';
|
||||
import { parametersSchema } from './parameters-schema';
|
||||
import { featureStrategySchema } from './feature-strategy-schema';
|
||||
import { tagSchema } from './tag-schema';
|
||||
import { featureEnvironmentSchema } from './feature-environment-schema';
|
||||
import { strategyVariantSchema } from './strategy-variant-schema';
|
||||
import { featureSearchEnvironmentSchema } from './feature-search-environment-schema';
|
||||
|
||||
export const featureSearchResponseSchema = {
|
||||
$id: '#/components/schemas/featureSearchResponseSchema',
|
||||
@ -92,7 +92,7 @@ export const featureSearchResponseSchema = {
|
||||
environments: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/featureEnvironmentSchema',
|
||||
$ref: '#/components/schemas/featureSearchEnvironmentSchema',
|
||||
},
|
||||
description:
|
||||
'The list of environments where the feature can be used',
|
||||
@ -174,7 +174,7 @@ export const featureSearchResponseSchema = {
|
||||
components: {
|
||||
schemas: {
|
||||
constraintSchema,
|
||||
featureEnvironmentSchema,
|
||||
featureSearchEnvironmentSchema,
|
||||
featureStrategySchema,
|
||||
strategyVariantSchema,
|
||||
overrideSchema,
|
||||
|
@ -77,6 +77,7 @@ export * from './feature-environment-schema';
|
||||
export * from './feature-events-schema';
|
||||
export * from './feature-metrics-schema';
|
||||
export * from './feature-schema';
|
||||
export * from './feature-search-environment-schema';
|
||||
export * from './feature-search-response-schema';
|
||||
export * from './feature-strategy-schema';
|
||||
export * from './feature-strategy-segment-schema';
|
||||
|
@ -4,10 +4,10 @@ import { variantSchema } from './variant-schema';
|
||||
import { overrideSchema } from './override-schema';
|
||||
import { featureStrategySchema } from './feature-strategy-schema';
|
||||
import { constraintSchema } from './constraint-schema';
|
||||
import { featureEnvironmentSchema } from './feature-environment-schema';
|
||||
import { strategyVariantSchema } from './strategy-variant-schema';
|
||||
import { tagSchema } from './tag-schema';
|
||||
import { featureSearchResponseSchema } from './feature-search-response-schema';
|
||||
import { featureSearchEnvironmentSchema } from './feature-search-environment-schema';
|
||||
|
||||
export const searchFeaturesSchema = {
|
||||
$id: '#/components/schemas/searchFeaturesSchema',
|
||||
@ -33,9 +33,9 @@ export const searchFeaturesSchema = {
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
featureSearchEnvironmentSchema,
|
||||
featureSearchResponseSchema,
|
||||
constraintSchema,
|
||||
featureEnvironmentSchema,
|
||||
featureStrategySchema,
|
||||
strategyVariantSchema,
|
||||
overrideSchema,
|
||||
|
@ -6,6 +6,7 @@ import type { ALL_OPERATORS } from '../util';
|
||||
import type { IProjectStats } from '../features/project/project-service';
|
||||
import type { CreateFeatureStrategySchema } from '../openapi';
|
||||
import type { ProjectEnvironment } from '../features/project/project-store-type';
|
||||
import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-search-environment-schema';
|
||||
|
||||
export type Operator = (typeof ALL_OPERATORS)[number];
|
||||
|
||||
@ -218,6 +219,13 @@ export interface IFeatureOverview {
|
||||
environments: IEnvironmentOverview[];
|
||||
}
|
||||
|
||||
export type IFeatureSearchOverview = Exclude<
|
||||
IFeatureOverview,
|
||||
'environments'
|
||||
> & {
|
||||
environments: FeatureSearchEnvironmentSchema[];
|
||||
};
|
||||
|
||||
export interface IFeatureTypeCount {
|
||||
type: string;
|
||||
count: number;
|
||||
|
Loading…
Reference in New Issue
Block a user