1
0
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:
Jaanus Sellin 2024-03-25 10:32:19 +02:00 committed by GitHub
parent d22ecd0c73
commit a2a9a84974
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 237 additions and 94 deletions

View File

@ -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);

View File

@ -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,
},
],
},
],
});
});

View 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
>;

View File

@ -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,

View File

@ -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';

View File

@ -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,

View File

@ -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;