mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
feat: new event search (#7708)
This introduces the new event search API, with paging.
This commit is contained in:
parent
5cd657065f
commit
bcb7a803d0
@ -3,11 +3,14 @@ import type { IFeatureTagStore, IUnleashStores } from '../../types/stores';
|
||||
import type { Logger } from '../../logger';
|
||||
import type { IEventStore } from '../../types/stores/event-store';
|
||||
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 { IApiUser, ITag, IUser } from '../../types';
|
||||
import { ApiTokenType } from '../../types/models/api-token';
|
||||
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 {
|
||||
private logger: Logger;
|
||||
@ -40,9 +43,38 @@ export default class EventService {
|
||||
};
|
||||
}
|
||||
|
||||
async searchEvents(search: SearchEventsSchema): Promise<IEventList> {
|
||||
const totalEvents = await this.eventStore.filteredCount(search);
|
||||
const events = await this.eventStore.searchEvents(search);
|
||||
async deprecatedSearchEvents(
|
||||
search: DeprecatedSearchEventsSchema,
|
||||
): 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 {
|
||||
events,
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
@ -27,7 +27,9 @@ test('Trying to get events by name if db fails should yield empty list', async (
|
||||
client: 'pg',
|
||||
});
|
||||
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.length).toBe(0);
|
||||
await db.destroy();
|
||||
|
@ -9,12 +9,14 @@ import {
|
||||
import type { Logger, LogProvider } from '../../logger';
|
||||
import type { IEventStore } from '../../types/stores/event-store';
|
||||
import type { ITag } from '../../types/model';
|
||||
import type { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
|
||||
import { sharedEventEmitter } from '../../util/anyEventEmitter';
|
||||
import type { Db } from '../../db/db';
|
||||
import type { Knex } from 'knex';
|
||||
import type EventEmitter from 'events';
|
||||
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 = [
|
||||
'id',
|
||||
@ -85,6 +87,12 @@ export interface IEventTable {
|
||||
tags: ITag[];
|
||||
}
|
||||
|
||||
export interface IEventSearchParams {
|
||||
query: string | undefined;
|
||||
offset: string | undefined;
|
||||
limit: string | undefined;
|
||||
}
|
||||
|
||||
const TABLE = 'events';
|
||||
|
||||
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);
|
||||
if (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> {
|
||||
try {
|
||||
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
|
||||
.select(EVENT_COLUMNS)
|
||||
.from<IEventTable>(TABLE)
|
||||
|
@ -104,19 +104,26 @@ export default class FeatureSearchController extends Controller {
|
||||
state,
|
||||
status,
|
||||
favoritesFirst,
|
||||
sortBy,
|
||||
} = req.query;
|
||||
const userId = req.user.id;
|
||||
const {
|
||||
normalizedQuery,
|
||||
normalizedSortBy,
|
||||
normalizedSortOrder,
|
||||
normalizedOffset,
|
||||
normalizedLimit,
|
||||
} = normalizeQueryParams(req.query, {
|
||||
limitDefault: 50,
|
||||
maxLimit: 100,
|
||||
sortByDefault: 'createdAt',
|
||||
});
|
||||
} = normalizeQueryParams(
|
||||
{
|
||||
query,
|
||||
offset: req.query.offset,
|
||||
limit: req.query.limit,
|
||||
sortOrder: req.query.sortOrder,
|
||||
},
|
||||
{
|
||||
limitDefault: 50,
|
||||
maxLimit: 100,
|
||||
},
|
||||
);
|
||||
|
||||
const normalizedStatus = status
|
||||
?.map((tag) => tag.split(':'))
|
||||
@ -136,10 +143,10 @@ export default class FeatureSearchController extends Controller {
|
||||
state,
|
||||
createdAt,
|
||||
createdBy,
|
||||
sortBy,
|
||||
status: normalizedStatus,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
sortBy: normalizedSortBy,
|
||||
sortOrder: normalizedSortOrder,
|
||||
favoritesFirst: normalizedFavoritesFirst,
|
||||
});
|
||||
|
@ -6,9 +6,9 @@ import type {
|
||||
} from '../../types';
|
||||
import type {
|
||||
IFeatureSearchParams,
|
||||
IQueryOperator,
|
||||
IQueryParam,
|
||||
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||
import { parseSearchOperatorValue } from './search-utils';
|
||||
|
||||
export class FeatureSearchService {
|
||||
private featureSearchStore: IFeatureSearchStore;
|
||||
@ -28,6 +28,7 @@ export class FeatureSearchService {
|
||||
{
|
||||
...params,
|
||||
limit: params.limit,
|
||||
sortBy: params.sortBy || 'createdAt',
|
||||
},
|
||||
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[] => {
|
||||
const queryParams: IQueryParam[] = [];
|
||||
|
||||
if (params.state) {
|
||||
const parsedState = this.parseOperatorValue('stale', params.state);
|
||||
const parsedState = parseSearchOperatorValue('stale', params.state);
|
||||
if (parsedState) {
|
||||
parsedState.values = parsedState.values.map((value) =>
|
||||
value === 'active' ? 'false' : 'true',
|
||||
@ -68,7 +53,7 @@ export class FeatureSearchService {
|
||||
}
|
||||
|
||||
if (params.createdAt) {
|
||||
const parsed = this.parseOperatorValue(
|
||||
const parsed = parseSearchOperatorValue(
|
||||
'features.created_at',
|
||||
params.createdAt,
|
||||
);
|
||||
@ -76,7 +61,7 @@ export class FeatureSearchService {
|
||||
}
|
||||
|
||||
if (params.createdBy) {
|
||||
const parsed = this.parseOperatorValue(
|
||||
const parsed = parseSearchOperatorValue(
|
||||
'users.id',
|
||||
params.createdBy,
|
||||
);
|
||||
@ -84,7 +69,7 @@ export class FeatureSearchService {
|
||||
}
|
||||
|
||||
if (params.type) {
|
||||
const parsed = this.parseOperatorValue(
|
||||
const parsed = parseSearchOperatorValue(
|
||||
'features.type',
|
||||
params.type,
|
||||
);
|
||||
@ -93,7 +78,7 @@ export class FeatureSearchService {
|
||||
|
||||
['tag', 'segment', 'project'].forEach((field) => {
|
||||
if (params[field]) {
|
||||
const parsed = this.parseOperatorValue(field, params[field]);
|
||||
const parsed = parseSearchOperatorValue(field, params[field]);
|
||||
if (parsed) queryParams.push(parsed);
|
||||
}
|
||||
});
|
||||
|
@ -475,7 +475,7 @@ test('should sort features', async () => {
|
||||
|
||||
const { body: defaultCreatedAt } = await sortFeatures({
|
||||
sortBy: '',
|
||||
sortOrder: '',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
||||
expect(defaultCreatedAt).toMatchObject({
|
||||
|
@ -1,13 +1,29 @@
|
||||
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 {
|
||||
limitDefault: number;
|
||||
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
|
||||
}
|
||||
|
||||
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 = (
|
||||
qb: Knex.QueryBuilder,
|
||||
searchParams: string[] | undefined,
|
||||
@ -54,16 +70,10 @@ export const applyGenericQueryParams = (
|
||||
};
|
||||
|
||||
export const normalizeQueryParams = (
|
||||
params,
|
||||
params: SearchParams,
|
||||
defaults: NormalizeParamsDefaults,
|
||||
) => {
|
||||
const {
|
||||
query,
|
||||
offset,
|
||||
limit = defaults.limitDefault,
|
||||
sortOrder,
|
||||
sortBy = defaults.sortByDefault,
|
||||
} = params;
|
||||
): NormalizedSearchParams => {
|
||||
const { query, offset, limit = defaults.limitDefault, sortOrder } = params;
|
||||
|
||||
const normalizedQuery = query
|
||||
?.split(',')
|
||||
@ -78,7 +88,6 @@ export const normalizeQueryParams = (
|
||||
|
||||
const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0;
|
||||
|
||||
const normalizedSortBy = sortBy;
|
||||
const normalizedSortOrder =
|
||||
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
|
||||
|
||||
@ -86,7 +95,25 @@ export const normalizeQueryParams = (
|
||||
normalizedQuery,
|
||||
normalizedLimit,
|
||||
normalizedOffset,
|
||||
normalizedSortBy,
|
||||
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;
|
||||
};
|
||||
|
@ -34,7 +34,7 @@ export interface IFeatureSearchParams {
|
||||
offset: number;
|
||||
favoritesFirst?: boolean;
|
||||
limit: number;
|
||||
sortBy: string;
|
||||
sortBy?: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
|
@ -269,8 +269,12 @@ export class InstanceStatsService {
|
||||
this.hasSAML(),
|
||||
this.hasOIDC(),
|
||||
this.appCount ? this.appCount : this.refreshAppCountSnapshot(),
|
||||
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }),
|
||||
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }),
|
||||
this.eventStore.deprecatedFilteredCount({
|
||||
type: FEATURES_EXPORTED,
|
||||
}),
|
||||
this.eventStore.deprecatedFilteredCount({
|
||||
type: FEATURES_IMPORTED,
|
||||
}),
|
||||
this.getProductionChanges(),
|
||||
this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets(),
|
||||
]);
|
||||
|
@ -177,8 +177,9 @@ export default class ClientInstanceService {
|
||||
query: IClientApplicationsSearchParams,
|
||||
userId: number,
|
||||
): Promise<IClientApplications> {
|
||||
const applications =
|
||||
await this.clientApplicationsStore.getApplications(query);
|
||||
const applications = await this.clientApplicationsStore.getApplications(
|
||||
{ ...query, sortBy: query.sortBy || 'appName' },
|
||||
);
|
||||
const accessibleProjects =
|
||||
await this.privateProjectChecker.getUserAccessibleProjects(userId);
|
||||
if (accessibleProjects.mode === 'all') {
|
||||
|
@ -325,14 +325,12 @@ export default class ProjectController extends Controller {
|
||||
|
||||
const {
|
||||
normalizedQuery,
|
||||
normalizedSortBy,
|
||||
normalizedSortOrder,
|
||||
normalizedOffset,
|
||||
normalizedLimit,
|
||||
} = normalizeQueryParams(req.query, {
|
||||
limitDefault: 50,
|
||||
maxLimit: 100,
|
||||
sortByDefault: 'appName',
|
||||
});
|
||||
|
||||
const applications = await this.projectService.getApplications({
|
||||
@ -340,7 +338,7 @@ export default class ProjectController extends Controller {
|
||||
project: projectId,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
sortBy: normalizedSortBy,
|
||||
sortBy: req.query.sortBy,
|
||||
sortOrder: normalizedSortOrder,
|
||||
});
|
||||
|
||||
|
@ -1117,8 +1117,10 @@ export default class ProjectService {
|
||||
async getApplications(
|
||||
searchParams: IProjectApplicationsSearchParams,
|
||||
): Promise<IProjectApplications> {
|
||||
const applications =
|
||||
await this.projectStore.getApplicationsByProject(searchParams);
|
||||
const applications = await this.projectStore.getApplicationsByProject({
|
||||
...searchParams,
|
||||
sortBy: searchParams.sortBy || 'appName',
|
||||
});
|
||||
return applications;
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import type { FromSchema } from 'json-schema-to-ts';
|
||||
import { IEventTypes } from '../../types';
|
||||
|
||||
export const searchEventsSchema = {
|
||||
$id: '#/components/schemas/searchEventsSchema',
|
||||
export const deprecatedSearchEventsSchema = {
|
||||
$id: '#/components/schemas/deprecatedSearchEventsSchema',
|
||||
type: 'object',
|
||||
description: `
|
||||
Search for events by type, project, feature, free-text query,
|
||||
@ -50,4 +50,6 @@ export const searchEventsSchema = {
|
||||
components: {},
|
||||
} as const;
|
||||
|
||||
export type SearchEventsSchema = FromSchema<typeof searchEventsSchema>;
|
||||
export type DeprecatedSearchEventsSchema = FromSchema<
|
||||
typeof deprecatedSearchEventsSchema
|
||||
>;
|
105
src/lib/openapi/spec/event-search-query-parameters.ts
Normal file
105
src/lib/openapi/spec/event-search-query-parameters.ts
Normal 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>
|
||||
>;
|
34
src/lib/openapi/spec/event-search-response-schema.ts
Normal file
34
src/lib/openapi/spec/event-search-response-schema.ts
Normal 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
|
||||
>;
|
@ -129,6 +129,7 @@ export const featureSearchQueryParameters = [
|
||||
name: 'sortOrder',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['asc', 'desc'] as any,
|
||||
example: 'desc',
|
||||
},
|
||||
description:
|
||||
|
@ -61,6 +61,7 @@ export * from './date-schema';
|
||||
export * from './dependencies-exist-schema';
|
||||
export * from './dependent-feature-schema';
|
||||
export * from './deprecated-project-overview-schema';
|
||||
export * from './deprecated-search-events-schema';
|
||||
export * from './dora-features-schema';
|
||||
export * from './edge-token-schema';
|
||||
export * from './email-schema';
|
||||
@ -69,6 +70,7 @@ export * from './environment-schema';
|
||||
export * from './environments-project-schema';
|
||||
export * from './environments-schema';
|
||||
export * from './event-schema';
|
||||
export * from './event-search-response-schema';
|
||||
export * from './events-schema';
|
||||
export * from './export-query-schema';
|
||||
export * from './export-result-schema';
|
||||
@ -161,7 +163,6 @@ export * from './role-schema';
|
||||
export * from './roles-schema';
|
||||
export * from './sdk-context-schema';
|
||||
export * from './sdk-flat-context-schema';
|
||||
export * from './search-events-schema';
|
||||
export * from './search-features-schema';
|
||||
export * from './segment-schema';
|
||||
export * from './segment-strategies-schema';
|
||||
|
@ -19,8 +19,18 @@ import {
|
||||
} from '../../../lib/openapi/spec/feature-events-schema';
|
||||
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
|
||||
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 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 version = 1 as const;
|
||||
@ -98,6 +108,27 @@ export default class EventController extends Controller {
|
||||
this.route({
|
||||
method: 'post',
|
||||
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,
|
||||
permission: NONE,
|
||||
middleware: [
|
||||
@ -106,9 +137,11 @@ export default class EventController extends Controller {
|
||||
tags: ['Events'],
|
||||
summary: 'Search for events',
|
||||
description:
|
||||
'Allows searching for events matching the search criteria in the request body',
|
||||
requestBody: createRequestSchema('searchEventsSchema'),
|
||||
responses: { 200: createResponseSchema('eventsSchema') },
|
||||
'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.',
|
||||
parameters: [...eventSearchQueryParameters],
|
||||
responses: {
|
||||
200: createResponseSchema('eventSearchResponseSchema'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -128,7 +161,9 @@ export default class EventController extends Controller {
|
||||
const { project } = req.query;
|
||||
let eventList: IEventList;
|
||||
if (project) {
|
||||
eventList = await this.eventService.searchEvents({ project });
|
||||
eventList = await this.eventService.deprecatedSearchEvents({
|
||||
project,
|
||||
});
|
||||
} else {
|
||||
eventList = await this.eventService.getEvents();
|
||||
}
|
||||
@ -152,7 +187,9 @@ export default class EventController extends Controller {
|
||||
res: Response<FeatureEventsSchema>,
|
||||
): Promise<void> {
|
||||
const feature = req.params.featureName;
|
||||
const eventList = await this.eventService.searchEvents({ feature });
|
||||
const eventList = await this.eventService.deprecatedSearchEvents({
|
||||
feature,
|
||||
});
|
||||
|
||||
const response = {
|
||||
version,
|
||||
@ -169,11 +206,13 @@ export default class EventController extends Controller {
|
||||
);
|
||||
}
|
||||
|
||||
async searchEvents(
|
||||
req: Request<unknown, unknown, SearchEventsSchema>,
|
||||
async deprecatedSearchEvents(
|
||||
req: Request<unknown, unknown, DeprecatedSearchEventsSchema>,
|
||||
res: Response<EventsSchema>,
|
||||
): Promise<void> {
|
||||
const eventList = await this.eventService.searchEvents(req.body);
|
||||
const eventList = await this.eventService.deprecatedSearchEvents(
|
||||
req.body,
|
||||
);
|
||||
|
||||
const response = {
|
||||
version,
|
||||
@ -188,4 +227,33 @@ export default class EventController extends Controller {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -246,14 +246,12 @@ class MetricsController extends Controller {
|
||||
const { user } = req;
|
||||
const {
|
||||
normalizedQuery,
|
||||
normalizedSortBy,
|
||||
normalizedSortOrder,
|
||||
normalizedOffset,
|
||||
normalizedLimit,
|
||||
} = normalizeQueryParams(req.query, {
|
||||
limitDefault: 1000,
|
||||
maxLimit: 1000,
|
||||
sortByDefault: 'appName',
|
||||
});
|
||||
|
||||
const applications = await this.clientInstanceService.getApplications(
|
||||
@ -261,7 +259,7 @@ class MetricsController extends Controller {
|
||||
searchParams: normalizedQuery,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
sortBy: normalizedSortBy,
|
||||
sortBy: req.query.sortBy,
|
||||
sortOrder: normalizedSortOrder,
|
||||
},
|
||||
extractUserIdFromUser(user),
|
||||
|
@ -280,8 +280,12 @@ export default class VersionService {
|
||||
this.strategyStore.count(),
|
||||
this.hasSAML(),
|
||||
this.hasOIDC(),
|
||||
this.eventStore.filteredCount({ type: FEATURES_EXPORTED }),
|
||||
this.eventStore.filteredCount({ type: FEATURES_IMPORTED }),
|
||||
this.eventStore.deprecatedFilteredCount({
|
||||
type: FEATURES_EXPORTED,
|
||||
}),
|
||||
this.eventStore.deprecatedFilteredCount({
|
||||
type: FEATURES_IMPORTED,
|
||||
}),
|
||||
this.userStats(),
|
||||
this.productionChanges(),
|
||||
this.postgresVersion(),
|
||||
|
@ -1,8 +1,12 @@
|
||||
import type { IBaseEvent, IEvent } from '../events';
|
||||
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 { 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
|
||||
extends Store<IEvent, number>,
|
||||
@ -12,8 +16,20 @@ export interface IEventStore
|
||||
batchStore(events: IBaseEvent[]): Promise<void>;
|
||||
getEvents(): Promise<IEvent[]>;
|
||||
count(): Promise<number>;
|
||||
filteredCount(search: SearchEventsSchema): Promise<number>;
|
||||
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
|
||||
deprecatedFilteredCount(
|
||||
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>;
|
||||
query(operations: IQueryOperations[]): Promise<IEvent[]>;
|
||||
queryCount(operations: IQueryOperations[]): Promise<number>;
|
||||
|
@ -36,7 +36,7 @@ test('Can create new setting', async () => {
|
||||
|
||||
expect(actual).toStrictEqual(someData);
|
||||
const { eventStore } = stores;
|
||||
const createdEvents = await eventStore.searchEvents({
|
||||
const createdEvents = await eventStore.deprecatedSearchEvents({
|
||||
type: SETTING_CREATED,
|
||||
});
|
||||
expect(createdEvents).toHaveLength(1);
|
||||
@ -51,7 +51,7 @@ test('Can delete setting', async () => {
|
||||
const actual = await service.get('some-setting');
|
||||
expect(actual).toBeUndefined();
|
||||
const { eventStore } = stores;
|
||||
const createdEvents = await eventStore.searchEvents({
|
||||
const createdEvents = await eventStore.deprecatedSearchEvents({
|
||||
type: SETTING_DELETED,
|
||||
});
|
||||
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 { eventStore } = stores;
|
||||
|
||||
const updatedEvents = await eventStore.searchEvents({
|
||||
const updatedEvents = await eventStore.deprecatedSearchEvents({
|
||||
type: SETTING_UPDATED,
|
||||
});
|
||||
expect(updatedEvents[0].preData).toEqual({ hideEventDetails: true });
|
||||
@ -83,7 +83,7 @@ test('Can update setting', async () => {
|
||||
TEST_AUDIT_USER,
|
||||
false,
|
||||
);
|
||||
const updatedEvents = await eventStore.searchEvents({
|
||||
const updatedEvents = await eventStore.deprecatedSearchEvents({
|
||||
type: SETTING_UPDATED,
|
||||
});
|
||||
expect(updatedEvents).toHaveLength(1);
|
||||
|
@ -234,11 +234,11 @@ test('Should get all events of type', async () => {
|
||||
return eventStore.store(event);
|
||||
}),
|
||||
);
|
||||
const featureCreatedEvents = await eventStore.searchEvents({
|
||||
const featureCreatedEvents = await eventStore.deprecatedSearchEvents({
|
||||
type: FEATURE_CREATED,
|
||||
});
|
||||
expect(featureCreatedEvents).toHaveLength(3);
|
||||
const featureDeletedEvents = await eventStore.searchEvents({
|
||||
const featureDeletedEvents = await eventStore.deprecatedSearchEvents({
|
||||
type: FEATURE_DELETED,
|
||||
});
|
||||
expect(featureDeletedEvents).toHaveLength(3);
|
||||
|
28
src/test/fixtures/fake-event-store.ts
vendored
28
src/test/fixtures/fake-event-store.ts
vendored
@ -2,7 +2,7 @@ import type { IEventStore } from '../../lib/types/stores/event-store';
|
||||
import type { IBaseEvent, IEvent } from '../../lib/types/events';
|
||||
import { sharedEventEmitter } from '../../lib/util/anyEventEmitter';
|
||||
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';
|
||||
|
||||
class FakeEventStore implements IEventStore {
|
||||
@ -61,7 +61,13 @@ class FakeEventStore implements IEventStore {
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@ -79,23 +85,11 @@ class FakeEventStore implements IEventStore {
|
||||
return this.events;
|
||||
}
|
||||
|
||||
async searchEvents(): Promise<IEvent[]> {
|
||||
async deprecatedSearchEvents(): Promise<IEvent[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
async getForFeatures(
|
||||
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 searchEvents(): Promise<IEvent[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
async query(operations: IQueryOperations[]): Promise<IEvent[]> {
|
||||
|
Loading…
Reference in New Issue
Block a user