[APM] Optimize anomaly data loading strategy (#87522)

* [APM] Optimize anomaly data loading strategy

Closes #86423.

* Fix tests/types

* Review feedback

* Optimize ML calls for latency chart

* Optimize ML loading for latency chart

* Remove unused optimization

* Update snapshots for E2E tests

* Make sure area is stacked in correct order

* Review feedback + log warning if more than one ML job was found

* Review feedback
This commit is contained in:
Dario Gieselaar 2021-01-15 09:35:41 +01:00 committed by GitHub
parent f2699f9505
commit a0da8bda04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 617 additions and 608 deletions

View file

@ -4,9 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isFinite } from 'lodash';
import { Maybe } from '../../typings/common';
// _.isNumber() returns true for NaN, _.isFinite() does not refine
export function isFiniteNumber(value: Maybe<number>): value is number {
export function isFiniteNumber(value: any): value is number {
return isFinite(value);
}

View file

@ -100,7 +100,7 @@ export function LatencyChart({ height }: Props) {
id="latencyChart"
timeseries={latencyTimeseries}
yLabelFormat={getResponseTimeTickFormatter(latencyFormatter)}
anomalySeries={anomalyTimeseries}
anomalyTimeseries={anomalyTimeseries}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -55,7 +55,7 @@ interface Props {
yTickFormat?: (y: number) => string;
showAnnotations?: boolean;
yDomain?: YDomainRange;
anomalySeries?: ReturnType<
anomalyTimeseries?: ReturnType<
typeof getLatencyChartSelector
>['anomalyTimeseries'];
}
@ -70,7 +70,7 @@ export function TimeseriesChart({
yTickFormat,
showAnnotations = true,
yDomain,
anomalySeries,
anomalyTimeseries,
}: Props) {
const history = useHistory();
const { annotations } = useAnnotationsContext();
@ -90,6 +90,8 @@ export function TimeseriesChart({
const annotationColor = theme.eui.euiColorSecondary;
const allSeries = [...timeseries, ...(anomalyTimeseries?.boundaries ?? [])];
return (
<ChartContainer hasData={!isEmpty} height={height} status={fetchStatus}>
<Chart ref={chartRef} id={id}>
@ -150,7 +152,7 @@ export function TimeseriesChart({
/>
)}
{timeseries.map((serie) => {
{allSeries.map((serie) => {
const Series = serie.type === 'area' ? AreaSeries : LineSeries;
return (
@ -164,37 +166,28 @@ export function TimeseriesChart({
data={isEmpty ? [] : serie.data}
color={serie.color}
curve={CurveType.CURVE_MONOTONE_X}
hideInLegend={serie.hideLegend}
fit={serie.fit ?? undefined}
filterSeriesInTooltip={
serie.hideTooltipValue ? () => false : undefined
}
stackAccessors={serie.stackAccessors ?? undefined}
areaSeriesStyle={serie.areaSeriesStyle}
lineSeriesStyle={serie.lineSeriesStyle}
/>
);
})}
{anomalySeries?.bounderies && (
<AreaSeries
key={anomalySeries.bounderies.title}
id={anomalySeries.bounderies.title}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
y0Accessors={['y0']}
data={anomalySeries.bounderies.data}
color={anomalySeries.bounderies.color}
curve={CurveType.CURVE_MONOTONE_X}
hideInLegend
filterSeriesInTooltip={() => false}
/>
)}
{anomalySeries?.scores && (
{anomalyTimeseries?.scores && (
<RectAnnotation
key={anomalySeries.scores.title}
key={anomalyTimeseries.scores.title}
id="score_anomalies"
dataValues={(anomalySeries.scores.data as RectCoordinate[]).map(
dataValues={(anomalyTimeseries.scores.data as RectCoordinate[]).map(
({ x0, x: x1 }) => ({
coordinates: { x0, x1 },
})
)}
style={{ fill: anomalySeries.scores.color }}
style={{ fill: anomalyTimeseries.scores.color }}
/>
)}
</Chart>

View file

@ -4,18 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Coordinate, TimeSeries } from '../../../../../typings/timeseries';
import { isFiniteNumber } from '../../../../../common/utils/is_finite_number';
import { APMChartSpec, Coordinate } from '../../../../../typings/timeseries';
import { TimeFormatter } from '../../../../../common/utils/formatters';
export function getResponseTimeTickFormatter(formatter: TimeFormatter) {
return (t: number) => formatter(t).formatted;
}
export function getMaxY(timeSeries?: Array<TimeSeries<Coordinate>>) {
if (timeSeries) {
const coordinates = timeSeries.flatMap((serie) => serie.data);
const numbers = coordinates.map((c) => (c.y ? c.y : 0));
return Math.max(...numbers, 0);
export function getMaxY(specs?: Array<APMChartSpec<Coordinate>>) {
const values = specs
?.flatMap((spec) => spec.data)
.map((coord) => coord.y)
.filter(isFiniteNumber);
if (values?.length) {
return Math.max(...values, 0);
}
return 0;
}

View file

@ -25,7 +25,7 @@ const latencyChartData = {
latencyTimeseries: [{ x: 1, y: 10 }],
anomalyTimeseries: {
jobId: '1',
anomalyBoundaries: [{ x: 1, y: 2 }],
anomalyBoundaries: [{ x: 1, y: 2, y0: 1 }],
anomalyScore: [{ x: 1, x0: 2 }],
},
} as LatencyChartsResponse;
@ -110,30 +110,76 @@ describe('getLatencyChartSelector', () => {
latencyAggregationType: LatencyAggregationType.p99,
});
expect(latencyTimeseries).toEqual({
anomalyTimeseries: {
boundaries: [
{
color: 'rgba(0,0,0,0)',
areaSeriesStyle: {
point: {
opacity: 0,
},
},
data: [
{
x: 1,
y: 1,
},
],
fit: 'lookahead',
hideLegend: true,
hideTooltipValue: true,
stackAccessors: ['y'],
title: 'anomalyBoundariesLower',
type: 'area',
},
{
color: 'rgba(0,0,255,0.5)',
areaSeriesStyle: {
point: {
opacity: 0,
},
},
data: [
{
x: 1,
y: 1,
},
],
fit: 'lookahead',
hideLegend: true,
hideTooltipValue: true,
stackAccessors: ['y'],
title: 'anomalyBoundariesUpper',
type: 'area',
},
],
scores: {
color: 'yellow',
data: [
{
x: 1,
x0: 2,
},
],
title: 'anomalyScores',
type: 'rectAnnotation',
},
},
latencyTimeseries: [
{
color: 'black',
data: [
{
x: 1,
y: 10,
},
],
title: '99th percentile',
titleShort: '99th',
data: [{ x: 1, y: 10 }],
type: 'linemark',
color: 'black',
},
],
mlJobId: '1',
anomalyTimeseries: {
bounderies: {
title: 'Anomaly Boundaries',
data: [{ x: 1, y: 2 }],
type: 'area',
color: 'rgba(0,0,255,0.5)',
},
scores: {
title: 'Anomaly score',
data: [{ x: 1, x0: 2 }],
type: 'rectAnnotation',
color: 'yellow',
},
},
});
});
});

View file

@ -3,26 +3,20 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Fit } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { rgba } from 'polished';
import { EuiTheme } from '../../../observability/public';
import { asDuration } from '../../common/utils/formatters';
import {
Coordinate,
RectCoordinate,
TimeSeries,
} from '../../typings/timeseries';
import { APMChartSpec, Coordinate } from '../../typings/timeseries';
import { APIReturnType } from '../services/rest/createCallApmApi';
export type LatencyChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/latency'>;
interface LatencyChart {
latencyTimeseries: Array<TimeSeries<Coordinate>>;
interface LatencyChartData {
latencyTimeseries: Array<APMChartSpec<Coordinate>>;
mlJobId?: string;
anomalyTimeseries?: {
bounderies: TimeSeries;
scores: TimeSeries;
};
anomalyTimeseries?: { boundaries: APMChartSpec[]; scores: APMChartSpec };
}
export function getLatencyChartSelector({
@ -33,7 +27,7 @@ export function getLatencyChartSelector({
latencyChart?: LatencyChartsResponse;
theme: EuiTheme;
latencyAggregationType?: string;
}): LatencyChart {
}): LatencyChartData {
if (!latencyChart?.latencyTimeseries || !latencyAggregationType) {
return {
latencyTimeseries: [],
@ -48,7 +42,7 @@ export function getLatencyChartSelector({
latencyAggregationType,
}),
mlJobId: latencyChart.anomalyTimeseries?.jobId,
anomalyTimeseries: getAnnomalyTimeseries({
anomalyTimeseries: getAnomalyTimeseries({
anomalyTimeseries: latencyChart.anomalyTimeseries,
theme,
}),
@ -114,45 +108,57 @@ function getLatencyTimeseries({
return [];
}
function getAnnomalyTimeseries({
function getAnomalyTimeseries({
anomalyTimeseries,
theme,
}: {
anomalyTimeseries: LatencyChartsResponse['anomalyTimeseries'];
theme: EuiTheme;
}) {
if (anomalyTimeseries) {
return {
bounderies: getAnomalyBoundariesSeries(
anomalyTimeseries.anomalyBoundaries,
theme
),
scores: getAnomalyScoreSeries(anomalyTimeseries.anomalyScore, theme),
};
}): { boundaries: APMChartSpec[]; scores: APMChartSpec } | undefined {
if (!anomalyTimeseries) {
return undefined;
}
}
export function getAnomalyScoreSeries(data: RectCoordinate[], theme: EuiTheme) {
return {
title: i18n.translate('xpack.apm.transactions.chart.anomalyScoreLabel', {
defaultMessage: 'Anomaly score',
}),
data,
const boundariesConfigBase = {
type: 'area',
fit: Fit.Lookahead,
hideLegend: true,
hideTooltipValue: true,
stackAccessors: ['y'],
areaSeriesStyle: {
point: {
opacity: 0,
},
},
};
const boundaries = [
{
...boundariesConfigBase,
title: 'anomalyBoundariesLower',
data: anomalyTimeseries.anomalyBoundaries.map((coord) => ({
x: coord.x,
y: coord.y0,
})),
color: rgba(0, 0, 0, 0),
},
{
...boundariesConfigBase,
title: 'anomalyBoundariesUpper',
data: anomalyTimeseries.anomalyBoundaries.map((coord) => ({
x: coord.x,
y: coord.y - coord.y0,
})),
color: rgba(theme.eui.euiColorVis1, 0.5),
},
];
const scores = {
title: 'anomalyScores',
type: 'rectAnnotation',
data: anomalyTimeseries.anomalyScore,
color: theme.eui.euiColorVis9,
};
}
function getAnomalyBoundariesSeries(data: Coordinate[], theme: EuiTheme) {
return {
title: i18n.translate(
'xpack.apm.transactions.chart.anomalyBoundariesLabel',
{
defaultMessage: 'Anomaly Boundaries',
}
),
data,
type: 'area',
color: rgba(theme.eui.euiColorVis1, 0.5),
};
return { boundaries, scores };
}

View file

@ -4,13 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from '@hapi/boom';
import { sortBy, uniqBy } from 'lodash';
import { ESSearchResponse } from '../../../../../typings/elasticsearch';
import { MlPluginSetup } from '../../../../ml/server';
import { PromiseReturnType } from '../../../../observability/typings/common';
import {
getSeverity,
ML_ERRORS,
ServiceAnomalyStats,
} from '../../../common/anomaly_detection';
import { getSeverity, ML_ERRORS } from '../../../common/anomaly_detection';
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
import { getServiceHealthStatus } from '../../../common/service_health_status';
import {
@ -20,7 +18,10 @@ import {
import { getMlJobsWithAPMGroup } from '../anomaly_detection/get_ml_jobs_with_apm_group';
import { Setup, SetupTimeRange } from '../helpers/setup_request';
export const DEFAULT_ANOMALIES = { mlJobIds: [], serviceAnomalies: {} };
export const DEFAULT_ANOMALIES: ServiceAnomaliesResponse = {
mlJobIds: [],
serviceAnomalies: [],
};
export type ServiceAnomaliesResponse = PromiseReturnType<
typeof getServiceAnomalies
@ -39,21 +40,6 @@ export async function getServiceAnomalies({
throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE);
}
const mlCapabilities = await ml.mlSystem.mlCapabilities();
if (!mlCapabilities.mlFeatureEnabledInSpace) {
throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE);
}
const mlJobIds = await getMLJobIds(ml.anomalyDetectors, environment);
if (!mlJobIds.length) {
return {
mlJobIds: [],
serviceAnomalies: {},
};
}
const params = {
body: {
size: 0,
@ -61,7 +47,6 @@ export async function getServiceAnomalies({
bool: {
filter: [
{ terms: { result_type: ['model_plot', 'record'] } },
{ terms: { job_id: mlJobIds } },
{
range: {
timestamp: {
@ -83,19 +68,25 @@ export async function getServiceAnomalies({
},
aggs: {
services: {
terms: { field: 'partition_field_value' },
composite: {
size: 5000,
sources: [
{ serviceName: { terms: { field: 'partition_field_value' } } },
{ jobId: { terms: { field: 'job_id' } } },
],
},
aggs: {
top_score: {
top_hits: {
sort: { record_score: 'desc' },
_source: [
'actual',
'job_id',
'by_field_value',
'result_type',
'record_score',
],
size: 1,
metrics: {
top_metrics: {
metrics: [
{ field: 'actual' },
{ field: 'by_field_value' },
{ field: 'result_type' },
{ field: 'record_score' },
] as const,
sort: {
record_score: 'desc' as const,
},
},
},
},
@ -104,80 +95,54 @@ export async function getServiceAnomalies({
},
};
const response = await ml.mlSystem.mlAnomalySearch(params, mlJobIds);
const [anomalyResponse, jobIds] = await Promise.all([
// pass an empty array of job ids to anomaly search
// so any validation is skipped
ml.mlSystem.mlAnomalySearch(params, []),
getMLJobIds(ml.anomalyDetectors, environment),
]);
const typedAnomalyResponse: ESSearchResponse<
unknown,
typeof params
> = anomalyResponse as any;
const relevantBuckets = uniqBy(
sortBy(
// make sure we only return data for jobs that are available in this space
typedAnomalyResponse.aggregations?.services.buckets.filter((bucket) =>
jobIds.includes(bucket.key.jobId as string)
) ?? [],
// sort by job ID in case there are multiple jobs for one service to
// ensure consistent results
(bucket) => bucket.key.jobId
),
// return one bucket per service
(bucket) => bucket.key.serviceName
);
return {
mlJobIds,
serviceAnomalies: transformResponseToServiceAnomalies(
response as ServiceAnomaliesAggResponse
),
};
}
interface ServiceAnomaliesAggResponse {
aggregations: {
services: {
buckets: Array<{
key: string;
top_score: {
hits: {
hits: Array<{
sort: [number];
_source: {
job_id: string;
by_field_value: string;
} & (
| {
record_score: number | null;
result_type: 'record';
actual: number[];
}
| {
result_type: 'model_plot';
actual?: number;
}
);
}>;
};
};
}>;
};
};
}
function transformResponseToServiceAnomalies(
response: ServiceAnomaliesAggResponse
) {
const serviceAnomaliesMap = (
response.aggregations?.services.buckets ?? []
).reduce<Record<string, ServiceAnomalyStats>>(
(statsByServiceName, { key: serviceName, top_score: topScoreAgg }) => {
const mlResult = topScoreAgg.hits.hits[0]._source;
mlJobIds: jobIds,
serviceAnomalies: relevantBuckets.map((bucket) => {
const metrics = bucket.metrics.top[0].metrics;
const anomalyScore =
(mlResult.result_type === 'record' && mlResult.record_score) || 0;
metrics.result_type === 'record' && metrics.record_score
? (metrics.record_score as number)
: 0;
const severity = getSeverity(anomalyScore);
const healthStatus = getServiceHealthStatus({ severity });
return {
...statsByServiceName,
[serviceName]: {
transactionType: mlResult.by_field_value,
jobId: mlResult.job_id,
actualValue:
mlResult.result_type === 'record'
? mlResult.actual[0]
: mlResult.actual,
anomalyScore,
healthStatus,
},
serviceName: bucket.key.serviceName as string,
jobId: bucket.key.jobId as string,
transactionType: metrics.by_field_value as string,
actualValue: metrics.actual as number | null,
anomalyScore,
healthStatus,
};
},
{}
);
return serviceAnomaliesMap;
}),
};
}
export async function getMLJobs(

View file

@ -18,7 +18,6 @@ import { Setup, SetupTimeRange } from '../helpers/setup_request';
import {
DEFAULT_ANOMALIES,
getServiceAnomalies,
ServiceAnomaliesResponse,
} from './get_service_anomalies';
import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids';
import { getTraceSampleIds } from './get_trace_sample_ids';
@ -149,7 +148,7 @@ export type ServiceMapAPIResponse = PromiseReturnType<typeof getServiceMap>;
export async function getServiceMap(options: IEnvOptions) {
const { logger } = options;
const anomaliesPromise: Promise<ServiceAnomaliesResponse> = getServiceAnomalies(
const anomaliesPromise = getServiceAnomalies(
options
// always catch error to avoid breaking service maps if there is a problem with ML

View file

@ -39,15 +39,16 @@ const javaService = {
const anomalies = {
mlJobIds: ['apm-test-1234-ml-module-name'],
serviceAnomalies: {
'opbeans-test': {
serviceAnomalies: [
{
serviceName: 'opbeans-test',
transactionType: 'request',
actualValue: 10000,
anomalyScore: 50,
jobId: 'apm-test-1234-ml-module-name',
healthStatus: ServiceHealthStatus.warning,
},
},
],
};
describe('transformServiceMapResponses', () => {

View file

@ -110,7 +110,9 @@ export function transformServiceMapResponses(response: ServiceMapResponse) {
const mergedServiceNode = Object.assign({}, ...matchedServiceNodes);
const serviceAnomalyStats = serviceName
? anomalies.serviceAnomalies[serviceName]
? anomalies.serviceAnomalies.find(
(item) => item.serviceName === serviceName
)
: null;
if (matchedServiceNodes.length) {

View file

@ -6,10 +6,7 @@
import { getSeverity } from '../../../../common/anomaly_detection';
import { getServiceHealthStatus } from '../../../../common/service_health_status';
import {
getMLJobIds,
getServiceAnomalies,
} from '../../service_map/get_service_anomalies';
import { getServiceAnomalies } from '../../service_map/get_service_anomalies';
import {
ServicesItemsProjection,
ServicesItemsSetup,
@ -29,27 +26,16 @@ export const getHealthStatuses = async (
return [];
}
const jobIds = await getMLJobIds(
setup.ml.anomalyDetectors,
mlAnomaliesEnvironment
);
if (!jobIds.length) {
return [];
}
const anomalies = await getServiceAnomalies({
setup,
environment: mlAnomaliesEnvironment,
});
return Object.keys(anomalies.serviceAnomalies).map((serviceName) => {
const stats = anomalies.serviceAnomalies[serviceName];
const severity = getSeverity(stats.anomalyScore);
return anomalies.serviceAnomalies.map((anomalyStats) => {
const severity = getSeverity(anomalyStats.anomalyScore);
const healthStatus = getServiceHealthStatus({ severity });
return {
serviceName,
serviceName: anomalyStats.serviceName,
healthStatus,
};
});

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger } from 'kibana/server';
import { ESSearchResponse } from '../../../../../../typings/elasticsearch';
import { PromiseReturnType } from '../../../../../observability/typings/common';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { Setup } from '../../helpers/setup_request';
export type ESResponse = Exclude<
PromiseReturnType<typeof anomalySeriesFetcher>,
@ -18,81 +17,74 @@ export async function anomalySeriesFetcher({
serviceName,
transactionType,
intervalString,
mlBucketSize,
setup,
jobId,
logger,
ml,
start,
end,
}: {
serviceName: string;
transactionType: string;
intervalString: string;
mlBucketSize: number;
setup: Setup & SetupTimeRange;
jobId: string;
logger: Logger;
ml: Required<Setup>['ml'];
start: number;
end: number;
}) {
const { ml, start, end } = setup;
if (!ml) {
return;
}
// move the start back with one bucket size, to ensure to get anomaly data in the beginning
// this is required because ML has a minimum bucket size (default is 900s) so if our buckets are smaller, we might have several null buckets in the beginning
const newStart = start - mlBucketSize * 1000;
const params = {
body: {
size: 0,
query: {
bool: {
filter: [
{ term: { job_id: jobId } },
{ exists: { field: 'bucket_span' } },
{ terms: { result_type: ['model_plot', 'record'] } },
{ term: { partition_field_value: serviceName } },
{ term: { by_field_value: transactionType } },
{
range: {
timestamp: { gte: newStart, lte: end, format: 'epoch_millis' },
timestamp: {
gte: start,
lte: end,
format: 'epoch_millis',
},
},
},
],
},
},
aggs: {
ml_avg_response_times: {
date_histogram: {
field: 'timestamp',
fixed_interval: intervalString,
min_doc_count: 0,
extended_bounds: { min: newStart, max: end },
job_id: {
terms: {
field: 'job_id',
},
aggs: {
anomaly_score: { max: { field: 'record_score' } },
lower: { min: { field: 'model_lower' } },
upper: { max: { field: 'model_upper' } },
ml_avg_response_times: {
date_histogram: {
field: 'timestamp',
fixed_interval: intervalString,
extended_bounds: { min: start, max: end },
},
aggs: {
anomaly_score: {
top_metrics: {
metrics: [
{ field: 'record_score' },
{ field: 'timestamp' },
{ field: 'bucket_span' },
] as const,
sort: {
record_score: 'desc' as const,
},
},
},
lower: { min: { field: 'model_lower' } },
upper: { max: { field: 'model_upper' } },
},
},
},
},
},
},
};
try {
const response: ESSearchResponse<
unknown,
typeof params
> = (await ml.mlSystem.mlAnomalySearch(params, [jobId])) as any;
return response;
} catch (err) {
const isHttpError = 'statusCode' in err;
if (isHttpError) {
logger.info(
`Status code "${err.statusCode}" while retrieving ML anomalies for APM`
);
return;
}
logger.error('An error occurred while retrieving ML anomalies for APM');
logger.error(err);
}
return (ml.mlSystem.mlAnomalySearch(params, []) as unknown) as Promise<
ESSearchResponse<unknown, typeof params>
>;
}

View file

@ -1,61 +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 { Logger } from 'kibana/server';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
interface IOptions {
setup: Setup & SetupTimeRange;
jobId: string;
logger: Logger;
}
interface ESResponse {
bucket_span: number;
}
export async function getMlBucketSize({
setup,
jobId,
logger,
}: IOptions): Promise<number | undefined> {
const { ml, start, end } = setup;
if (!ml) {
return;
}
const params = {
body: {
_source: 'bucket_span',
size: 1,
terminate_after: 1,
query: {
bool: {
filter: [
{ term: { job_id: jobId } },
{ exists: { field: 'bucket_span' } },
{
range: {
timestamp: { gte: start, lte: end, format: 'epoch_millis' },
},
},
],
},
},
},
};
try {
const resp = await ml.mlSystem.mlAnomalySearch<ESResponse>(params, [jobId]);
return resp.hits.hits[0]?._source.bucket_span;
} catch (err) {
const isHttpError = 'statusCode' in err;
if (isHttpError) {
return;
}
logger.error(err);
}
}

View file

@ -3,53 +3,47 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger } from 'kibana/server';
import { isNumber } from 'lodash';
import { compact } from 'lodash';
import { Logger } from 'src/core/server';
import { isFiniteNumber } from '../../../../common/utils/is_finite_number';
import { maybe } from '../../../../common/utils/maybe';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { getBucketSize } from '../../helpers/get_bucket_size';
import { Setup, SetupTimeRange } from '../../helpers/setup_request';
import { anomalySeriesFetcher } from './fetcher';
import { getMlBucketSize } from './get_ml_bucket_size';
import { anomalySeriesTransform } from './transform';
import { getMLJobIds } from '../../service_map/get_service_anomalies';
import { getLatencyTimeseries } from '../get_latency_charts';
import { PromiseReturnType } from '../../../../../observability/typings/common';
import { ANOMALY_THRESHOLD } from '../../../../../ml/common';
export async function getAnomalySeries({
serviceName,
transactionType,
transactionName,
latencyTimeseries,
setup,
logger,
}: {
serviceName: string;
transactionType: string | undefined;
transactionName: string | undefined;
latencyTimeseries: PromiseReturnType<
typeof getLatencyTimeseries
>['latencyTimeseries'];
transactionType: string;
transactionName?: string;
setup: Setup & SetupTimeRange;
logger: Logger;
}) {
const timeseriesDates = latencyTimeseries?.map(({ x }) => x);
const { uiFilters, start, end, ml } = setup;
const { environment } = uiFilters;
/*
* don't fetch:
* - anomalies for transaction details page
* - anomalies without a type
* - timeseries is empty
*/
if (transactionName || !transactionType || !timeseriesDates?.length) {
return;
// don't fetch anomalies if the ML plugin is not setup
if (!ml) {
return undefined;
}
const { uiFilters, start, end } = setup;
const { environment } = uiFilters;
// don't fetch anomalies if requested for a specific transaction name
// as ML results are not partitioned by transaction name
if (!!transactionName) {
return undefined;
}
// don't fetch anomalies when no specific environment is selected
if (environment === ENVIRONMENT_ALL.value) {
return;
return undefined;
}
// don't fetch anomalies if unknown uiFilters are applied
@ -60,48 +54,77 @@ export async function getAnomalySeries({
.some((uiFilterName) => !knownFilters.includes(uiFilterName));
if (hasUnknownFiltersApplied) {
return;
return undefined;
}
// don't fetch anomalies if the ML plugin is not setup
if (!setup.ml) {
return;
}
const { intervalString } = getBucketSize({ start, end });
// don't fetch anomalies if required license is not satisfied
const mlCapabilities = await setup.ml.mlSystem.mlCapabilities();
if (!mlCapabilities.isPlatinumOrTrialLicense) {
return;
}
// move the start back with one bucket size, to ensure to get anomaly data in the beginning
// this is required because ML has a minimum bucket size (default is 900s) so if our buckets
// are smaller, we might have several null buckets in the beginning
const mlStart = start - 900 * 1000;
const mlJobIds = await getMLJobIds(setup.ml.anomalyDetectors, environment);
const [anomaliesResponse, jobIds] = await Promise.all([
anomalySeriesFetcher({
serviceName,
transactionType,
intervalString,
ml,
start: mlStart,
end,
}),
getMLJobIds(ml.anomalyDetectors, environment),
]);
const jobId = mlJobIds[0];
const scoreSeriesCollection = anomaliesResponse?.aggregations?.job_id.buckets
.filter((bucket) => jobIds.includes(bucket.key as string))
.map((bucket) => {
const dateBuckets = bucket.ml_avg_response_times.buckets;
const mlBucketSize = await getMlBucketSize({ setup, jobId, logger });
if (!isNumber(mlBucketSize)) {
return;
}
return {
jobId: bucket.key as string,
anomalyScore: compact(
dateBuckets.map((dateBucket) => {
const metrics = maybe(dateBucket.anomaly_score.top[0])?.metrics;
const score = metrics?.record_score;
const { intervalString, bucketSize } = getBucketSize({ start, end });
if (
!metrics ||
!isFiniteNumber(score) ||
score < ANOMALY_THRESHOLD.CRITICAL
) {
return null;
}
const esResponse = await anomalySeriesFetcher({
serviceName,
transactionType,
intervalString,
mlBucketSize,
setup,
jobId,
logger,
});
const anomalyStart = Date.parse(metrics.timestamp as string);
const anomalyEnd =
anomalyStart + (metrics.bucket_span as number) * 1000;
if (esResponse && mlBucketSize > 0) {
return anomalySeriesTransform(
esResponse,
mlBucketSize,
bucketSize,
timeseriesDates,
jobId
return {
x0: anomalyStart,
x: anomalyEnd,
y: score,
};
})
),
anomalyBoundaries: dateBuckets
.filter(
(dateBucket) =>
dateBucket.lower.value !== null && dateBucket.upper.value !== null
)
.map((dateBucket) => ({
x: dateBucket.key,
y0: dateBucket.lower.value as number,
y: dateBucket.upper.value as number,
})),
};
});
if ((scoreSeriesCollection?.length ?? 0) > 1) {
logger.warn(
`More than one ML job was found for ${serviceName} for environment ${environment}. Only showing results from ${scoreSeriesCollection?.[0].jobId}`
);
}
return scoreSeriesCollection?.[0];
}

View file

@ -1,134 +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 { first, last } from 'lodash';
import { Coordinate, RectCoordinate } from '../../../../typings/timeseries';
import { ESResponse } from './fetcher';
type IBucket = ReturnType<typeof getBucket>;
function getBucket(
bucket: Required<ESResponse>['aggregations']['ml_avg_response_times']['buckets'][0]
) {
return {
x: bucket.key,
anomalyScore: bucket.anomaly_score.value,
lower: bucket.lower.value,
upper: bucket.upper.value,
};
}
export type AnomalyTimeSeriesResponse = ReturnType<
typeof anomalySeriesTransform
>;
export function anomalySeriesTransform(
response: ESResponse,
mlBucketSize: number,
bucketSize: number,
timeSeriesDates: number[],
jobId: string
) {
const buckets =
response.aggregations?.ml_avg_response_times.buckets.map(getBucket) || [];
const bucketSizeInMillis = Math.max(bucketSize, mlBucketSize) * 1000;
return {
jobId,
anomalyScore: getAnomalyScoreDataPoints(
buckets,
timeSeriesDates,
bucketSizeInMillis
),
anomalyBoundaries: getAnomalyBoundaryDataPoints(buckets, timeSeriesDates),
};
}
export function getAnomalyScoreDataPoints(
buckets: IBucket[],
timeSeriesDates: number[],
bucketSizeInMillis: number
): RectCoordinate[] {
const ANOMALY_THRESHOLD = 75;
const firstDate = first(timeSeriesDates);
const lastDate = last(timeSeriesDates);
if (firstDate === undefined || lastDate === undefined) {
return [];
}
return buckets
.filter(
(bucket) =>
bucket.anomalyScore !== null && bucket.anomalyScore > ANOMALY_THRESHOLD
)
.filter(isInDateRange(firstDate, lastDate))
.map((bucket) => {
return {
x0: bucket.x,
x: Math.min(bucket.x + bucketSizeInMillis, lastDate), // don't go beyond last date
};
});
}
export function getAnomalyBoundaryDataPoints(
buckets: IBucket[],
timeSeriesDates: number[]
): Coordinate[] {
return replaceFirstAndLastBucket(buckets, timeSeriesDates)
.filter((bucket) => bucket.lower !== null)
.map((bucket) => {
return {
x: bucket.x,
y0: bucket.lower,
y: bucket.upper,
};
});
}
export function replaceFirstAndLastBucket(
buckets: IBucket[],
timeSeriesDates: number[]
) {
const firstDate = first(timeSeriesDates);
const lastDate = last(timeSeriesDates);
if (firstDate === undefined || lastDate === undefined) {
return buckets;
}
const preBucketWithValue = buckets
.filter((p) => p.x <= firstDate)
.reverse()
.find((p) => p.lower !== null);
const bucketsInRange = buckets.filter(isInDateRange(firstDate, lastDate));
// replace first bucket if it is null
const firstBucket = first(bucketsInRange);
if (preBucketWithValue && firstBucket && firstBucket.lower === null) {
firstBucket.lower = preBucketWithValue.lower;
firstBucket.upper = preBucketWithValue.upper;
}
const lastBucketWithValue = [...buckets]
.reverse()
.find((p) => p.lower !== null);
// replace last bucket if it is null
const lastBucket = last(bucketsInRange);
if (lastBucketWithValue && lastBucket && lastBucket.lower === null) {
lastBucket.lower = lastBucketWithValue.lower;
lastBucket.upper = lastBucketWithValue.upper;
}
return bucketsInRange;
}
// anomaly time series contain one or more buckets extra in the beginning
// these extra buckets should be removed
function isInDateRange(firstDate: number, lastDate: number) {
return (p: IBucket) => p.x >= firstDate && p.x <= lastDate;
}

View file

@ -169,23 +169,28 @@ export const transactionLatencyChatsRoute = createRoute({
transactionName,
setup,
searchAggregatedTransactions,
logger,
};
const {
const [latencyData, anomalyTimeseries] = await Promise.all([
getLatencyTimeseries({
...options,
latencyAggregationType: latencyAggregationType as LatencyAggregationType,
}),
getAnomalySeries(options).catch((error) => {
logger.warn(`Unable to retrieve anomalies for latency charts.`);
logger.error(error);
return undefined;
}),
]);
const { latencyTimeseries, overallAvgDuration } = latencyData;
return {
latencyTimeseries,
overallAvgDuration,
} = await getLatencyTimeseries({
...options,
latencyAggregationType: latencyAggregationType as LatencyAggregationType,
});
const anomalyTimeseries = await getAnomalySeries({
...options,
logger,
latencyTimeseries,
});
return { latencyTimeseries, overallAvgDuration, anomalyTimeseries };
anomalyTimeseries,
};
},
});

View file

@ -3,6 +3,14 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
AccessorFn,
AreaSeriesStyle,
Fit,
FitConfig,
LineSeriesStyle,
} from '@elastic/charts';
import { DeepPartial } from 'utility-types';
import { Maybe } from '../typings/common';
export interface Coordinate {
@ -15,7 +23,13 @@ export interface RectCoordinate {
x0: number;
}
export interface TimeSeries<
type Accessor = Array<string | number | AccessorFn>;
export type TimeSeries<
TCoordinate extends { x: number } = Coordinate | RectCoordinate
> = APMChartSpec<TCoordinate>;
export interface APMChartSpec<
TCoordinate extends { x: number } = Coordinate | RectCoordinate
> {
title: string;
@ -27,6 +41,11 @@ export interface TimeSeries<
type: string;
color: string;
areaColor?: string;
fit?: Exclude<Fit, 'explicit'> | FitConfig;
stackAccessors?: Accessor;
splitSeriesAccessors?: Accessor;
lineSeriesStyle?: DeepPartial<LineSeriesStyle>;
areaSeriesStyle?: DeepPartial<AreaSeriesStyle>;
}
export type ChartType = 'area' | 'linemark';

View file

@ -5482,8 +5482,6 @@
"xpack.apm.transactionOverview.userExperience.calloutTitle": "導入Elasticユーザーエクスペリエンス",
"xpack.apm.transactionOverview.userExperience.linkLabel": "移動",
"xpack.apm.transactionRateLabel": "{value} tpm",
"xpack.apm.transactions.chart.anomalyBoundariesLabel": "異常境界",
"xpack.apm.transactions.chart.anomalyScoreLabel": "異常スコア",
"xpack.apm.transactions.latency.chart.95thPercentileLabel": "95 パーセンタイル",
"xpack.apm.transactions.latency.chart.99thPercentileLabel": "99 パーセンタイル",
"xpack.apm.transactions.latency.chart.averageLabel": "平均",

View file

@ -5492,8 +5492,6 @@
"xpack.apm.transactionOverview.userExperience.calloutTitle": "即将引入Elastic 用户体验",
"xpack.apm.transactionOverview.userExperience.linkLabel": "带我前往此处",
"xpack.apm.transactionRateLabel": "{value} tpm",
"xpack.apm.transactions.chart.anomalyBoundariesLabel": "异常边界",
"xpack.apm.transactions.chart.anomalyScoreLabel": "异常分数",
"xpack.apm.transactions.latency.chart.95thPercentileLabel": "第 95 个百分位",
"xpack.apm.transactions.latency.chart.99thPercentileLabel": "第 99 个百分位",
"xpack.apm.transactions.latency.chart.averageLabel": "平均值",

View file

@ -167,9 +167,11 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
"id": "opbeans-python",
"service.name": "opbeans-python",
"serviceAnomalyStats": Object {
"actualValue": 24282.2352941176,
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-production-1369-high_mean_transaction_duration",
"jobId": "apm-environment_not_defined-5626-high_mean_transaction_duration",
"serviceName": "opbeans-python",
"transactionType": "request",
},
},
@ -185,6 +187,7 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-testing-384f-high_mean_transaction_duration",
"serviceName": "opbeans-node",
"transactionType": "request",
},
},
@ -200,6 +203,7 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
"anomalyScore": 0,
"healthStatus": "healthy",
"jobId": "apm-testing-384f-high_mean_transaction_duration",
"serviceName": "opbeans-rum",
"transactionType": "page-load",
},
},

View file

@ -12,11 +12,6 @@ Array [
"y": 0,
"y0": 0,
},
Object {
"x": 1607437650000,
"y": 0,
"y0": 0,
},
]
`;
@ -32,11 +27,6 @@ Array [
"y": 1660982.24115757,
"y0": 5732.00699123528,
},
Object {
"x": 1607437650000,
"y": 1660982.24115757,
"y0": 5732.00699123528,
},
]
`;
@ -52,10 +42,5 @@ Array [
"y": 1660982.24115757,
"y0": 5732.00699123528,
},
Object {
"x": 1607437650000,
"y": 1660982.24115757,
"y0": 5732.00699123528,
},
]
`;

View file

@ -387,22 +387,24 @@ interface AggregationResponsePart<TAggregationOptionsMap extends AggregationOpti
};
bucket_sort: undefined;
bucket_selector: undefined;
top_metrics: [
{
sort: [string | number];
metrics: UnionToIntersection<
TAggregationOptionsMap extends {
top_metrics: { metrics: { field: infer TFieldName } };
}
? TopMetricsMap<TFieldName>
: TAggregationOptionsMap extends {
top_metrics: { metrics: MaybeReadonlyArray<{ field: infer TFieldName }> };
}
? TopMetricsMap<TFieldName>
: TopMetricsMap<string>
>;
}
];
top_metrics: {
top: [
{
sort: [string | number];
metrics: UnionToIntersection<
TAggregationOptionsMap extends {
top_metrics: { metrics: { field: infer TFieldName } };
}
? TopMetricsMap<TFieldName>
: TAggregationOptionsMap extends {
top_metrics: { metrics: MaybeReadonlyArray<{ field: infer TFieldName }> };
}
? TopMetricsMap<TFieldName>
: TopMetricsMap<string>
>;
}
];
};
avg_bucket: {
value: number | null;
};