[Uptime] Improve query performance with Heartbeat 7.6+ data. (#52433) (#54352)

This PR optimizes both the snapshot component and the monitor list on the overview page by using the new monitor.timespan field from elastic/beats#14778. Note that the functionality here will work with heartbeats lacking that patch, but the performance improvements will be absent.

This PR adapts the snapshot tests to use synthetically generated data which should be easier to maintain. As a result some of that code is refactored as well.

See #52433 parent issue as well.
This commit is contained in:
Andrew Cholakian 2020-01-09 14:11:12 -06:00 committed by GitHub
parent ace18932b0
commit 40346ca0d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 492 additions and 342 deletions

View file

@ -2173,18 +2173,6 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "mixed",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": { "kind": "SCALAR", "name": "Int", "ofType": null }
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "total",
"description": "",

View file

@ -419,8 +419,6 @@ export interface SnapshotCount {
down: number;
mixed: number;
total: number;
}

View file

@ -8,7 +8,6 @@ import * as t from 'io-ts';
export const SnapshotType = t.type({
down: t.number,
mixed: t.number,
total: t.number,
up: t.number,
});

View file

@ -13,7 +13,6 @@ describe('Snapshot component', () => {
const snapshot: Snapshot = {
up: 8,
down: 2,
mixed: 0,
total: 10,
};

View file

@ -103,6 +103,7 @@ exports[`DonutChart component renders a donut chart 1`] = `
</span>
<span
class="euiFlexItem c2"
data-test-subj="xpack.uptime.snapshot.donutChart.down"
>
32
</span>
@ -150,6 +151,7 @@ exports[`DonutChart component renders a donut chart 1`] = `
</span>
<span
class="euiFlexItem c2"
data-test-subj="xpack.uptime.snapshot.donutChart.up"
>
95
</span>

View file

@ -5,6 +5,7 @@ exports[`DonutChartLegend applies valid props as expected 1`] = `
<DonutChartLegendRow
color="#bd271e"
content={23}
data-test-subj="xpack.uptime.snapshot.donutChart.down"
message="Down"
/>
<EuiSpacer
@ -13,6 +14,7 @@ exports[`DonutChartLegend applies valid props as expected 1`] = `
<DonutChartLegendRow
color="#d3dae6"
content={45}
data-test-subj="xpack.uptime.snapshot.donutChart.up"
message="Up"
/>
</styled.div>

View file

@ -21,6 +21,7 @@ exports[`DonutChartLegendRow passes appropriate props 1`] = `
</Styled(EuiFlexItem)>
<Styled(EuiFlexItem)
component="span"
data-test-subj="foo"
>
23
</Styled(EuiFlexItem)>

View file

@ -11,7 +11,7 @@ import React from 'react';
describe('DonutChartLegendRow', () => {
it('passes appropriate props', () => {
const wrapper = shallowWithIntl(
<DonutChartLegendRow color="green" message="Foo" content={23} />
<DonutChartLegendRow color="green" message="Foo" content={23} data-test-subj="foo" />
);
expect(wrapper).toMatchSnapshot();
});

View file

@ -73,7 +73,7 @@ export const DonutChart = ({ height, down, up, width }: DonutChartProps) => {
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<svg
aria-label={i18n.translate('xpack.uptime.donutChart.ariaLabel', {
aria-label={i18n.translate('xpack.uptime.snapshot.donutChart.ariaLabel', {
defaultMessage:
'Pie chart showing the current status. {down} of {total} monitors are down.',
values: { down, total: up + down },

View file

@ -34,17 +34,19 @@ export const DonutChartLegend = ({ down, up }: Props) => {
<DonutChartLegendRow
color={danger}
content={down}
message={i18n.translate('xpack.uptime.donutChart.legend.downRowLabel', {
message={i18n.translate('xpack.uptime.snapshot.donutChart.legend.downRowLabel', {
defaultMessage: 'Down',
})}
data-test-subj={'xpack.uptime.snapshot.donutChart.down'}
/>
<EuiSpacer size="m" />
<DonutChartLegendRow
color={gray}
content={up}
message={i18n.translate('xpack.uptime.donutChart.legend.upRowLabel', {
message={i18n.translate('xpack.uptime.snapshot.donutChart.legend.upRowLabel', {
defaultMessage: 'Up',
})}
data-test-subj={'xpack.uptime.snapshot.donutChart.up'}
/>
</LegendContainer>
);

View file

@ -23,9 +23,10 @@ interface Props {
color: string;
message: string;
content: string | number;
'data-test-subj': string;
}
export const DonutChartLegendRow = ({ color, content, message }: Props) => (
export const DonutChartLegendRow = ({ color, content, message, 'data-test-subj': dts }: Props) => (
<EuiFlexGroup gutterSize="l" responsive={false}>
<EuiFlexItemReducedMargin component="span" grow={false}>
<EuiHealth color={color} />
@ -33,6 +34,8 @@ export const DonutChartLegendRow = ({ color, content, message }: Props) => (
<EuiFlexItemReducedMargin component="span" grow={false}>
{message}
</EuiFlexItemReducedMargin>
<EuiFlexItemAlignRight component="span">{content}</EuiFlexItemAlignRight>
<EuiFlexItemAlignRight component="span" data-test-subj={dts}>
{content}
</EuiFlexItemAlignRight>
</EuiFlexGroup>
);

View file

@ -271,7 +271,6 @@
"monitor": {
"id": null,
"name": "elastic",
"status": "mixed",
"type": null,
"__typename": "MonitorState"
},
@ -377,7 +376,6 @@
"monitor": {
"id": null,
"name": null,
"status": "mixed",
"type": null,
"__typename": "MonitorState"
},

View file

@ -20,8 +20,6 @@ const getHealthColor = (status: string): string => {
return 'success';
case 'down':
return 'danger';
case 'mixed':
return 'warning';
default:
return '';
}
@ -37,10 +35,6 @@ const getHealthMessage = (status: string): string | null => {
return i18n.translate('xpack.uptime.monitorList.statusColumn.downLabel', {
defaultMessage: 'Down',
});
case 'mixed':
return i18n.translate('xpack.uptime.monitorList.statusColumn.mixedLabel', {
defaultMessage: 'Mixed',
});
default:
return null;
}

View file

@ -1,8 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`snapshot API throws when server response doesn't correspond to expected type 1`] = `
[Error: Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/down: number
Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/mixed: number
Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/total: number
Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/up: number]
[Error: Invalid value undefined supplied to : { down: number, total: number, up: number }/down: number
Invalid value undefined supplied to : { down: number, total: number, up: number }/total: number
Invalid value undefined supplied to : { down: number, total: number, up: number }/up: number]
`;

View file

@ -14,7 +14,7 @@ describe('snapshot API', () => {
fetchMock = jest.spyOn(window, 'fetch');
mockResponse = {
ok: true,
json: () => new Promise(r => r({ up: 3, down: 12, mixed: 0, total: 15 })),
json: () => new Promise(r => r({ up: 3, down: 12, total: 15 })),
};
});
@ -34,7 +34,7 @@ describe('snapshot API', () => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/uptime/snapshot/count?dateRangeStart=now-15m&dateRangeEnd=now&filters=monitor.id%3A%22auto-http-0X21EE76EAC459873F%22&statusFilter=up'
);
expect(resp).toEqual({ up: 3, down: 12, mixed: 0, total: 15 });
expect(resp).toEqual({ up: 3, down: 12, total: 15 });
});
it(`throws when server response doesn't correspond to expected type`, async () => {

View file

@ -4,7 +4,6 @@ exports[`snapshot reducer appends a current error to existing errors list 1`] =
Object {
"count": Object {
"down": 0,
"mixed": 0,
"total": 0,
"up": 0,
},
@ -19,7 +18,6 @@ exports[`snapshot reducer changes the count when a snapshot fetch succeeds 1`] =
Object {
"count": Object {
"down": 15,
"mixed": 0,
"total": 25,
"up": 10,
},
@ -32,7 +30,6 @@ exports[`snapshot reducer sets the state's status to loading during a fetch 1`]
Object {
"count": Object {
"down": 0,
"mixed": 0,
"total": 0,
"up": 0,
},
@ -45,7 +42,6 @@ exports[`snapshot reducer updates existing state 1`] = `
Object {
"count": Object {
"down": 1,
"mixed": 0,
"total": 4,
"up": 3,
},

View file

@ -21,7 +21,7 @@ describe('snapshot reducer', () => {
expect(
snapshotReducer(
{
count: { down: 1, mixed: 0, total: 4, up: 3 },
count: { down: 1, total: 4, up: 3 },
errors: [],
loading: false,
},
@ -47,7 +47,6 @@ describe('snapshot reducer', () => {
payload: {
up: 10,
down: 15,
mixed: 0,
total: 25,
},
};

View file

@ -21,7 +21,6 @@ export interface SnapshotState {
const initialState: SnapshotState = {
count: {
down: 0,
mixed: 0,
total: 0,
up: 0,
},

View file

@ -19,7 +19,6 @@ describe('state selectors', () => {
count: {
up: 2,
down: 0,
mixed: 0,
total: 2,
},
errors: [],

View file

@ -1,10 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`get snapshot helper reduces check groups as expected 1`] = `
Object {
"down": 1,
"mixed": 0,
"total": 3,
"up": 2,
}
`;

View file

@ -1,106 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getSnapshotCountHelper } from '../get_snapshot_helper';
import { MonitorGroups } from '../search';
describe('get snapshot helper', () => {
let mockIterator: any;
beforeAll(() => {
mockIterator = jest.fn();
const summaryTimestamp = new Date('2019-01-01');
const firstResult: MonitorGroups = {
id: 'firstGroup',
groups: [
{
monitorId: 'first-monitor',
location: 'us-east-1',
checkGroup: 'abc',
status: 'down',
summaryTimestamp,
},
{
monitorId: 'first-monitor',
location: 'us-west-1',
checkGroup: 'abc',
status: 'up',
summaryTimestamp,
},
{
monitorId: 'first-monitor',
location: 'amsterdam',
checkGroup: 'abc',
status: 'down',
summaryTimestamp,
},
],
};
const secondResult: MonitorGroups = {
id: 'secondGroup',
groups: [
{
monitorId: 'second-monitor',
location: 'us-east-1',
checkGroup: 'yyz',
status: 'up',
summaryTimestamp,
},
{
monitorId: 'second-monitor',
location: 'us-west-1',
checkGroup: 'yyz',
status: 'up',
summaryTimestamp,
},
{
monitorId: 'second-monitor',
location: 'amsterdam',
checkGroup: 'yyz',
status: 'up',
summaryTimestamp,
},
],
};
const thirdResult: MonitorGroups = {
id: 'thirdGroup',
groups: [
{
monitorId: 'third-monitor',
location: 'us-east-1',
checkGroup: 'dt',
status: 'up',
summaryTimestamp,
},
{
monitorId: 'third-monitor',
location: 'us-west-1',
checkGroup: 'dt',
status: 'up',
summaryTimestamp,
},
{
monitorId: 'third-monitor',
location: 'amsterdam',
checkGroup: 'dt',
status: 'up',
summaryTimestamp,
},
],
};
const mockNext = jest
.fn()
.mockReturnValueOnce(firstResult)
.mockReturnValueOnce(secondResult)
.mockReturnValueOnce(thirdResult)
.mockReturnValueOnce(null);
mockIterator.next = mockNext;
});
it('reduces check groups as expected', async () => {
expect(await getSnapshotCountHelper(mockIterator)).toMatchSnapshot();
});
});

View file

@ -4,22 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { UMMonitorStatesAdapter, CursorPagination } from './adapter_types';
import { UMMonitorStatesAdapter } from './adapter_types';
import { INDEX_NAMES, CONTEXT_DEFAULTS } from '../../../../common/constants';
import { fetchPage } from './search';
import { MonitorGroupIterator } from './search/monitor_group_iterator';
import { getSnapshotCountHelper } from './get_snapshot_helper';
export interface QueryContext {
count: (query: Record<string, any>) => Promise<any>;
search: (query: Record<string, any>) => Promise<any>;
dateRangeStart: string;
dateRangeEnd: string;
pagination: CursorPagination;
filterClause: any | null;
size: number;
statusFilter?: string;
}
import { Snapshot } from '../../../../common/runtime_types';
import { QueryContext } from './search/query_context';
export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = {
// Gets a page of monitor states.
@ -35,16 +25,15 @@ export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = {
statusFilter = statusFilter === null ? undefined : statusFilter;
const size = 10;
const queryContext: QueryContext = {
count: (query: Record<string, any>): Promise<any> => callES('count', query),
search: (query: Record<string, any>): Promise<any> => callES('search', query),
const queryContext = new QueryContext(
callES,
dateRangeStart,
dateRangeEnd,
pagination,
filterClause: filters && filters !== '' ? JSON.parse(filters) : null,
filters && filters !== '' ? JSON.parse(filters) : null,
size,
statusFilter,
};
statusFilter
);
const page = await fetchPage(queryContext);
@ -55,18 +44,46 @@ export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = {
};
},
getSnapshotCount: async ({ callES, dateRangeStart, dateRangeEnd, filters, statusFilter }) => {
const context: QueryContext = {
count: query => callES('count', query),
search: query => callES('search', query),
getSnapshotCount: async ({
callES,
dateRangeStart,
dateRangeEnd,
filters,
statusFilter,
}): Promise<Snapshot> => {
if (!(statusFilter === 'up' || statusFilter === 'down' || statusFilter === undefined)) {
throw new Error(`Invalid status filter value '${statusFilter}'`);
}
const context = new QueryContext(
callES,
dateRangeStart,
dateRangeEnd,
pagination: CONTEXT_DEFAULTS.CURSOR_PAGINATION,
filterClause: filters && filters !== '' ? JSON.parse(filters) : null,
size: CONTEXT_DEFAULTS.MAX_MONITORS_FOR_SNAPSHOT_COUNT,
statusFilter,
CONTEXT_DEFAULTS.CURSOR_PAGINATION,
filters && filters !== '' ? JSON.parse(filters) : null,
CONTEXT_DEFAULTS.MAX_MONITORS_FOR_SNAPSHOT_COUNT,
statusFilter
);
// Calculate the total, up, and down counts.
const counts = await fastStatusCount(context);
// Check if the last count was accurate, if not, we need to perform a slower count with the
// MonitorGroupsIterator.
if (!(await context.hasTimespan())) {
// Figure out whether 'up' or 'down' is more common. It's faster to count the lower cardinality
// one then use subtraction to figure out its opposite.
const [leastCommonStatus, mostCommonStatus]: Array<'up' | 'down'> =
counts.up > counts.down ? ['down', 'up'] : ['up', 'down'];
counts[leastCommonStatus] = await slowStatusCount(context, leastCommonStatus);
counts[mostCommonStatus] = counts.total - counts[leastCommonStatus];
}
return {
total: statusFilter ? counts[statusFilter] : counts.total,
up: statusFilter === 'down' ? 0 : counts.up,
down: statusFilter === 'up' ? 0 : counts.down,
};
return getSnapshotCountHelper(new MonitorGroupIterator(context));
},
statesIndexExists: async ({ callES }) => {
@ -92,3 +109,46 @@ const jsonifyPagination = (p: any): string | null => {
return JSON.stringify(p);
};
const fastStatusCount = async (context: QueryContext): Promise<Snapshot> => {
const params = {
index: INDEX_NAMES.HEARTBEAT,
body: {
size: 0,
query: { bool: { filter: await context.dateAndCustomFilters() } },
aggs: {
unique: {
// We set the precision threshold to 40k which is the max precision supported by cardinality
cardinality: { field: 'monitor.id', precision_threshold: 40000 },
},
down: {
filter: { range: { 'summary.down': { gt: 0 } } },
aggs: {
unique: { cardinality: { field: 'monitor.id', precision_threshold: 40000 } },
},
},
},
},
};
const statistics = await context.search(params);
const total = statistics.aggregations.unique.value;
const down = statistics.aggregations.down.unique.value;
return {
total,
down,
up: total - down,
};
};
const slowStatusCount = async (context: QueryContext, status: string): Promise<number> => {
const downContext = context.clone();
downContext.statusFilter = status;
const iterator = new MonitorGroupIterator(downContext);
let count = 0;
while (await iterator.next()) {
count++;
}
return count;
};

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { MonitorGroups, MonitorGroupIterator } from './search';
import { Snapshot } from '../../../../common/runtime_types';
const reduceItemsToCounts = (items: MonitorGroups[]) => {
let down = 0;
let up = 0;
items.forEach(item => {
if (item.groups.some(group => group.status === 'down')) {
down++;
} else {
up++;
}
});
return {
down,
mixed: 0,
total: down + up,
up,
};
};
export const getSnapshotCountHelper = async (iterator: MonitorGroupIterator): Promise<Snapshot> => {
const items: MonitorGroups[] = [];
let res: MonitorGroups | null;
// query the index to find the most recent check group for each monitor/location
do {
res = await iterator.next();
if (res) {
items.push(res);
}
} while (res !== null);
return reduceItemsToCounts(items);
};

View file

@ -11,7 +11,7 @@ import {
MonitorGroupsFetcher,
MonitorGroupsPage,
} from '../fetch_page';
import { QueryContext } from '../../elasticsearch_monitor_states_adapter';
import { QueryContext } from '../query_context';
import { MonitorSummary } from '../../../../../../common/graphql/types';
import { nextPagination, prevPagination, simpleQueryContext } from './test_helpers';

View file

@ -11,8 +11,8 @@ import {
MonitorGroupIterator,
} from '../monitor_group_iterator';
import { simpleQueryContext } from './test_helpers';
import { QueryContext } from '../../elasticsearch_monitor_states_adapter';
import { MonitorGroups } from '../fetch_page';
import { QueryContext } from '../query_context';
describe('iteration', () => {
let iterator: MonitorGroupIterator | null = null;

View file

@ -6,7 +6,7 @@
import { CursorPagination } from '../../adapter_types';
import { CursorDirection, SortOrder } from '../../../../../../common/graphql/types';
import { QueryContext } from '../../elasticsearch_monitor_states_adapter';
import { QueryContext } from '../query_context';
export const prevPagination = (key: any): CursorPagination => {
return {
@ -23,14 +23,5 @@ export const nextPagination = (key: any): CursorPagination => {
};
};
export const simpleQueryContext = (): QueryContext => {
return {
count: _query => new Promise(r => ({})),
search: _query => new Promise(r => ({})),
dateRangeEnd: '',
dateRangeStart: '',
filterClause: undefined,
pagination: nextPagination('something'),
size: 0,
statusFilter: '',
};
return new QueryContext(undefined, '', '', nextPagination('something'), undefined, 0, '');
};

View file

@ -5,7 +5,7 @@
*/
import { get, sortBy } from 'lodash';
import { QueryContext } from '../elasticsearch_monitor_states_adapter';
import { QueryContext } from './query_context';
import { getHistogramIntervalFormatted } from '../../../helper';
import { INDEX_NAMES, STATES } from '../../../../../common/constants';
import {
@ -77,19 +77,19 @@ export const enrichMonitorGroups: MonitorEnricher = async (
}
String agentIdIP = agentId + "-" + (ip == null ? "" : ip.toString());
def ts = doc["@timestamp"][0].toInstant().toEpochMilli();
def lastCheck = state.checksByAgentIdIP[agentId];
Instant lastTs = lastCheck != null ? lastCheck["@timestamp"] : null;
if (lastTs != null && lastTs > ts) {
return;
}
curCheck.put("@timestamp", ts);
Map agent = new HashMap();
agent.id = agentId;
curCheck.put("agent", agent);
if (state.globals.url == null) {
Map url = new HashMap();
Collection fields = ["full", "original", "scheme", "username", "password", "domain", "port", "path", "query", "fragment"];
@ -102,7 +102,7 @@ export const enrichMonitorGroups: MonitorEnricher = async (
}
state.globals.url = url;
}
Map monitor = new HashMap();
monitor.status = doc["monitor.status"][0];
monitor.ip = ip;
@ -113,7 +113,7 @@ export const enrichMonitorGroups: MonitorEnricher = async (
}
}
curCheck.monitor = monitor;
if (curCheck.observer == null) {
curCheck.observer = new HashMap();
}
@ -144,14 +144,14 @@ export const enrichMonitorGroups: MonitorEnricher = async (
if (!doc["tls.certificate_not_valid_before"].isEmpty()) {
curCheck.tls.certificate_not_valid_before = doc["tls.certificate_not_valid_before"][0];
}
state.checksByAgentIdIP[agentIdIP] = curCheck;
`,
combine_script: 'return state;',
reduce_script: `
// The final document
Map result = new HashMap();
Map checks = new HashMap();
Instant maxTs = Instant.ofEpochMilli(0);
Collection ips = new HashSet();
@ -159,7 +159,7 @@ export const enrichMonitorGroups: MonitorEnricher = async (
Collection podUids = new HashSet();
Collection containerIds = new HashSet();
Collection tls = new HashSet();
String name = null;
String name = null;
for (state in states) {
result.putAll(state.globals);
for (entry in state.checksByAgentIdIP.entrySet()) {
@ -167,18 +167,18 @@ export const enrichMonitorGroups: MonitorEnricher = async (
def check = entry.getValue();
def lastBestCheck = checks.get(agentIdIP);
def checkTs = Instant.ofEpochMilli(check.get("@timestamp"));
if (maxTs.isBefore(checkTs)) { maxTs = checkTs}
if (lastBestCheck == null || lastBestCheck.get("@timestamp") < checkTs) {
check["@timestamp"] = check["@timestamp"];
checks[agentIdIP] = check
}
if (check.monitor.name != null && check.monitor.name != "") {
name = check.monitor.name;
}
ips.add(check.monitor.ip);
if (check.observer != null && check.observer.geo != null && check.observer.geo.name != null) {
geoNames.add(check.observer.geo.name);
@ -194,45 +194,45 @@ export const enrichMonitorGroups: MonitorEnricher = async (
}
}
}
// We just use the values so we can store these as nested docs
result.checks = checks.values();
result.put("@timestamp", maxTs);
Map summary = new HashMap();
summary.up = checks.entrySet().stream().filter(c -> c.getValue().monitor.status == "up").count();
summary.down = checks.size() - summary.up;
result.summary = summary;
Map monitor = new HashMap();
monitor.ip = ips;
monitor.name = name;
monitor.status = summary.down > 0 ? (summary.up > 0 ? "mixed": "down") : "up";
monitor.status = summary.down > 0 ? "down" : "up";
result.monitor = monitor;
Map observer = new HashMap();
Map geo = new HashMap();
observer.geo = geo;
geo.name = geoNames;
result.observer = observer;
if (!podUids.isEmpty()) {
result.kubernetes = new HashMap();
result.kubernetes.pod = new HashMap();
result.kubernetes.pod.uid = podUids;
}
if (!containerIds.isEmpty()) {
result.container = new HashMap();
result.container.id = containerIds;
}
if (!tls.isEmpty()) {
result.tls = new HashMap();
result.tls = tls;
}
return result;
`,
},

View file

@ -6,8 +6,8 @@
import { refinePotentialMatches } from './refine_potential_matches';
import { findPotentialMatches } from './find_potential_matches';
import { QueryContext } from '../elasticsearch_monitor_states_adapter';
import { ChunkFetcher, ChunkResult } from './monitor_group_iterator';
import { QueryContext } from './query_context';
/**
* Fetches a single 'chunk' of data with a single query, then uses a secondary query to filter out erroneous matches.

View file

@ -6,7 +6,7 @@
import { flatten } from 'lodash';
import { CursorPagination } from '../adapter_types';
import { QueryContext } from '../elasticsearch_monitor_states_adapter';
import { QueryContext } from './query_context';
import { QUERY } from '../../../../../common/constants';
import { CursorDirection, MonitorSummary, SortOrder } from '../../../../../common/graphql/types';
import { enrichMonitorGroups } from './enrich_monitor_groups';
@ -51,6 +51,7 @@ const fetchPageMonitorGroups: MonitorGroupsFetcher = async (
size: number
): Promise<MonitorGroupsPage> => {
const monitorGroups: MonitorGroups[] = [];
const iterator = new MonitorGroupIterator(queryContext);
let paginationBefore: CursorPagination | null = null;

View file

@ -5,10 +5,9 @@
*/
import { get, set } from 'lodash';
import { QueryContext } from '../elasticsearch_monitor_states_adapter';
import { CursorDirection } from '../../../../../common/graphql/types';
import { INDEX_NAMES } from '../../../../../common/constants';
import { makeDateRangeFilter } from '../../../helper/make_date_rate_filter';
import { QueryContext } from './query_context';
// This is the first phase of the query. In it, we find the most recent check groups that matched the given query.
// Note that these check groups may not be the most recent groups for the matching monitor ID! We'll filter those
@ -55,7 +54,7 @@ export const findPotentialMatches = async (
};
const query = async (queryContext: QueryContext, searchAfter: any, size: number) => {
const body = queryBody(queryContext, searchAfter, size);
const body = await queryBody(queryContext, searchAfter, size);
const params = {
index: INDEX_NAMES.HEARTBEAT,
@ -65,15 +64,11 @@ const query = async (queryContext: QueryContext, searchAfter: any, size: number)
return await queryContext.search(params);
};
const queryBody = (queryContext: QueryContext, searchAfter: any, size: number) => {
const queryBody = async (queryContext: QueryContext, searchAfter: any, size: number) => {
const compositeOrder = cursorDirectionToOrder(queryContext.pagination.cursorDirection);
const filters: any[] = [
makeDateRangeFilter(queryContext.dateRangeStart, queryContext.dateRangeEnd),
];
if (queryContext.filterClause) {
filters.push(queryContext.filterClause);
}
const filters = await queryContext.dateAndCustomFilters();
if (queryContext.statusFilter) {
filters.push({ match: { 'monitor.status': queryContext.statusFilter } });
}
@ -82,6 +77,11 @@ const queryBody = (queryContext: QueryContext, searchAfter: any, size: number) =
size: 0,
query: { bool: { filter: filters } },
aggs: {
has_timespan: {
filter: {
exists: { field: 'monitor.timespan' },
},
},
monitors: {
composite: {
size,

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { QueryContext } from '../elasticsearch_monitor_states_adapter';
import { QueryContext } from './query_context';
import { CursorPagination } from '../adapter_types';
import { fetchChunk } from './fetch_chunk';
import { CursorDirection } from '../../../../../common/graphql/types';
@ -155,7 +155,7 @@ export class MonitorGroupIterator {
// Returns a copy of this fetcher that goes backwards from the current position
reverse(): MonitorGroupIterator | null {
const reverseContext = Object.assign({}, this.queryContext);
const reverseContext = this.queryContext.clone();
const current = this.getCurrent();
reverseContext.pagination = {

View file

@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import DateMath from '@elastic/datemath';
import { APICaller } from 'kibana/server';
import { CursorPagination } from '../adapter_types';
import { INDEX_NAMES } from '../../../../../common/constants';
export class QueryContext {
callES: APICaller;
dateRangeStart: string;
dateRangeEnd: string;
pagination: CursorPagination;
filterClause: any | null;
size: number;
statusFilter?: string;
hasTimespanCache?: boolean;
constructor(
database: any,
dateRangeStart: string,
dateRangeEnd: string,
pagination: CursorPagination,
filterClause: any | null,
size: number,
statusFilter?: string
) {
this.callES = database;
this.dateRangeStart = dateRangeStart;
this.dateRangeEnd = dateRangeEnd;
this.pagination = pagination;
this.filterClause = filterClause;
this.size = size;
this.statusFilter = statusFilter;
}
async search(params: any): Promise<any> {
params.index = INDEX_NAMES.HEARTBEAT;
return this.callES('search', params);
}
async count(params: any): Promise<any> {
params.index = INDEX_NAMES.HEARTBEAT;
return this.callES('count', params);
}
async dateAndCustomFilters(): Promise<any[]> {
const clauses = [await this.dateRangeFilter()];
if (this.filterClause) {
clauses.push(this.filterClause);
}
return clauses;
}
async dateRangeFilter(forceNoTimespan?: boolean): Promise<any> {
const timestampClause = {
range: { '@timestamp': { gte: this.dateRangeStart, lte: this.dateRangeEnd } },
};
if (forceNoTimespan === true || !(await this.hasTimespan())) {
return timestampClause;
}
// @ts-ignore
const tsStart = DateMath.parse(this.dateRangeEnd).subtract(10, 'seconds');
const tsEnd = DateMath.parse(this.dateRangeEnd)!;
return {
bool: {
filter: [
timestampClause,
{
bool: {
should: [
{
range: {
'monitor.timespan': {
gte: tsStart.toISOString(),
lte: tsEnd.toISOString(),
},
},
},
{
bool: {
must_not: { exists: { field: 'monitor.timespan' } },
},
},
],
},
},
],
},
};
}
async hasTimespan(): Promise<boolean> {
if (this.hasTimespanCache) {
return this.hasTimespanCache;
}
this.hasTimespanCache =
(
await this.count({
body: {
query: {
bool: {
filter: [
await this.dateRangeFilter(true),
{ exists: { field: 'monitor.timespan' } },
],
},
},
},
terminate_after: 1,
})
).count > 0;
return this.hasTimespanCache;
}
clone(): QueryContext {
return new QueryContext(
this.callES,
this.dateRangeStart,
this.dateRangeEnd,
this.pagination,
this.filterClause,
this.size,
this.statusFilter
);
}
}

View file

@ -5,10 +5,9 @@
*/
import { INDEX_NAMES } from '../../../../../common/constants';
import { QueryContext } from '../elasticsearch_monitor_states_adapter';
import { QueryContext } from './query_context';
import { CursorDirection } from '../../../../../common/graphql/types';
import { MonitorGroups, MonitorLocCheckGroup } from './fetch_page';
import { makeDateRangeFilter } from '../../../helper/make_date_rate_filter';
/**
* Determines whether the provided check groups are the latest complete check groups for their associated monitor ID's.
@ -103,7 +102,7 @@ export const mostRecentCheckGroups = async (
query: {
bool: {
filter: [
makeDateRangeFilter(queryContext.dateRangeStart, queryContext.dateRangeEnd),
await queryContext.dateRangeFilter(),
{ terms: { 'monitor.id': potentialMatchMonitorIDs } },
// only match summary docs because we only want the latest *complete* check group.
{ exists: { field: 'summary' } },

View file

@ -11824,9 +11824,9 @@
"xpack.uptime.pingList.expandRow": "拡張",
"xpack.uptime.snapshot.pingsOverTimeTitle": "一定時間のピング",
"xpack.uptime.snapshotHistogram.yAxis.title": "ピング",
"xpack.uptime.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total} 個中 {down} 個のモニターがダウンしています。",
"xpack.uptime.donutChart.legend.downRowLabel": "ダウン",
"xpack.uptime.donutChart.legend.upRowLabel": "アップ",
"xpack.uptime.snapshot.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total} 個中 {down} 個のモニターがダウンしています。",
"xpack.uptime.snapshot.donutChart.legend.downRowLabel": "ダウン",
"xpack.uptime.snapshot.donutChart.legend.upRowLabel": "アップ",
"xpack.uptime.durationChart.emptyPrompt.description": "このモニターは選択された時間範囲で一度も {emphasizedText} していません。",
"xpack.uptime.durationChart.emptyPrompt.title": "利用可能な期間データがありません",
"xpack.uptime.emptyStateError.notAuthorized": "アップタイムデータの表示が承認されていません。システム管理者にお問い合わせください。",

View file

@ -11852,9 +11852,9 @@
"xpack.uptime.pingList.expandRow": "展开",
"xpack.uptime.snapshot.pingsOverTimeTitle": "时移 Ping 数",
"xpack.uptime.snapshotHistogram.yAxis.title": "Ping",
"xpack.uptime.donutChart.ariaLabel": "显示当前状态的饼图。{down} 个监测已关闭,共 {total} 个。",
"xpack.uptime.donutChart.legend.downRowLabel": "关闭",
"xpack.uptime.donutChart.legend.upRowLabel": "运行",
"xpack.uptime.snapshot.donutChart.ariaLabel": "显示当前状态的饼图。{down} 个监测已关闭,共 {total} 个。",
"xpack.uptime.snapshot.donutChart.legend.downRowLabel": "关闭",
"xpack.uptime.snapshot.donutChart.legend.upRowLabel": "运行",
"xpack.uptime.durationChart.emptyPrompt.description": "在选定时间范围内此监测从未{emphasizedText}。",
"xpack.uptime.durationChart.emptyPrompt.title": "没有持续时间数据",
"xpack.uptime.emptyStateError.notAuthorized": "您无权查看 Uptime 数据,请联系系统管理员。",

View file

@ -1,6 +1,5 @@
{
"up": 93,
"up": 10,
"down": 7,
"mixed": 0,
"total": 100
"total": 17
}

View file

@ -1,6 +1,5 @@
{
"up": 0,
"down": 7,
"mixed": 0,
"total": 7
"down": 0,
"total": 0
}

View file

@ -1,6 +1,5 @@
{
"up": 0,
"down": 7,
"mixed": 0,
"total": 7
}

View file

@ -1,6 +1,5 @@
{
"up": 93,
"up": 10,
"down": 0,
"mixed": 0,
"total": 93
"total": 10
}

View file

@ -5,11 +5,12 @@
*/
import uuid from 'uuid';
import { merge } from 'lodash';
import { merge, flattenDeep } from 'lodash';
const INDEX_NAME = 'heartbeat-8.0.0';
export const makePing = async (
es: any,
index: string,
monitorId: string,
fields: { [key: string]: any },
mogrify: (doc: any) => any
@ -101,7 +102,7 @@ export const makePing = async (
const doc = mogrify(merge(baseDoc, fields));
await es.index({
index,
index: INDEX_NAME,
refresh: true,
body: doc,
});
@ -111,7 +112,6 @@ export const makePing = async (
export const makeCheck = async (
es: any,
index: string,
monitorId: string,
numIps: number,
fields: { [key: string]: any },
@ -137,7 +137,7 @@ export const makeCheck = async (
if (i === numIps - 1) {
pingFields.summary = summary;
}
const doc = await makePing(es, index, monitorId, pingFields, mogrify);
const doc = await makePing(es, monitorId, pingFields, mogrify);
docs.push(doc);
// @ts-ignore
summary[doc.monitor.status]++;
@ -147,16 +147,78 @@ export const makeCheck = async (
export const makeChecks = async (
es: any,
index: string,
monitorId: string,
numChecks: number,
numIps: number,
every: number, // number of millis between checks
fields: { [key: string]: any } = {},
mogrify: (doc: any) => any = d => d
) => {
const checks = [];
const oldestTime = new Date().getTime() - numChecks * every;
let newestTime = oldestTime;
for (let li = 0; li < numChecks; li++) {
checks.push(await makeCheck(es, index, monitorId, numIps, fields, mogrify));
const checkDate = new Date(newestTime + every);
newestTime = checkDate.getTime() + every;
fields = merge(fields, {
'@timestamp': checkDate.toISOString(),
monitor: {
timespan: {
gte: checkDate.toISOString(),
lt: new Date(newestTime).toISOString(),
},
},
});
checks.push(await makeCheck(es, monitorId, numIps, fields, mogrify));
}
return checks;
};
export const makeChecksWithStatus = async (
es: any,
monitorId: string,
numChecks: number,
numIps: number,
every: number,
fields: { [key: string]: any } = {},
status: 'up' | 'down',
mogrify: (doc: any) => any = d => d
) => {
const oppositeStatus = status === 'up' ? 'down' : 'up';
return await makeChecks(es, monitorId, numChecks, numIps, every, fields, d => {
d.monitor.status = status;
if (d.summary) {
d.summary[status] += d.summary[oppositeStatus];
d.summary[oppositeStatus] = 0;
}
return mogrify(d);
});
};
// Helper for processing a list of checks to find the time picker bounds.
export const getChecksDateRange = (checks: any[]) => {
// Flatten 2d arrays
const flattened = flattenDeep(checks);
let startTime = 1 / 0;
let endTime = -1 / 0;
flattened.forEach(c => {
const ts = Date.parse(c['@timestamp']);
if (ts < startTime) {
startTime = ts;
}
if (ts > endTime) {
endTime = ts;
}
});
return {
start: new Date(startTime).toISOString(),
end: new Date(endTime).toISOString(),
};
};

View file

@ -8,7 +8,7 @@ import expect from '@kbn/expect';
import { monitorStatesQueryString } from '../../../../../legacy/plugins/uptime/public/queries/monitor_states_query';
import { expectFixtureEql } from './helpers/expect_fixture_eql';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { makeChecks } from './helpers/make_checks';
import { makeChecksWithStatus } from './helpers/make_checks';
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
@ -104,11 +104,10 @@ export default function({ getService }: FtrProviderContext) {
};
before(async () => {
const index = 'heartbeat-8.0.0';
const es = getService('legacyEs');
dateRangeStart = new Date().toISOString();
checks = await makeChecks(es, index, testMonitorId, 1, numIps, {}, d => {
checks = await makeChecksWithStatus(es, testMonitorId, 1, numIps, 1, {}, 'up', d => {
// turn an all up status into having at least one down
if (d.summary) {
d.monitor.status = 'down';
d.summary.up--;

View file

@ -9,8 +9,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
export default function({ getService, loadTestFile }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
describe('uptime REST endpoints', () => {
before('load heartbeat data', () => esArchiver.load('uptime/full_heartbeat'));
after('unload', () => esArchiver.unload('uptime/full_heartbeat'));
before('load heartbeat data', () => esArchiver.load('uptime/blank'));
after('unload', () => esArchiver.unload('uptime/blank'));
loadTestFile(require.resolve('./snapshot'));
});
}

View file

@ -6,47 +6,97 @@
import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { makeChecksWithStatus, getChecksDateRange } from '../graphql/helpers/make_checks';
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('snapshot count', () => {
let dateRangeStart = '2019-01-28T17:40:08.078Z';
let dateRangeEnd = '2025-01-28T19:00:16.078Z';
const dateRangeStart = new Date().toISOString();
const dateRangeEnd = new Date().toISOString();
it('will fetch the full set of snapshot counts', async () => {
const apiResponse = await supertest.get(
`/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}`
);
expectFixtureEql(apiResponse.body, 'snapshot');
describe('when no data is present', async () => {
it('returns a null snapshot', async () => {
const apiResponse = await supertest.get(
`/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}`
);
expectFixtureEql(apiResponse.body, 'snapshot_empty');
});
});
it('will fetch a monitor snapshot filtered by down status', async () => {
const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"down","operator":"and"}}}]}}`;
const statusFilter = 'down';
const apiResponse = await supertest.get(
`/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}&statusFilter=${statusFilter}`
);
expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_down');
});
describe('when data is present', async () => {
const numUpMonitors = 10;
const numDownMonitors = 7;
const numIps = 2;
const checksPerMonitor = 5;
const scheduleEvery = 10000; // fake monitor checks every 10s
let dateRange: { start: string; end: string };
it('will fetch a monitor snapshot filtered by up status', async () => {
const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"up","operator":"and"}}}]}}`;
const statusFilter = 'up';
const apiResponse = await supertest.get(
`/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}&statusFilter=${statusFilter}`
);
expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_up');
});
[true, false].forEach(async (includeTimespan: boolean) => {
describe(`with timespans ${includeTimespan ? 'included' : 'missing'}`, async () => {
before(async () => {
const promises: Array<Promise<any>> = [];
it('returns a null snapshot when no data is present', async () => {
dateRangeStart = '2019-01-25T04:30:54.740Z';
dateRangeEnd = '2025-01-28T04:50:54.740Z';
const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"down","operator":"and"}}}]}}`;
const apiResponse = await supertest.get(
`/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}`
);
expectFixtureEql(apiResponse.body, 'snapshot_empty');
// When includeTimespan is false we have to remove the values there.
let mogrify = (d: any) => d;
if ((includeTimespan = false)) {
mogrify = (d: any): any => {
d.monitor.delete('timespan');
return d;
};
}
const makeMonitorChecks = async (monitorId: string, status: 'up' | 'down') => {
return makeChecksWithStatus(
getService('legacyEs'),
monitorId,
checksPerMonitor,
numIps,
scheduleEvery,
{},
status,
mogrify
);
};
for (let i = 0; i < numUpMonitors; i++) {
promises.push(makeMonitorChecks(`up-${i}`, 'up'));
}
for (let i = 0; i < numDownMonitors; i++) {
promises.push(makeMonitorChecks(`down-${i}`, 'down'));
}
const allResults = await Promise.all(promises);
dateRange = getChecksDateRange(allResults);
});
it('will count all statuses correctly', async () => {
const apiResponse = await supertest.get(
`/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}`
);
expectFixtureEql(apiResponse.body, 'snapshot');
});
it('will fetch a monitor snapshot filtered by down status', async () => {
const statusFilter = 'down';
const apiResponse = await supertest.get(
`/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}&statusFilter=${statusFilter}`
);
expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_down');
});
it('will fetch a monitor snapshot filtered by up status', async () => {
const statusFilter = 'up';
const apiResponse = await supertest.get(
`/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}&statusFilter=${statusFilter}`
);
expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_up');
});
});
});
});
});
}

View file

@ -64,5 +64,27 @@ export default ({ getPageObjects }: FtrProviderContext) => {
'0009-up',
]);
});
describe('snapshot counts', () => {
it('updates the snapshot count when status filter is set to down', async () => {
await pageObjects.uptime.goToUptimePageAndSetDateRange(
DEFAULT_DATE_START,
DEFAULT_DATE_END
);
await pageObjects.uptime.setStatusFilter('down');
const counts = await pageObjects.uptime.getSnapshotCount();
expect(counts).to.eql({ up: '0', down: '7' });
});
it('updates the snapshot count when status filter is set to up', async () => {
await pageObjects.uptime.goToUptimePageAndSetDateRange(
DEFAULT_DATE_START,
DEFAULT_DATE_END
);
await pageObjects.uptime.setStatusFilter('up');
const counts = await pageObjects.uptime.getSnapshotCount();
expect(counts).to.eql({ up: '93', down: '0' });
});
});
});
};

View file

@ -1267,6 +1267,9 @@
"type": {
"ignore_above": 1024,
"type": "keyword"
},
"timespan": {
"type": "date_range"
}
}
},

View file

@ -70,5 +70,9 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo
await uptimeService.setStatusFilterDown();
}
}
public async getSnapshotCount() {
return await uptimeService.getSnapshotCount();
}
})();
}

View file

@ -49,5 +49,11 @@ export function UptimeProvider({ getService }: FtrProviderContext) {
async setStatusFilterDown() {
await testSubjects.click('xpack.uptime.filterBar.filterStatusDown');
},
async getSnapshotCount() {
return {
up: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.up'),
down: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.down'),
};
},
};
}