Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[APM] Anomaly detection integration with transaction duration chart #71230

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export function TransactionDetails() {
<EuiSpacer size="s" />

<TransactionCharts
hasMLJob={false}
charts={transactionChartsData}
urlParams={urlParams}
location={location}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,6 @@ export function TransactionOverview() {
<EuiSpacer size="s" />

<TransactionCharts
// TODO [APM ML] set hasMLJob prop when ML integration is reintroduced:
hasMLJob={false}
charts={transactionCharts}
location={location}
urlParams={urlParams}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import {
} from '../../../../../common/transaction_types';

interface TransactionChartProps {
hasMLJob: boolean;
charts: ITransactionChartData;
location: Location;
urlParams: IUrlParams;
Expand Down Expand Up @@ -96,18 +95,19 @@ export class TransactionCharts extends Component<TransactionChartProps> {
};

public renderMLHeader(hasValidMlLicense: boolean | undefined) {
const { hasMLJob } = this.props;
if (!hasValidMlLicense || !hasMLJob) {
const {
charts: { mlJobId },
} = this.props;
ogupte marked this conversation as resolved.
Show resolved Hide resolved

if (!hasValidMlLicense || !mlJobId) {
return null;
}

const { serviceName, kuery } = this.props.urlParams;
const { serviceName, kuery, transactionType } = this.props.urlParams;
if (!serviceName) {
return null;
}

const linkedJobId = ''; // TODO [APM ML] link to ML job id for the selected environment

const hasKuery = !isEmpty(kuery);
const icon = hasKuery ? (
<EuiIconTip
Expand Down Expand Up @@ -140,7 +140,13 @@ export class TransactionCharts extends Component<TransactionChartProps> {
}
)}{' '}
</span>
<MLJobLink jobId={linkedJobId}>View Job</MLJobLink>
<MLJobLink
jobId={mlJobId}
serviceName={serviceName}
transactionType={transactionType}
>
View Job
</MLJobLink>
</ShiftedEuiText>
</EuiFlexItem>
);
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/apm/public/selectors/chartSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ITpmBucket {
export interface ITransactionChartData {
tpmSeries: ITpmBucket[];
responseTimeSeries: TimeSeries[];
mlJobId: string | undefined;
}

const INITIAL_DATA = {
Expand Down Expand Up @@ -62,6 +63,7 @@ export function getTransactionCharts(
return {
tpmSeries,
responseTimeSeries,
mlJobId: anomalyTimeseries?.jobId,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* 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 { PromiseReturnType } from '../../../../../../observability/typings/common';
import { Setup, SetupTimeRange } from '../../../helpers/setup_request';

export type ESResponse = Exclude<
PromiseReturnType<typeof anomalySeriesFetcher>,
undefined
>;

export async function anomalySeriesFetcher({
serviceName,
transactionType,
intervalString,
mlBucketSize,
setup,
jobId,
logger,
}: {
serviceName: string;
transactionType: string;
intervalString: string;
mlBucketSize: number;
setup: Setup & SetupTimeRange;
jobId: string;
logger: Logger;
}) {
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' } },
{ term: { result_type: 'model_plot' } },
{ term: { partition_field_value: serviceName } },
{ term: { by_field_value: transactionType } },
{
range: {
timestamp: { gte: newStart, 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 },
},
aggs: {
anomaly_score: { max: { field: 'anomaly_score' } },
lower: { min: { field: 'model_lower' } },
upper: { max: { field: 'model_upper' } },
},
},
},
},
};

try {
const response = await ml.mlSystem.mlAnomalySearch(params);
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* 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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps add terminateAfter: 1 since we only need a single result

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);
return resp.hits.hits[0]?._source.bucket_span;
} catch (err) {
const isHttpError = 'statusCode' in err;
if (isHttpError) {
return;
}
logger.error(err);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,37 @@
* 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 { getBucketSize } from '../../../helpers/get_bucket_size';
import {
Setup,
SetupTimeRange,
SetupUIFilters,
} from '../../../helpers/setup_request';
import { Coordinate, RectCoordinate } from '../../../../../typings/timeseries';

interface AnomalyTimeseries {
anomalyBoundaries: Coordinate[];
anomalyScore: RectCoordinate[];
}
import { anomalySeriesFetcher } from './fetcher';
import { getMlBucketSize } from './get_ml_bucket_size';
import { anomalySeriesTransform } from './transform';
import { getMLJobIds } from '../../../service_map/get_service_anomalies';
import { UIFilters } from '../../../../../typings/ui_filters';

export async function getAnomalySeries({
serviceName,
transactionType,
transactionName,
timeSeriesDates,
setup,
logger,
uiFilters,
}: {
serviceName: string;
transactionType: string | undefined;
transactionName: string | undefined;
timeSeriesDates: number[];
setup: Setup & SetupTimeRange & SetupUIFilters;
}): Promise<void | AnomalyTimeseries> {
logger: Logger;
uiFilters: UIFilters;
}) {
// don't fetch anomalies for transaction details page
if (transactionName) {
return;
Expand All @@ -39,8 +44,12 @@ export async function getAnomalySeries({
return;
}

// don't fetch anomalies if uiFilters are applied
if (setup.uiFiltersES.length > 0) {
// don't fetch anomalies if unknown uiFilters are applied
const knownFilters = ['environment', 'serviceName'];
const uiFilterNames = Object.keys(uiFilters);
if (
uiFilterNames.some((uiFilterName) => !knownFilters.includes(uiFilterName))
) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks much better 👍

return;
}

Expand All @@ -55,6 +64,39 @@ export async function getAnomalySeries({
return;
}

// TODO [APM ML] return a series of anomaly scores, upper & lower bounds for the given timeSeriesDates
return;
const mlJobIds = await getMLJobIds(setup.ml, uiFilters.environment);

// don't fetch anomalies if there are more than 1 ML jobs for the given environment
if (mlJobIds.length > 1) {
return;
}
const jobId = mlJobIds[0];

const mlBucketSize = await getMlBucketSize({ setup, jobId, logger });
if (!isNumber(mlBucketSize)) {
return;
}

const { start, end } = setup;
const { intervalString, bucketSize } = getBucketSize(start, end, 'auto');

const esResponse = await anomalySeriesFetcher({
serviceName,
transactionType,
intervalString,
mlBucketSize,
setup,
jobId,
logger,
});

if (esResponse && mlBucketSize > 0) {
return anomalySeriesTransform(
esResponse,
mlBucketSize,
bucketSize,
timeSeriesDates,
jobId
);
}
}
Loading