1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02:00

feat: new event search (#7708)

This introduces the new event search API, with paging.
This commit is contained in:
Jaanus Sellin 2024-08-02 10:56:42 +03:00 committed by GitHub
parent 5cd657065f
commit bcb7a803d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 494 additions and 106 deletions

View File

@ -3,11 +3,14 @@ import type { IFeatureTagStore, IUnleashStores } from '../../types/stores';
import type { Logger } from '../../logger'; import type { Logger } from '../../logger';
import type { IEventStore } from '../../types/stores/event-store'; import type { IEventStore } from '../../types/stores/event-store';
import type { IBaseEvent, IEventList } from '../../types/events'; import type { IBaseEvent, IEventList } from '../../types/events';
import type { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; import type { DeprecatedSearchEventsSchema } from '../../openapi/spec/deprecated-search-events-schema';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import type { IApiUser, ITag, IUser } from '../../types'; import type { IApiUser, ITag, IUser } from '../../types';
import { ApiTokenType } from '../../types/models/api-token'; import { ApiTokenType } from '../../types/models/api-token';
import { EVENTS_CREATED_BY_PROCESSED } from '../../metric-events'; import { EVENTS_CREATED_BY_PROCESSED } from '../../metric-events';
import type { EventSearchQueryParameters } from '../../openapi/spec/event-search-query-parameters';
import type { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type';
import { parseSearchOperatorValue } from '../feature-search/search-utils';
export default class EventService { export default class EventService {
private logger: Logger; private logger: Logger;
@ -40,9 +43,38 @@ export default class EventService {
}; };
} }
async searchEvents(search: SearchEventsSchema): Promise<IEventList> { async deprecatedSearchEvents(
const totalEvents = await this.eventStore.filteredCount(search); search: DeprecatedSearchEventsSchema,
const events = await this.eventStore.searchEvents(search); ): Promise<IEventList> {
const totalEvents =
await this.eventStore.deprecatedFilteredCount(search);
const events = await this.eventStore.deprecatedSearchEvents(search);
return {
events,
totalEvents,
};
}
async searchEvents(
search: EventSearchQueryParameters,
): Promise<IEventList> {
const queryParams = this.convertToDbParams(search);
const totalEvents = await this.eventStore.searchEventsCount(
{
limit: search.limit,
offset: search.offset,
query: search.query,
},
queryParams,
);
const events = await this.eventStore.searchEvents(
{
limit: search.limit,
offset: search.offset,
query: search.query,
},
queryParams,
);
return { return {
events, events,
totalEvents, totalEvents,
@ -110,4 +142,46 @@ export default class EventService {
}); });
} }
} }
convertToDbParams = (params: EventSearchQueryParameters): IQueryParam[] => {
const queryParams: IQueryParam[] = [];
if (params.createdAtFrom) {
queryParams.push({
field: 'created_at',
operator: 'IS_ON_OR_AFTER',
values: [params.createdAtFrom],
});
}
if (params.createdAtTo) {
queryParams.push({
field: 'created_at',
operator: 'IS_BEFORE',
values: [params.createdAtTo],
});
}
if (params.createdBy) {
const parsed = parseSearchOperatorValue(
'created_by',
params.createdBy,
);
if (parsed) queryParams.push(parsed);
}
if (params.type) {
const parsed = parseSearchOperatorValue('type', params.type);
if (parsed) queryParams.push(parsed);
}
['feature', 'project'].forEach((field) => {
if (params[field]) {
const parsed = parseSearchOperatorValue(field, params[field]);
if (parsed) queryParams.push(parsed);
}
});
return queryParams;
};
} }

View File

@ -27,7 +27,9 @@ test('Trying to get events by name if db fails should yield empty list', async (
client: 'pg', client: 'pg',
}); });
const store = new EventStore(db, getLogger); const store = new EventStore(db, getLogger);
const events = await store.searchEvents({ type: 'application-created' }); const events = await store.deprecatedSearchEvents({
type: 'application-created',
});
expect(events).toBeTruthy(); expect(events).toBeTruthy();
expect(events.length).toBe(0); expect(events.length).toBe(0);
await db.destroy(); await db.destroy();

View File

@ -9,12 +9,14 @@ import {
import type { Logger, LogProvider } from '../../logger'; import type { Logger, LogProvider } from '../../logger';
import type { IEventStore } from '../../types/stores/event-store'; import type { IEventStore } from '../../types/stores/event-store';
import type { ITag } from '../../types/model'; import type { ITag } from '../../types/model';
import type { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
import { sharedEventEmitter } from '../../util/anyEventEmitter'; import { sharedEventEmitter } from '../../util/anyEventEmitter';
import type { Db } from '../../db/db'; import type { Db } from '../../db/db';
import type { Knex } from 'knex'; import type { Knex } from 'knex';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import { ADMIN_TOKEN_USER, SYSTEM_USER_ID } from '../../types'; import { ADMIN_TOKEN_USER, SYSTEM_USER_ID } from '../../types';
import type { DeprecatedSearchEventsSchema } from '../../openapi';
import type { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type';
import { applyGenericQueryParams } from '../feature-search/search-utils';
const EVENT_COLUMNS = [ const EVENT_COLUMNS = [
'id', 'id',
@ -85,6 +87,12 @@ export interface IEventTable {
tags: ITag[]; tags: ITag[];
} }
export interface IEventSearchParams {
query: string | undefined;
offset: string | undefined;
limit: string | undefined;
}
const TABLE = 'events'; const TABLE = 'events';
class EventStore implements IEventStore { class EventStore implements IEventStore {
@ -125,7 +133,9 @@ class EventStore implements IEventStore {
} }
} }
async filteredCount(eventSearch: SearchEventsSchema): Promise<number> { async deprecatedFilteredCount(
eventSearch: DeprecatedSearchEventsSchema,
): Promise<number> {
let query = this.db(TABLE); let query = this.db(TABLE);
if (eventSearch.type) { if (eventSearch.type) {
query = query.andWhere({ type: eventSearch.type }); query = query.andWhere({ type: eventSearch.type });
@ -147,6 +157,22 @@ class EventStore implements IEventStore {
} }
} }
async searchEventsCount(
params: IEventSearchParams,
queryParams: IQueryParam[],
): Promise<number> {
const query = this.buildSearchQuery(params, queryParams);
const count = await query.count().first();
if (!count) {
return 0;
}
if (typeof count.count === 'string') {
return Number.parseInt(count.count, 10);
} else {
return count.count;
}
}
async batchStore(events: IBaseEvent[]): Promise<void> { async batchStore(events: IBaseEvent[]): Promise<void> {
try { try {
await this.db(TABLE).insert(events.map(this.eventToDbRow)); await this.db(TABLE).insert(events.map(this.eventToDbRow));
@ -320,7 +346,46 @@ class EventStore implements IEventStore {
} }
} }
async searchEvents(search: SearchEventsSchema = {}): Promise<IEvent[]> { async searchEvents(
params: IEventSearchParams,
queryParams: IQueryParam[],
): Promise<IEvent[]> {
const query = this.buildSearchQuery(params, queryParams)
.select(EVENT_COLUMNS)
.orderBy('created_at', 'desc')
.limit(Number(params.limit) ?? 100)
.offset(Number(params.offset) ?? 0);
try {
return (await query).map(this.rowToEvent);
} catch (err) {
return [];
}
}
private buildSearchQuery(
params: IEventSearchParams,
queryParams: IQueryParam[],
) {
let query = this.db.from<IEventTable>(TABLE);
applyGenericQueryParams(query, queryParams);
if (params.query) {
query = query.where((where) =>
where
.orWhereRaw('data::text ILIKE ?', `%${params.query}%`)
.orWhereRaw('tags::text ILIKE ?', `%${params.query}%`)
.orWhereRaw('pre_data::text ILIKE ?', `%${params.query}%`),
);
}
return query;
}
async deprecatedSearchEvents(
search: DeprecatedSearchEventsSchema = {},
): Promise<IEvent[]> {
let query = this.db let query = this.db
.select(EVENT_COLUMNS) .select(EVENT_COLUMNS)
.from<IEventTable>(TABLE) .from<IEventTable>(TABLE)

View File

@ -104,19 +104,26 @@ export default class FeatureSearchController extends Controller {
state, state,
status, status,
favoritesFirst, favoritesFirst,
sortBy,
} = req.query; } = req.query;
const userId = req.user.id; const userId = req.user.id;
const { const {
normalizedQuery, normalizedQuery,
normalizedSortBy,
normalizedSortOrder, normalizedSortOrder,
normalizedOffset, normalizedOffset,
normalizedLimit, normalizedLimit,
} = normalizeQueryParams(req.query, { } = normalizeQueryParams(
limitDefault: 50, {
maxLimit: 100, query,
sortByDefault: 'createdAt', offset: req.query.offset,
}); limit: req.query.limit,
sortOrder: req.query.sortOrder,
},
{
limitDefault: 50,
maxLimit: 100,
},
);
const normalizedStatus = status const normalizedStatus = status
?.map((tag) => tag.split(':')) ?.map((tag) => tag.split(':'))
@ -136,10 +143,10 @@ export default class FeatureSearchController extends Controller {
state, state,
createdAt, createdAt,
createdBy, createdBy,
sortBy,
status: normalizedStatus, status: normalizedStatus,
offset: normalizedOffset, offset: normalizedOffset,
limit: normalizedLimit, limit: normalizedLimit,
sortBy: normalizedSortBy,
sortOrder: normalizedSortOrder, sortOrder: normalizedSortOrder,
favoritesFirst: normalizedFavoritesFirst, favoritesFirst: normalizedFavoritesFirst,
}); });

View File

@ -6,9 +6,9 @@ import type {
} from '../../types'; } from '../../types';
import type { import type {
IFeatureSearchParams, IFeatureSearchParams,
IQueryOperator,
IQueryParam, IQueryParam,
} from '../feature-toggle/types/feature-toggle-strategies-store-type'; } from '../feature-toggle/types/feature-toggle-strategies-store-type';
import { parseSearchOperatorValue } from './search-utils';
export class FeatureSearchService { export class FeatureSearchService {
private featureSearchStore: IFeatureSearchStore; private featureSearchStore: IFeatureSearchStore;
@ -28,6 +28,7 @@ export class FeatureSearchService {
{ {
...params, ...params,
limit: params.limit, limit: params.limit,
sortBy: params.sortBy || 'createdAt',
}, },
queryParams, queryParams,
); );
@ -38,27 +39,11 @@ export class FeatureSearchService {
}; };
} }
parseOperatorValue = (field: string, value: string): IQueryParam | null => {
const pattern =
/^(IS|IS_NOT|IS_ANY_OF|IS_NONE_OF|INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL|IS_BEFORE|IS_ON_OR_AFTER):(.+)$/;
const match = value.match(pattern);
if (match) {
return {
field,
operator: match[1] as IQueryOperator,
values: match[2].split(','),
};
}
return null;
};
convertToQueryParams = (params: IFeatureSearchParams): IQueryParam[] => { convertToQueryParams = (params: IFeatureSearchParams): IQueryParam[] => {
const queryParams: IQueryParam[] = []; const queryParams: IQueryParam[] = [];
if (params.state) { if (params.state) {
const parsedState = this.parseOperatorValue('stale', params.state); const parsedState = parseSearchOperatorValue('stale', params.state);
if (parsedState) { if (parsedState) {
parsedState.values = parsedState.values.map((value) => parsedState.values = parsedState.values.map((value) =>
value === 'active' ? 'false' : 'true', value === 'active' ? 'false' : 'true',
@ -68,7 +53,7 @@ export class FeatureSearchService {
} }
if (params.createdAt) { if (params.createdAt) {
const parsed = this.parseOperatorValue( const parsed = parseSearchOperatorValue(
'features.created_at', 'features.created_at',
params.createdAt, params.createdAt,
); );
@ -76,7 +61,7 @@ export class FeatureSearchService {
} }
if (params.createdBy) { if (params.createdBy) {
const parsed = this.parseOperatorValue( const parsed = parseSearchOperatorValue(
'users.id', 'users.id',
params.createdBy, params.createdBy,
); );
@ -84,7 +69,7 @@ export class FeatureSearchService {
} }
if (params.type) { if (params.type) {
const parsed = this.parseOperatorValue( const parsed = parseSearchOperatorValue(
'features.type', 'features.type',
params.type, params.type,
); );
@ -93,7 +78,7 @@ export class FeatureSearchService {
['tag', 'segment', 'project'].forEach((field) => { ['tag', 'segment', 'project'].forEach((field) => {
if (params[field]) { if (params[field]) {
const parsed = this.parseOperatorValue(field, params[field]); const parsed = parseSearchOperatorValue(field, params[field]);
if (parsed) queryParams.push(parsed); if (parsed) queryParams.push(parsed);
} }
}); });

View File

@ -475,7 +475,7 @@ test('should sort features', async () => {
const { body: defaultCreatedAt } = await sortFeatures({ const { body: defaultCreatedAt } = await sortFeatures({
sortBy: '', sortBy: '',
sortOrder: '', sortOrder: 'asc',
}); });
expect(defaultCreatedAt).toMatchObject({ expect(defaultCreatedAt).toMatchObject({

View File

@ -1,13 +1,29 @@
import type { Knex } from 'knex'; import type { Knex } from 'knex';
import type { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type'; import type {
IQueryOperator,
IQueryParam,
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
export interface NormalizeParamsDefaults { export interface NormalizeParamsDefaults {
limitDefault: number; limitDefault: number;
maxLimit?: number; // Optional because you might not always want to enforce a max limit maxLimit?: number; // Optional because you might not always want to enforce a max limit
sortByDefault: string;
typeDefault?: string; // Optional field for type, not required for every call typeDefault?: string; // Optional field for type, not required for every call
} }
export type SearchParams = {
query?: string;
offset?: string | number;
limit?: string | number;
sortOrder?: 'asc' | 'desc';
};
export type NormalizedSearchParams = {
normalizedQuery?: string[];
normalizedLimit: number;
normalizedOffset: number;
normalizedSortOrder: 'asc' | 'desc';
};
export const applySearchFilters = ( export const applySearchFilters = (
qb: Knex.QueryBuilder, qb: Knex.QueryBuilder,
searchParams: string[] | undefined, searchParams: string[] | undefined,
@ -54,16 +70,10 @@ export const applyGenericQueryParams = (
}; };
export const normalizeQueryParams = ( export const normalizeQueryParams = (
params, params: SearchParams,
defaults: NormalizeParamsDefaults, defaults: NormalizeParamsDefaults,
) => { ): NormalizedSearchParams => {
const { const { query, offset, limit = defaults.limitDefault, sortOrder } = params;
query,
offset,
limit = defaults.limitDefault,
sortOrder,
sortBy = defaults.sortByDefault,
} = params;
const normalizedQuery = query const normalizedQuery = query
?.split(',') ?.split(',')
@ -78,7 +88,6 @@ export const normalizeQueryParams = (
const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0; const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0;
const normalizedSortBy = sortBy;
const normalizedSortOrder = const normalizedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
@ -86,7 +95,25 @@ export const normalizeQueryParams = (
normalizedQuery, normalizedQuery,
normalizedLimit, normalizedLimit,
normalizedOffset, normalizedOffset,
normalizedSortBy,
normalizedSortOrder, normalizedSortOrder,
}; };
}; };
export const parseSearchOperatorValue = (
field: string,
value: string,
): IQueryParam | null => {
const pattern =
/^(IS|IS_NOT|IS_ANY_OF|IS_NONE_OF|INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL|IS_BEFORE|IS_ON_OR_AFTER):(.+)$/;
const match = value.match(pattern);
if (match) {
return {
field,
operator: match[1] as IQueryOperator,
values: match[2].split(','),
};
}
return null;
};

View File

@ -34,7 +34,7 @@ export interface IFeatureSearchParams {
offset: number; offset: number;
favoritesFirst?: boolean; favoritesFirst?: boolean;
limit: number; limit: number;
sortBy: string; sortBy?: string;
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
} }

View File

@ -269,8 +269,12 @@ export class InstanceStatsService {
this.hasSAML(), this.hasSAML(),
this.hasOIDC(), this.hasOIDC(),
this.appCount ? this.appCount : this.refreshAppCountSnapshot(), this.appCount ? this.appCount : this.refreshAppCountSnapshot(),
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }), this.eventStore.deprecatedFilteredCount({
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }), type: FEATURES_EXPORTED,
}),
this.eventStore.deprecatedFilteredCount({
type: FEATURES_IMPORTED,
}),
this.getProductionChanges(), this.getProductionChanges(),
this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets(), this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets(),
]); ]);

View File

@ -177,8 +177,9 @@ export default class ClientInstanceService {
query: IClientApplicationsSearchParams, query: IClientApplicationsSearchParams,
userId: number, userId: number,
): Promise<IClientApplications> { ): Promise<IClientApplications> {
const applications = const applications = await this.clientApplicationsStore.getApplications(
await this.clientApplicationsStore.getApplications(query); { ...query, sortBy: query.sortBy || 'appName' },
);
const accessibleProjects = const accessibleProjects =
await this.privateProjectChecker.getUserAccessibleProjects(userId); await this.privateProjectChecker.getUserAccessibleProjects(userId);
if (accessibleProjects.mode === 'all') { if (accessibleProjects.mode === 'all') {

View File

@ -325,14 +325,12 @@ export default class ProjectController extends Controller {
const { const {
normalizedQuery, normalizedQuery,
normalizedSortBy,
normalizedSortOrder, normalizedSortOrder,
normalizedOffset, normalizedOffset,
normalizedLimit, normalizedLimit,
} = normalizeQueryParams(req.query, { } = normalizeQueryParams(req.query, {
limitDefault: 50, limitDefault: 50,
maxLimit: 100, maxLimit: 100,
sortByDefault: 'appName',
}); });
const applications = await this.projectService.getApplications({ const applications = await this.projectService.getApplications({
@ -340,7 +338,7 @@ export default class ProjectController extends Controller {
project: projectId, project: projectId,
offset: normalizedOffset, offset: normalizedOffset,
limit: normalizedLimit, limit: normalizedLimit,
sortBy: normalizedSortBy, sortBy: req.query.sortBy,
sortOrder: normalizedSortOrder, sortOrder: normalizedSortOrder,
}); });

View File

@ -1117,8 +1117,10 @@ export default class ProjectService {
async getApplications( async getApplications(
searchParams: IProjectApplicationsSearchParams, searchParams: IProjectApplicationsSearchParams,
): Promise<IProjectApplications> { ): Promise<IProjectApplications> {
const applications = const applications = await this.projectStore.getApplicationsByProject({
await this.projectStore.getApplicationsByProject(searchParams); ...searchParams,
sortBy: searchParams.sortBy || 'appName',
});
return applications; return applications;
} }

View File

@ -1,8 +1,8 @@
import type { FromSchema } from 'json-schema-to-ts'; import type { FromSchema } from 'json-schema-to-ts';
import { IEventTypes } from '../../types'; import { IEventTypes } from '../../types';
export const searchEventsSchema = { export const deprecatedSearchEventsSchema = {
$id: '#/components/schemas/searchEventsSchema', $id: '#/components/schemas/deprecatedSearchEventsSchema',
type: 'object', type: 'object',
description: ` description: `
Search for events by type, project, feature, free-text query, Search for events by type, project, feature, free-text query,
@ -50,4 +50,6 @@ export const searchEventsSchema = {
components: {}, components: {},
} as const; } as const;
export type SearchEventsSchema = FromSchema<typeof searchEventsSchema>; export type DeprecatedSearchEventsSchema = FromSchema<
typeof deprecatedSearchEventsSchema
>;

View File

@ -0,0 +1,105 @@
import type { FromQueryParams } from '../util/from-query-params';
export const eventSearchQueryParameters = [
{
name: 'query',
schema: {
type: 'string',
example: 'admin@example.com',
},
description:
'Find events by a free-text search query. The query will be matched against the event type and the event data payload (if any).',
in: 'query',
},
{
name: 'feature',
schema: {
type: 'string',
example: 'IS:myfeature',
pattern: '^(IS|IS_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$',
},
description:
'Filter by feature name using supported operators: IS, IS_ANY_OF',
in: 'query',
},
{
name: 'project',
schema: {
type: 'string',
example: 'IS:default',
pattern: '^(IS|IS_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$',
},
description:
'Filter by projects ID using supported operators: IS, IS_ANY_OF.',
in: 'query',
},
{
name: 'type',
schema: {
type: 'string',
example: 'IS:change-added',
pattern: '^(IS|IS_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$',
},
description:
'Filter by event type using supported operators: IS, IS_ANY_OF.',
in: 'query',
},
{
name: 'createdBy',
schema: {
type: 'string',
example: 'IS:2',
pattern: '^(IS|IS_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$',
},
description:
'The ID of event creator to filter by. The creators can be specified with an operator. The supported operators are IS, IS_ANY_OF.',
in: 'query',
},
{
name: 'createdAtFrom',
schema: {
type: 'string',
example: 'IS:2024-01-01',
pattern: '^(IS):\\d{4}-\\d{2}-\\d{2}$',
},
description:
'The starting date of the creation date range in IS:yyyy-MM-dd format',
in: 'query',
},
{
name: 'createdAtTo',
schema: {
type: 'string',
example: 'IS:2024-01-31',
pattern: '^(IS):\\d{4}-\\d{2}-\\d{2}$',
},
description:
'The ending date of the creation date range in IS:yyyy-MM-dd format',
in: 'query',
},
{
name: 'offset',
schema: {
type: 'string',
example: '50',
},
description:
'The number of features to skip when returning a page. By default it is set to 0.',
in: 'query',
},
{
name: 'limit',
schema: {
type: 'string',
example: '50',
},
description:
'The number of feature environments to return in a page. By default it is set to 50.',
in: 'query',
},
] as const;
export type EventSearchQueryParameters = Partial<
FromQueryParams<typeof eventSearchQueryParameters>
>;

View File

@ -0,0 +1,34 @@
import type { FromSchema } from 'json-schema-to-ts';
import { eventSchema } from './event-schema';
import { tagSchema } from './tag-schema';
export const eventSearchResponseSchema = {
$id: '#/components/schemas/eventsSearchResponseSchema',
type: 'object',
additionalProperties: false,
required: ['events', 'total'],
description: 'A list of events that have been registered by the system',
properties: {
events: {
description: 'The list of events',
type: 'array',
items: { $ref: eventSchema.$id },
},
total: {
type: 'integer',
description: 'The total count of events',
minimum: 0,
example: 842,
},
},
components: {
schemas: {
eventSchema,
tagSchema,
},
},
} as const;
export type EventSearchResponseSchema = FromSchema<
typeof eventSearchResponseSchema
>;

View File

@ -129,6 +129,7 @@ export const featureSearchQueryParameters = [
name: 'sortOrder', name: 'sortOrder',
schema: { schema: {
type: 'string', type: 'string',
enum: ['asc', 'desc'] as any,
example: 'desc', example: 'desc',
}, },
description: description:

View File

@ -61,6 +61,7 @@ export * from './date-schema';
export * from './dependencies-exist-schema'; export * from './dependencies-exist-schema';
export * from './dependent-feature-schema'; export * from './dependent-feature-schema';
export * from './deprecated-project-overview-schema'; export * from './deprecated-project-overview-schema';
export * from './deprecated-search-events-schema';
export * from './dora-features-schema'; export * from './dora-features-schema';
export * from './edge-token-schema'; export * from './edge-token-schema';
export * from './email-schema'; export * from './email-schema';
@ -69,6 +70,7 @@ export * from './environment-schema';
export * from './environments-project-schema'; export * from './environments-project-schema';
export * from './environments-schema'; export * from './environments-schema';
export * from './event-schema'; export * from './event-schema';
export * from './event-search-response-schema';
export * from './events-schema'; export * from './events-schema';
export * from './export-query-schema'; export * from './export-query-schema';
export * from './export-result-schema'; export * from './export-result-schema';
@ -161,7 +163,6 @@ export * from './role-schema';
export * from './roles-schema'; export * from './roles-schema';
export * from './sdk-context-schema'; export * from './sdk-context-schema';
export * from './sdk-flat-context-schema'; export * from './sdk-flat-context-schema';
export * from './search-events-schema';
export * from './search-features-schema'; export * from './search-features-schema';
export * from './segment-schema'; export * from './segment-schema';
export * from './segment-strategies-schema'; export * from './segment-strategies-schema';

View File

@ -19,8 +19,18 @@ import {
} from '../../../lib/openapi/spec/feature-events-schema'; } from '../../../lib/openapi/spec/feature-events-schema';
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses'; import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { createRequestSchema } from '../../openapi/util/create-request-schema';
import type { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; import type { DeprecatedSearchEventsSchema } from '../../openapi/spec/deprecated-search-events-schema';
import type { IFlagResolver } from '../../types/experimental'; import type { IFlagResolver } from '../../types/experimental';
import {
type EventSearchQueryParameters,
eventSearchQueryParameters,
} from '../../openapi/spec/event-search-query-parameters';
import type { IAuthRequest } from '../unleash-types';
import {
type EventSearchResponseSchema,
eventSearchResponseSchema,
} from '../../openapi';
import { normalizeQueryParams } from '../../features/feature-search/search-utils';
const ANON_KEYS = ['email', 'username', 'createdBy']; const ANON_KEYS = ['email', 'username', 'createdBy'];
const version = 1 as const; const version = 1 as const;
@ -98,6 +108,27 @@ export default class EventController extends Controller {
this.route({ this.route({
method: 'post', method: 'post',
path: '/search', path: '/search',
handler: this.deprecatedSearchEvents,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'deprecatedSearchEvents',
tags: ['Events'],
deprecated: true,
summary: 'Search for events (deprecated)',
description:
'Allows searching for events matching the search criteria in the request body',
requestBody: createRequestSchema(
'deprecatedSearchEventsSchema',
),
responses: { 200: createResponseSchema('eventsSchema') },
}),
],
});
this.route({
method: 'get',
path: '/search',
handler: this.searchEvents, handler: this.searchEvents,
permission: NONE, permission: NONE,
middleware: [ middleware: [
@ -106,9 +137,11 @@ export default class EventController extends Controller {
tags: ['Events'], tags: ['Events'],
summary: 'Search for events', summary: 'Search for events',
description: description:
'Allows searching for events matching the search criteria in the request body', 'Allows searching for events matching the search criteria in the request body. This operation is deprecated. You should perform a GET request to the same endpoint with your query encoded as query parameters instead.',
requestBody: createRequestSchema('searchEventsSchema'), parameters: [...eventSearchQueryParameters],
responses: { 200: createResponseSchema('eventsSchema') }, responses: {
200: createResponseSchema('eventSearchResponseSchema'),
},
}), }),
], ],
}); });
@ -128,7 +161,9 @@ export default class EventController extends Controller {
const { project } = req.query; const { project } = req.query;
let eventList: IEventList; let eventList: IEventList;
if (project) { if (project) {
eventList = await this.eventService.searchEvents({ project }); eventList = await this.eventService.deprecatedSearchEvents({
project,
});
} else { } else {
eventList = await this.eventService.getEvents(); eventList = await this.eventService.getEvents();
} }
@ -152,7 +187,9 @@ export default class EventController extends Controller {
res: Response<FeatureEventsSchema>, res: Response<FeatureEventsSchema>,
): Promise<void> { ): Promise<void> {
const feature = req.params.featureName; const feature = req.params.featureName;
const eventList = await this.eventService.searchEvents({ feature }); const eventList = await this.eventService.deprecatedSearchEvents({
feature,
});
const response = { const response = {
version, version,
@ -169,11 +206,13 @@ export default class EventController extends Controller {
); );
} }
async searchEvents( async deprecatedSearchEvents(
req: Request<unknown, unknown, SearchEventsSchema>, req: Request<unknown, unknown, DeprecatedSearchEventsSchema>,
res: Response<EventsSchema>, res: Response<EventsSchema>,
): Promise<void> { ): Promise<void> {
const eventList = await this.eventService.searchEvents(req.body); const eventList = await this.eventService.deprecatedSearchEvents(
req.body,
);
const response = { const response = {
version, version,
@ -188,4 +227,33 @@ export default class EventController extends Controller {
response, response,
); );
} }
async searchEvents(
req: IAuthRequest<any, any, any, EventSearchQueryParameters>,
res: Response<EventSearchResponseSchema>,
): Promise<void> {
const { normalizedLimit, normalizedOffset } = normalizeQueryParams(
req.query,
{
limitDefault: 50,
maxLimit: 1000,
},
);
const { events, totalEvents } = await this.eventService.searchEvents({
...req.body,
offset: normalizedOffset,
limit: normalizedLimit,
});
this.openApiService.respondWithValidation(
200,
res,
eventSearchResponseSchema.$id,
serializeDates({
events: serializeDates(this.maybeAnonymiseEvents(events)),
total: totalEvents,
}),
);
}
} }

View File

@ -246,14 +246,12 @@ class MetricsController extends Controller {
const { user } = req; const { user } = req;
const { const {
normalizedQuery, normalizedQuery,
normalizedSortBy,
normalizedSortOrder, normalizedSortOrder,
normalizedOffset, normalizedOffset,
normalizedLimit, normalizedLimit,
} = normalizeQueryParams(req.query, { } = normalizeQueryParams(req.query, {
limitDefault: 1000, limitDefault: 1000,
maxLimit: 1000, maxLimit: 1000,
sortByDefault: 'appName',
}); });
const applications = await this.clientInstanceService.getApplications( const applications = await this.clientInstanceService.getApplications(
@ -261,7 +259,7 @@ class MetricsController extends Controller {
searchParams: normalizedQuery, searchParams: normalizedQuery,
offset: normalizedOffset, offset: normalizedOffset,
limit: normalizedLimit, limit: normalizedLimit,
sortBy: normalizedSortBy, sortBy: req.query.sortBy,
sortOrder: normalizedSortOrder, sortOrder: normalizedSortOrder,
}, },
extractUserIdFromUser(user), extractUserIdFromUser(user),

View File

@ -280,8 +280,12 @@ export default class VersionService {
this.strategyStore.count(), this.strategyStore.count(),
this.hasSAML(), this.hasSAML(),
this.hasOIDC(), this.hasOIDC(),
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }), this.eventStore.deprecatedFilteredCount({
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }), type: FEATURES_EXPORTED,
}),
this.eventStore.deprecatedFilteredCount({
type: FEATURES_IMPORTED,
}),
this.userStats(), this.userStats(),
this.productionChanges(), this.productionChanges(),
this.postgresVersion(), this.postgresVersion(),

View File

@ -1,8 +1,12 @@
import type { IBaseEvent, IEvent } from '../events'; import type { IBaseEvent, IEvent } from '../events';
import type { Store } from './store'; import type { Store } from './store';
import type { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; import type { DeprecatedSearchEventsSchema } from '../../openapi';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import type { IQueryOperations } from '../../features/events/event-store'; import type {
IEventSearchParams,
IQueryOperations,
} from '../../features/events/event-store';
import type { IQueryParam } from '../../features/feature-toggle/types/feature-toggle-strategies-store-type';
export interface IEventStore export interface IEventStore
extends Store<IEvent, number>, extends Store<IEvent, number>,
@ -12,8 +16,20 @@ export interface IEventStore
batchStore(events: IBaseEvent[]): Promise<void>; batchStore(events: IBaseEvent[]): Promise<void>;
getEvents(): Promise<IEvent[]>; getEvents(): Promise<IEvent[]>;
count(): Promise<number>; count(): Promise<number>;
filteredCount(search: SearchEventsSchema): Promise<number>; deprecatedFilteredCount(
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>; search: DeprecatedSearchEventsSchema,
): Promise<number>;
searchEventsCount(
params: IEventSearchParams,
queryParams: IQueryParam[],
): Promise<number>;
deprecatedSearchEvents(
search: DeprecatedSearchEventsSchema,
): Promise<IEvent[]>;
searchEvents(
params: IEventSearchParams,
queryParams: IQueryParam[],
): Promise<IEvent[]>;
getMaxRevisionId(currentMax?: number): Promise<number>; getMaxRevisionId(currentMax?: number): Promise<number>;
query(operations: IQueryOperations[]): Promise<IEvent[]>; query(operations: IQueryOperations[]): Promise<IEvent[]>;
queryCount(operations: IQueryOperations[]): Promise<number>; queryCount(operations: IQueryOperations[]): Promise<number>;

View File

@ -36,7 +36,7 @@ test('Can create new setting', async () => {
expect(actual).toStrictEqual(someData); expect(actual).toStrictEqual(someData);
const { eventStore } = stores; const { eventStore } = stores;
const createdEvents = await eventStore.searchEvents({ const createdEvents = await eventStore.deprecatedSearchEvents({
type: SETTING_CREATED, type: SETTING_CREATED,
}); });
expect(createdEvents).toHaveLength(1); expect(createdEvents).toHaveLength(1);
@ -51,7 +51,7 @@ test('Can delete setting', async () => {
const actual = await service.get('some-setting'); const actual = await service.get('some-setting');
expect(actual).toBeUndefined(); expect(actual).toBeUndefined();
const { eventStore } = stores; const { eventStore } = stores;
const createdEvents = await eventStore.searchEvents({ const createdEvents = await eventStore.deprecatedSearchEvents({
type: SETTING_DELETED, type: SETTING_DELETED,
}); });
expect(createdEvents).toHaveLength(1); expect(createdEvents).toHaveLength(1);
@ -66,7 +66,7 @@ test('Sentitive SSO settings are redacted in event log', async () => {
const actual = await service.get(property); const actual = await service.get(property);
const { eventStore } = stores; const { eventStore } = stores;
const updatedEvents = await eventStore.searchEvents({ const updatedEvents = await eventStore.deprecatedSearchEvents({
type: SETTING_UPDATED, type: SETTING_UPDATED,
}); });
expect(updatedEvents[0].preData).toEqual({ hideEventDetails: true }); expect(updatedEvents[0].preData).toEqual({ hideEventDetails: true });
@ -83,7 +83,7 @@ test('Can update setting', async () => {
TEST_AUDIT_USER, TEST_AUDIT_USER,
false, false,
); );
const updatedEvents = await eventStore.searchEvents({ const updatedEvents = await eventStore.deprecatedSearchEvents({
type: SETTING_UPDATED, type: SETTING_UPDATED,
}); });
expect(updatedEvents).toHaveLength(1); expect(updatedEvents).toHaveLength(1);

View File

@ -234,11 +234,11 @@ test('Should get all events of type', async () => {
return eventStore.store(event); return eventStore.store(event);
}), }),
); );
const featureCreatedEvents = await eventStore.searchEvents({ const featureCreatedEvents = await eventStore.deprecatedSearchEvents({
type: FEATURE_CREATED, type: FEATURE_CREATED,
}); });
expect(featureCreatedEvents).toHaveLength(3); expect(featureCreatedEvents).toHaveLength(3);
const featureDeletedEvents = await eventStore.searchEvents({ const featureDeletedEvents = await eventStore.deprecatedSearchEvents({
type: FEATURE_DELETED, type: FEATURE_DELETED,
}); });
expect(featureDeletedEvents).toHaveLength(3); expect(featureDeletedEvents).toHaveLength(3);

View File

@ -2,7 +2,7 @@ import type { IEventStore } from '../../lib/types/stores/event-store';
import type { IBaseEvent, IEvent } from '../../lib/types/events'; import type { IBaseEvent, IEvent } from '../../lib/types/events';
import { sharedEventEmitter } from '../../lib/util/anyEventEmitter'; import { sharedEventEmitter } from '../../lib/util/anyEventEmitter';
import type { IQueryOperations } from '../../lib/features/events/event-store'; import type { IQueryOperations } from '../../lib/features/events/event-store';
import type { SearchEventsSchema } from '../../lib/openapi'; import type { DeprecatedSearchEventsSchema } from '../../lib/openapi';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
class FakeEventStore implements IEventStore { class FakeEventStore implements IEventStore {
@ -61,7 +61,13 @@ class FakeEventStore implements IEventStore {
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
filteredCount(search: SearchEventsSchema): Promise<number> { searchEventsCount(): Promise<number> {
return Promise.resolve(0);
}
deprecatedFilteredCount(
search: DeprecatedSearchEventsSchema,
): Promise<number> {
return Promise.resolve(0); return Promise.resolve(0);
} }
@ -79,23 +85,11 @@ class FakeEventStore implements IEventStore {
return this.events; return this.events;
} }
async searchEvents(): Promise<IEvent[]> { async deprecatedSearchEvents(): Promise<IEvent[]> {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
async searchEvents(): Promise<IEvent[]> {
async getForFeatures( throw new Error('Method not implemented.');
features: string[],
environments: string[],
query: { type: string; projectId: string },
): Promise<IEvent[]> {
return this.events.filter((event) => {
return (
event.type === query.type &&
event.project === query.projectId &&
features.includes(event.data.featureName) &&
environments.includes(event.data.environment)
);
});
} }
async query(operations: IQueryOperations[]): Promise<IEvent[]> { async query(operations: IQueryOperations[]): Promise<IEvent[]> {