diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index 07ca8ec180..323550dd91 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -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 | 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 | 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 = 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 = new Map(); + const orderedEntries: IFeatureSearchOverview[] = []; rows.forEach((row) => { let entry = entriesMap.get(row.feature_name); diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index b0b06ed9a4..0271d5ab5f 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -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, + }, + ], + }, + ], + }); +}); diff --git a/src/lib/openapi/spec/feature-search-environment-schema.ts b/src/lib/openapi/spec/feature-search-environment-schema.ts new file mode 100644 index 0000000000..f266c30f77 --- /dev/null +++ b/src/lib/openapi/spec/feature-search-environment-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/feature-search-response-schema.ts b/src/lib/openapi/spec/feature-search-response-schema.ts index ae655fac0f..d6268b0797 100644 --- a/src/lib/openapi/spec/feature-search-response-schema.ts +++ b/src/lib/openapi/spec/feature-search-response-schema.ts @@ -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, diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 2f8bcf4ecb..1c5c251019 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -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'; diff --git a/src/lib/openapi/spec/search-features-schema.ts b/src/lib/openapi/spec/search-features-schema.ts index 592aaf6d87..b0610ba769 100644 --- a/src/lib/openapi/spec/search-features-schema.ts +++ b/src/lib/openapi/spec/search-features-schema.ts @@ -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, diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 9be2d0836c..7af1ed22f5 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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;