Example of Integration of timeline with EventQuery (#25452)

* add two widget for hosts pages

* fix spelling mistake

* add pr review

* Add _id to the EventsQuery

* integrate query with timeline + test

* PR review-1

* add explanation commented text

* remove unuseful tslint garbage
This commit is contained in:
Xavier Mouligneau 2018-11-08 22:54:02 -05:00 committed by Andrew Goldstein
parent b671b9df3f
commit c2425229e8
No known key found for this signature in database
GPG key ID: 42995DC9117D52CE
19 changed files with 303 additions and 208 deletions

View file

@ -414,6 +414,14 @@
"name": "EventItem",
"description": "",
"fields": [
{
"name": "_id",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "String", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "destination",
"description": "",

View file

@ -66,6 +66,7 @@ export interface KpiItem {
}
export interface EventItem {
_id?: string | null;
destination?: DestinationEcsFields | null;
event?: EventEcsFields | null;
geo?: GeoEcsFields | null;
@ -304,6 +305,7 @@ export namespace KpiItemResolvers {
export namespace EventItemResolvers {
export interface Resolvers<Context = any> {
_id?: IdResolver<string | null, any, Context>;
destination?: DestinationResolver<DestinationEcsFields | null, any, Context>;
event?: EventResolver<EventEcsFields | null, any, Context>;
geo?: GeoResolver<GeoEcsFields | null, any, Context>;
@ -313,6 +315,11 @@ export namespace EventItemResolvers {
timestamp?: TimestampResolver<string | null, any, Context>;
}
export type IdResolver<R = string | null, Parent = any, Context = any> = Resolver<
R,
Parent,
Context
>;
export type DestinationResolver<
R = DestinationEcsFields | null,
Parent = any,
@ -549,6 +556,7 @@ export namespace GetEventsQuery {
export type Events = {
__typename?: 'EventItem';
_id?: string | null;
timestamp?: string | null;
event?: Event | null;
host?: Host | null;

View file

@ -9,7 +9,6 @@ import { noop } from 'lodash/fp';
import * as React from 'react';
import { Body } from '.';
import { mockECSData } from '../../../pages/mock/mock_ecs';
import { mockDataProviders } from '../data_providers/mock/mock_data_providers';
import { headers } from './column_headers/headers';
import { columnRenderers, rowRenderers } from './renderers';
import { Sort } from './sort';
@ -27,7 +26,6 @@ describe('ColumnHeaders', () => {
columnHeaders={headers}
columnRenderers={columnRenderers}
data={mockECSData}
dataProviders={mockDataProviders}
sort={sort}
onColumnSorted={noop}
onDataProviderRemoved={noop}

View file

@ -10,7 +10,6 @@ import { pure } from 'recompose';
import styled from 'styled-components';
import { Range } from '../body/column_headers/range_picker/ranges';
import { DataProvider } from '../data_providers/data_provider';
import { ECS } from '../ecs';
import { OnColumnSorted, OnDataProviderRemoved, OnFilterChange, OnRangeSelected } from '../events';
import { ColumnHeaders } from './column_headers';
@ -22,7 +21,6 @@ interface Props {
columnHeaders: ColumnHeader[];
columnRenderers: ColumnRenderer[];
data: ECS[];
dataProviders: DataProvider[];
height?: string;
onColumnSorted: OnColumnSorted;
onDataProviderRemoved: OnDataProviderRemoved;

View file

@ -31,7 +31,7 @@ export const columnRenderers: ColumnRenderer[] = [
export const getSuricataCVEFromSignature = (signature: string): string | null => {
const regex = /CVE-[0-9]*-[0-9]*/;
const found = signature.match(regex);
const found = (signature && signature.match(regex)) || false;
if (found) {
return found[0];
} else {

View file

@ -43,13 +43,13 @@ export const plainColumnRenderer: ColumnRenderer = {
case 'timestamp':
return <React.Fragment>{moment(data.timestamp).format('YYYY-MM-DD')}</React.Fragment>;
case 'severity':
return <React.Fragment>{data.event.severity}</React.Fragment>;
return <React.Fragment>{getOr('--', 'event.severity', data)}</React.Fragment>;
case 'category':
return <React.Fragment>{data.event.category}</React.Fragment>;
return <React.Fragment>{getOr('--', 'event.category', data)}</React.Fragment>;
case 'type':
return <React.Fragment>{data.event.type}</React.Fragment>;
return <React.Fragment>{getOr('--', 'event.type', data)}</React.Fragment>;
case 'source':
return <React.Fragment>{data.source.ip}</React.Fragment>;
return <React.Fragment>{getOr('--', 'event.source.ip', data)}</React.Fragment>;
case 'user':
return <React.Fragment>{getOr('--', 'user.name', data)}</React.Fragment>;
case 'event':

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash/fp';
import { get, getOr } from 'lodash/fp';
import React from 'react';
import { ColumnRenderer, getSuricataCVEFromSignature } from '.';
import { ECS } from '../../ecs';
@ -12,8 +12,18 @@ import { ECS } from '../../ecs';
const columnsOverriden = ['event'];
export const suricataColumnRenderer: ColumnRenderer = {
isInstance: (columnName: string, ecs: ECS) =>
columnsOverriden.includes(columnName) && ecs.event.module.toLowerCase() === 'suricata',
isInstance: (columnName: string, ecs: ECS) => {
if (
columnsOverriden.includes(columnName) &&
ecs &&
ecs.event &&
ecs.event.module &&
ecs.event.module.toLowerCase() === 'suricata'
) {
return true;
}
return false;
},
renderColumn: (columnName: string, data: ECS) => {
switch (columnName) {
@ -23,7 +33,7 @@ export const suricataColumnRenderer: ColumnRenderer = {
if (cve != null) {
return <React.Fragment>{cve}</React.Fragment>;
} else {
return <React.Fragment>{data.event.id}</React.Fragment>;
return <React.Fragment>{getOr('--', 'event.id', data)}</React.Fragment>;
}
default:
// unknown column name

View file

@ -61,22 +61,25 @@ const SuricataRow = styled.div`
`;
export const suricataRowRenderer: RowRenderer = {
isInstance: (ecs: ECS) => (ecs.event.module.toLowerCase() === 'suricata' ? true : false),
isInstance: (ecs: ECS) => {
if (ecs && ecs.event && ecs.event.module && ecs.event.module.toLowerCase() === 'suricata') {
return true;
}
return false;
},
renderRow: (data: ECS, children: React.ReactNode) => {
const signature = get('suricata.eve.alert.signature', data) as string;
if (signature != null) {
return (
<SuricataRow>
{children}
return (
<SuricataRow>
{children}
{signature != null ? (
<SuricataSignature>
<EuiButton fill size="s" href={createLinkWithSignature(signature)}>
{signature}
</EuiButton>
</SuricataSignature>
</SuricataRow>
);
} else {
return <span />;
}
) : null}
</SuricataRow>
);
},
};

View file

@ -18,9 +18,11 @@ export interface DataProvider {
*/
enabled: boolean;
/**
* Returns a query that, when executed, returns the data for this provider
* Returns a Component query that, when executed, returns the data for this provider
*/
getQuery: () => string;
componentQuery: React.ReactNode;
componentQueryProps: {};
componentResultParam: string;
/**
* When `true`, boolean logic is applied to the data provider to negate it.
* default: `false`

View file

@ -7,6 +7,8 @@
import { EuiBadge, EuiText } from '@elastic/eui';
import * as React from 'react';
import styled from 'styled-components';
import { EventsQuery } from '../../../../containers/events';
import { DataProvider } from '../data_provider';
interface NameToEventCount<TValue> {
@ -49,9 +51,16 @@ const Text = styled(EuiText)`
export const mockDataProviders: DataProvider[] = Object.keys(mockSourceNameToEventCount).map(
name => ({
enabled: true,
getQuery: () => `query-for-provider-id-${name}`,
id: `id-${name}`,
name,
componentResultParam: 'events',
componentQuery: EventsQuery,
componentQueryProps: {
sourceId: 'default',
startDate: 1521830963132,
endDate: 1521862432253,
filterQuery: '',
},
negated: false,
render: () => (
<div data-test-subj="mockDataProvider">

View file

@ -8,37 +8,37 @@ export interface ECS {
_id: string;
timestamp: string;
host: {
hostname: string;
ip: string;
hostname?: string;
ip?: string;
};
event: {
id: string;
category: string;
type: string;
module: string;
severity: number;
id?: string;
category?: string;
type?: string;
module?: string;
severity?: number;
};
suricata?: {
eve: {
flow_id: number;
proto: string;
alert: {
eve?: {
flow_id?: number;
proto?: string;
alert?: {
signature: string;
signature_id: number;
};
};
};
source: {
ip: string;
port: number;
ip?: string;
port?: number;
};
destination: {
ip: string;
port: number;
ip?: string;
port?: number;
};
geo: {
region_name: string;
country_iso_code: string;
region_name?: string;
country_iso_code?: string;
};
user?: {
id: string;

View file

@ -30,7 +30,6 @@ export interface OwnProps {
interface StateReduxProps {
dataProviders?: DataProvider[];
data?: ECS[];
range?: Range;
sort?: Sort;
}
@ -74,7 +73,6 @@ class StatefulTimelineComponent extends React.PureComponent<Props> {
public render() {
const {
data,
dataProviders,
headers,
id,
@ -103,7 +101,6 @@ class StatefulTimelineComponent extends React.PureComponent<Props> {
columnHeaders={headers}
columnRenderers={columnRenderers}
dataProviders={dataProviders!}
data={data!}
onColumnSorted={onColumnSorted}
onDataProviderRemoved={onDataProviderRemoved}
onFilterChange={noop} // TODO: this is the callback for column filters, which is out scope for this phase of delivery
@ -119,9 +116,9 @@ class StatefulTimelineComponent extends React.PureComponent<Props> {
const mapStateToProps = (state: State, { id }: OwnProps) => {
const timeline = timelineByIdSelector(state)[id];
const { dataProviders, data, sort } = timeline || timelineDefaults;
const { dataProviders, sort } = timeline || timelineDefaults;
return defaultTo({ id, dataProviders, data, sort }, timeline);
return defaultTo({ id, dataProviders, sort }, timeline);
};
export const StatefulTimeline = connect(

View file

@ -7,7 +7,9 @@
import { mount } from 'enzyme';
import { noop, pick } from 'lodash/fp';
import * as React from 'react';
import { MockedProvider } from 'react-apollo/test-utils';
import { eventsQuery } from '../../containers/events/events.gql_query';
import { mockECSData } from '../../pages/mock/mock_ecs';
import { ColumnHeaderType } from './body/column_headers/column_header';
import { headers } from './body/column_headers/headers';
@ -22,23 +24,35 @@ describe('Timeline', () => {
sortDirection: 'descending',
};
const mocks = [
{
request: { query: eventsQuery },
result: {
data: {
events: mockECSData,
},
},
},
];
describe('rendering', () => {
test('it renders the timeline header', () => {
const wrapper = mount(
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
data={mockECSData}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
<MockedProvider mocks={mocks}>
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
</MockedProvider>
);
expect(wrapper.find('[data-test-subj="timelineHeader"]').exists()).toEqual(true);
@ -46,20 +60,21 @@ describe('Timeline', () => {
test('it renders the timeline body', () => {
const wrapper = mount(
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
data={mockECSData}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
<MockedProvider mocks={mocks}>
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
</MockedProvider>
);
expect(wrapper.find('[data-test-subj="body"]').exists()).toEqual(true);
@ -72,20 +87,21 @@ describe('Timeline', () => {
const mockOnColumnSorted = jest.fn();
const wrapper = mount(
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
data={mockECSData}
dataProviders={mockDataProviders}
onColumnSorted={mockOnColumnSorted}
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
<MockedProvider mocks={mocks}>
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
dataProviders={mockDataProviders}
onColumnSorted={mockOnColumnSorted}
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
</MockedProvider>
);
wrapper
@ -105,20 +121,21 @@ describe('Timeline', () => {
const mockOnDataProviderRemoved = jest.fn();
const wrapper = mount(
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
data={mockECSData}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={mockOnDataProviderRemoved}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
<MockedProvider mocks={mocks}>
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={mockOnDataProviderRemoved}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
</MockedProvider>
);
wrapper
@ -152,20 +169,21 @@ describe('Timeline', () => {
}));
const wrapper = mount(
<Timeline
columnHeaders={allColumnsHaveTextFilters}
columnRenderers={columnRenderers}
data={mockECSData}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={noop}
onFilterChange={mockOnFilterChange}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
<MockedProvider mocks={mocks}>
<Timeline
columnHeaders={allColumnsHaveTextFilters}
columnRenderers={columnRenderers}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={noop}
onFilterChange={mockOnFilterChange}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
</MockedProvider>
);
wrapper
@ -179,35 +197,5 @@ describe('Timeline', () => {
});
});
});
describe('onRangeSelected', () => {
test('it invokes the onRangeSelected callback when a new range is selected', () => {
const newSelection = '1 Day';
const mockOnRangeSelected = jest.fn();
const wrapper = mount(
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
data={mockECSData}
dataProviders={mockDataProviders}
onColumnSorted={noop}
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={mockOnRangeSelected}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
/>
);
wrapper
.find('[data-test-subj="rangePicker"] select')
.simulate('change', { target: { value: newSelection } });
expect(mockOnRangeSelected).toBeCalledWith(newSelection);
});
});
});
});

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getOr } from 'lodash/fp';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
@ -22,7 +22,6 @@ import { TimelineHeader } from './header/timeline_header';
interface Props {
columnHeaders: ColumnHeader[];
columnRenderers: ColumnRenderer[];
data: ECS[];
dataProviders: DataProvider[];
height?: string;
onColumnSorted: OnColumnSorted;
@ -53,7 +52,6 @@ export const Timeline = pure<Props>(
columnHeaders,
columnRenderers,
dataProviders,
data,
height = defaultHeight,
onColumnSorted,
onDataProviderRemoved,
@ -70,20 +68,30 @@ export const Timeline = pure<Props>(
onDataProviderRemoved={onDataProviderRemoved}
width={width}
/>
<Body
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
dataProviders={dataProviders}
data={data}
onColumnSorted={onColumnSorted}
onDataProviderRemoved={onDataProviderRemoved}
onFilterChange={onFilterChange}
onRangeSelected={onRangeSelected}
range={range}
rowRenderers={rowRenderers}
sort={sort}
width={width}
/>
{dataProviders.map(provider => {
const QueryComponent = provider.componentQuery as React.ComponentClass;
const queryProps = provider.componentQueryProps;
const resParm = provider.componentResultParam;
return (
<QueryComponent {...queryProps} key={provider.id}>
{(resData: {}) => (
<Body
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
data={getOr([], resParm, resData) as ECS[]}
onColumnSorted={onColumnSorted}
onDataProviderRemoved={onDataProviderRemoved}
onFilterChange={onFilterChange}
onRangeSelected={onRangeSelected}
range={range}
rowRenderers={rowRenderers}
sort={sort}
width={width}
/>
)}
</QueryComponent>
);
})}
</TimelineDiv>
)
);

View file

@ -11,6 +11,7 @@ export const eventsQuery = gql`
source(id: $sourceId) {
getEvents(timerange: $timerange, filterQuery: $filterQuery) {
events {
_id
timestamp
event {
type

View file

@ -19,8 +19,8 @@ interface EventsArgs {
loading: boolean;
}
interface EventsProps {
children: (args: EventsArgs) => React.ReactNode;
export interface EventsProps {
children?: (args: EventsArgs) => React.ReactNode;
sourceId: string;
startDate: number;
endDate: number;
@ -44,7 +44,7 @@ export const EventsQuery = pure<EventsProps>(
}}
>
{({ data, loading }) =>
children({
children!({
loading,
events: getOr([], 'source.getEvents.events', data),
kpiEventType: getOr([], 'source.getEvents.kpiEventType', data),

View file

@ -3,66 +3,121 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiBadge, EuiText } from '@elastic/eui';
import { getOr } from 'lodash/fp';
import React from 'react';
import { connect } from 'react-redux';
import { pure } from 'recompose';
import { Dispatch } from 'redux';
import styled from 'styled-components';
import { EventItem, KpiItem } from '../../../common/graphql/types';
import { BasicTable, Columns } from '../../components/basic_table';
import { BasicTable } from '../../components/basic_table';
import { HorizontalBarChart, HorizontalBarChartData } from '../../components/horizontal_bar_chart';
import { Pane1FlexContent } from '../../components/page';
import { Placeholders, VisualizationPlaceholder } from '../../components/visualization_placeholder';
import { EventsQuery } from '../../containers/events';
import { timelineActions } from '../../store';
export const Hosts = pure(() => (
<EventsQuery sourceId="default" startDate={1541044800000} endDate={1543640399999}>
{({ events, kpiEventType, loading }) => (
<Pane1FlexContent data-test-subj="pane1FlexContent">
<VisualizationPlaceholder>
<HorizontalBarChart
loading={loading}
title="KPI event types"
width={490}
height={279}
barChartdata={
kpiEventType.map((i: KpiItem) => ({
x: i.count,
y: i.value,
})) as HorizontalBarChartData[]
}
/>
</VisualizationPlaceholder>
<VisualizationPlaceholder>
<BasicTable
columns={eventsColumns}
loading={loading}
pageOfItems={events}
sortField="host.hostname"
title="Events"
/>
</VisualizationPlaceholder>
<Placeholders timelineId="pane2-timeline" count={8} myRoute="Hosts" />
</Pane1FlexContent>
)}
</EventsQuery>
));
// start/end date to show good alert in the timeline
const startDate = 1521830963132;
const endDate = 1521862432253;
const eventsColumns: Columns[] = [
// start/end date to show good data in the KPI event type
// const startDate = 1541044800000;
// const endDate = 1543640399999;
interface Props {
dispatch: Dispatch;
}
export const Hosts = connect()(
pure<Props>(({ dispatch }) => (
<EventsQuery sourceId="default" startDate={startDate} endDate={endDate}>
{({ events, kpiEventType, loading }) => (
<Pane1FlexContent data-test-subj="pane1FlexContent">
<VisualizationPlaceholder>
<HorizontalBarChart
loading={loading}
title="KPI event types"
width={490}
height={279}
barChartdata={
kpiEventType.map((i: KpiItem) => ({
x: i.count,
y: i.value,
})) as HorizontalBarChartData[]
}
/>
</VisualizationPlaceholder>
<VisualizationPlaceholder>
<BasicTable
columns={getEventsColumns(dispatch)}
loading={loading}
pageOfItems={events}
sortField="host.hostname"
title="Events"
/>
</VisualizationPlaceholder>
<Placeholders timelineId="pane2-timeline" count={8} myRoute="Hosts" />
</Pane1FlexContent>
)}
</EventsQuery>
))
);
const getEventsColumns = (dispatch: Dispatch) => [
{
name: 'Host name',
sortable: true,
truncateText: false,
hideForMobile: false,
render: (item: EventItem) => (
<React.Fragment>{getOr('--', 'host.hostname', item)}</React.Fragment>
),
render: (item: EventItem) => {
const hostName = getOr('--', 'host.hostname', item);
return (
<ProviderContainer
onClick={() => {
dispatch(
timelineActions.addProvider({
id: 'pane2-timeline',
provider: {
enabled: true,
id: `id-${hostName}`,
name,
negated: false,
componentResultParam: 'events',
componentQuery: EventsQuery,
componentQueryProps: {
sourceId: 'default',
startDate,
endDate,
filterQuery: `{"bool":{"should":[{"match":{"host.name":"${hostName}"}}],"minimum_should_match":1}}`,
},
render: () => (
<div data-test-subj="mockDataProvider">
<EuiBadge color="primary">n/a</EuiBadge>
<Text> {hostName} </Text>
</div>
),
},
})
);
}}
>
{hostName}
</ProviderContainer>
);
},
},
{
name: 'Event type',
sortable: true,
truncateText: true,
hideForMobile: true,
render: (item: EventItem) => <React.Fragment>{getOr('--', 'event.type', item)}</React.Fragment>,
render: (item: EventItem) => (
<ProviderContainer>{getOr('--', 'event.type', item)}</ProviderContainer>
),
},
{
name: 'Source',
@ -94,3 +149,13 @@ const eventsColumns: Columns[] = [
),
},
];
const ProviderContainer = styled.div`
user-select: none;
cursor: grab;
`;
const Text = styled(EuiText)`
display: inline;
padding-left: 5px;
`;

View file

@ -65,6 +65,7 @@ export const eventsSchema = gql`
}
type EventItem {
_id: String
destination: DestinationEcsFields
event: EventEcsFields
geo: GeoEcsFields

View file

@ -140,10 +140,8 @@ export class ElasticsearchEventsAdapter implements EventsAdapter {
count: item.doc_count,
}))
: [];
const hits = response.hits.hits;
const events = hits.map(formatEventsData(Fields)) as [EventItem];
return {
events,
kpiEventType,
@ -154,6 +152,7 @@ export class ElasticsearchEventsAdapter implements EventsAdapter {
const formatEventsData = (fields: string[]) => (hit: EventData) =>
fields.reduce(
(flattenedFields, fieldName) => {
flattenedFields._id = get('_id', hit);
if (EventFieldsMap.hasOwnProperty(fieldName)) {
const esField = Object.getOwnPropertyDescriptor(EventFieldsMap, fieldName);
return has(esField && esField.value, hit._source)