[APM] Pulls legacy ML code from service maps and integrations (#69779)
* Pulls out existing ML integration from the service maps * - removes ML job creation flyout in integrations menu on the service details UI - removes ML searches and transforms in the transaction charts API - removes unused shared functions and types related to the legacy ML integration * removes unused translations for APM anomaly detection * Adds tags to TODOs for easy searching later
This commit is contained in:
parent
14f975c899
commit
e3598cbeca
|
@ -4,45 +4,9 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
getMlJobId,
|
||||
getMlPrefix,
|
||||
getMlJobServiceName,
|
||||
getSeverity,
|
||||
severity,
|
||||
} from './ml_job_constants';
|
||||
import { getSeverity, severity } from './ml_job_constants';
|
||||
|
||||
describe('ml_job_constants', () => {
|
||||
it('getMlPrefix', () => {
|
||||
expect(getMlPrefix('myServiceName')).toBe('myservicename-');
|
||||
expect(getMlPrefix('myServiceName', 'myTransactionType')).toBe(
|
||||
'myservicename-mytransactiontype-'
|
||||
);
|
||||
});
|
||||
|
||||
it('getMlJobId', () => {
|
||||
expect(getMlJobId('myServiceName')).toBe(
|
||||
'myservicename-high_mean_response_time'
|
||||
);
|
||||
expect(getMlJobId('myServiceName', 'myTransactionType')).toBe(
|
||||
'myservicename-mytransactiontype-high_mean_response_time'
|
||||
);
|
||||
expect(getMlJobId('my service name')).toBe(
|
||||
'my_service_name-high_mean_response_time'
|
||||
);
|
||||
expect(getMlJobId('my service name', 'my transaction type')).toBe(
|
||||
'my_service_name-my_transaction_type-high_mean_response_time'
|
||||
);
|
||||
});
|
||||
|
||||
describe('getMlJobServiceName', () => {
|
||||
it('extracts the service name from a job id', () => {
|
||||
expect(
|
||||
getMlJobServiceName('opbeans-node-request-high_mean_response_time')
|
||||
).toEqual('opbeans-node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSeverity', () => {
|
||||
describe('when score is undefined', () => {
|
||||
it('returns undefined', () => {
|
||||
|
|
|
@ -11,25 +11,6 @@ export enum severity {
|
|||
warning = 'warning',
|
||||
}
|
||||
|
||||
export const APM_ML_JOB_GROUP_NAME = 'apm';
|
||||
|
||||
export function getMlPrefix(serviceName: string, transactionType?: string) {
|
||||
const maybeTransactionType = transactionType ? `${transactionType}-` : '';
|
||||
return encodeForMlApi(`${serviceName}-${maybeTransactionType}`);
|
||||
}
|
||||
|
||||
export function getMlJobId(serviceName: string, transactionType?: string) {
|
||||
return `${getMlPrefix(serviceName, transactionType)}high_mean_response_time`;
|
||||
}
|
||||
|
||||
export function getMlJobServiceName(jobId: string) {
|
||||
return jobId.split('-').slice(0, -2).join('-');
|
||||
}
|
||||
|
||||
export function encodeForMlApi(value: string) {
|
||||
return value.replace(/\s+/g, '_').toLowerCase();
|
||||
}
|
||||
|
||||
export function getSeverity(score?: number) {
|
||||
if (typeof score !== 'number') {
|
||||
return undefined;
|
||||
|
|
|
@ -34,16 +34,6 @@ export interface Connection {
|
|||
destination: ConnectionNode;
|
||||
}
|
||||
|
||||
export interface ServiceAnomaly {
|
||||
anomaly_score: number;
|
||||
anomaly_severity: string;
|
||||
actual_value: number;
|
||||
typical_value: number;
|
||||
ml_job_id: string;
|
||||
}
|
||||
|
||||
export type ServiceNode = ConnectionNode & Partial<ServiceAnomaly>;
|
||||
|
||||
export interface ServiceNodeMetrics {
|
||||
avgMemoryUsage: number | null;
|
||||
avgCpuUsage: number | null;
|
||||
|
|
|
@ -1,56 +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 {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiSuperSelect,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
|
||||
interface TransactionSelectProps {
|
||||
transactionTypes: string[];
|
||||
onChange: (value: string) => void;
|
||||
selectedTransactionType: string;
|
||||
}
|
||||
|
||||
export function TransactionSelect({
|
||||
transactionTypes,
|
||||
onChange,
|
||||
selectedTransactionType,
|
||||
}: TransactionSelectProps) {
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.selectTransactionTypeLabel',
|
||||
{
|
||||
defaultMessage: 'Select a transaction type for this job',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<EuiSuperSelect
|
||||
valueOfSelected={selectedTransactionType}
|
||||
onChange={onChange}
|
||||
options={transactionTypes.map((transactionType) => {
|
||||
return {
|
||||
value: transactionType,
|
||||
inputDisplay: transactionType,
|
||||
dropdownDisplay: (
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<EuiText>{transactionType}</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
|
@ -1,167 +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 { i18n } from '@kbn/i18n';
|
||||
import React, { Component } from 'react';
|
||||
import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public';
|
||||
import { startMLJob, MLError } from '../../../../../services/rest/ml';
|
||||
import { IUrlParams } from '../../../../../context/UrlParamsContext/types';
|
||||
import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink';
|
||||
import { MachineLearningFlyoutView } from './view';
|
||||
import { ApmPluginContext } from '../../../../../context/ApmPluginContext';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
urlParams: IUrlParams;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isCreatingJob: boolean;
|
||||
}
|
||||
|
||||
export class MachineLearningFlyout extends Component<Props, State> {
|
||||
static contextType = ApmPluginContext;
|
||||
|
||||
public state: State = {
|
||||
isCreatingJob: false,
|
||||
};
|
||||
|
||||
public onClickCreate = async ({
|
||||
transactionType,
|
||||
}: {
|
||||
transactionType: string;
|
||||
}) => {
|
||||
this.setState({ isCreatingJob: true });
|
||||
try {
|
||||
const { http } = this.context.core;
|
||||
const { serviceName } = this.props.urlParams;
|
||||
if (!serviceName) {
|
||||
throw new Error('Service name is required to create this ML job');
|
||||
}
|
||||
const res = await startMLJob({ http, serviceName, transactionType });
|
||||
const didSucceed = res.datafeeds[0].success && res.jobs[0].success;
|
||||
if (!didSucceed) {
|
||||
throw new Error('Creating ML job failed');
|
||||
}
|
||||
this.addSuccessToast({ transactionType });
|
||||
} catch (e) {
|
||||
this.addErrorToast(e as MLError);
|
||||
}
|
||||
|
||||
this.setState({ isCreatingJob: false });
|
||||
this.props.onClose();
|
||||
};
|
||||
|
||||
public addErrorToast = (error: MLError) => {
|
||||
const { core } = this.context;
|
||||
|
||||
const { urlParams } = this.props;
|
||||
const { serviceName } = urlParams;
|
||||
|
||||
if (!serviceName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorDescription = error?.body?.message;
|
||||
const errorText = errorDescription
|
||||
? `${error.message}: ${errorDescription}`
|
||||
: error.message;
|
||||
|
||||
core.notifications.toasts.addWarning({
|
||||
title: i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Job creation failed',
|
||||
}
|
||||
),
|
||||
text: toMountPoint(
|
||||
<>
|
||||
<p>{errorText}</p>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText',
|
||||
{
|
||||
defaultMessage:
|
||||
'Your current license may not allow for creating machine learning jobs, or this job may already exist.',
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
public addSuccessToast = ({
|
||||
transactionType,
|
||||
}: {
|
||||
transactionType: string;
|
||||
}) => {
|
||||
const { core } = this.context;
|
||||
const { urlParams } = this.props;
|
||||
const { serviceName } = urlParams;
|
||||
|
||||
if (!serviceName) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.notifications.toasts.addSuccess({
|
||||
title: i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle',
|
||||
{
|
||||
defaultMessage: 'Job successfully created',
|
||||
}
|
||||
),
|
||||
text: toMountPoint(
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText',
|
||||
{
|
||||
defaultMessage:
|
||||
'The analysis is now running for {serviceName} ({transactionType}). It might take a while before results are added to the response times graph.',
|
||||
values: {
|
||||
serviceName,
|
||||
transactionType,
|
||||
},
|
||||
}
|
||||
)}{' '}
|
||||
<ApmPluginContext.Provider value={this.context}>
|
||||
<MLJobLink
|
||||
serviceName={serviceName}
|
||||
transactionType={transactionType}
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText',
|
||||
{
|
||||
defaultMessage: 'View job',
|
||||
}
|
||||
)}
|
||||
</MLJobLink>
|
||||
</ApmPluginContext.Provider>
|
||||
</p>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { isOpen, onClose, urlParams } = this.props;
|
||||
const { serviceName } = urlParams;
|
||||
const { isCreatingJob } = this.state;
|
||||
|
||||
if (!isOpen || !serviceName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MachineLearningFlyoutView
|
||||
isCreatingJob={isCreatingJob}
|
||||
onClickCreate={this.onClickCreate}
|
||||
onClose={onClose}
|
||||
urlParams={urlParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,264 +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 {
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiFormRow,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { FETCH_STATUS, useFetcher } from '../../../../../hooks/useFetcher';
|
||||
import { getHasMLJob } from '../../../../../services/rest/ml';
|
||||
import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink';
|
||||
import { MLLink } from '../../../../shared/Links/MachineLearningLinks/MLLink';
|
||||
import { TransactionSelect } from './TransactionSelect';
|
||||
import { IUrlParams } from '../../../../../context/UrlParamsContext/types';
|
||||
import { useServiceTransactionTypes } from '../../../../../hooks/useServiceTransactionTypes';
|
||||
import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext';
|
||||
|
||||
interface Props {
|
||||
isCreatingJob: boolean;
|
||||
onClickCreate: ({ transactionType }: { transactionType: string }) => void;
|
||||
onClose: () => void;
|
||||
urlParams: IUrlParams;
|
||||
}
|
||||
|
||||
export function MachineLearningFlyoutView({
|
||||
isCreatingJob,
|
||||
onClickCreate,
|
||||
onClose,
|
||||
urlParams,
|
||||
}: Props) {
|
||||
const { serviceName } = urlParams;
|
||||
const transactionTypes = useServiceTransactionTypes(urlParams);
|
||||
|
||||
const [selectedTransactionType, setSelectedTransactionType] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const { http } = useApmPluginContext().core;
|
||||
|
||||
const { data: hasMLJob, status } = useFetcher(
|
||||
() => {
|
||||
if (serviceName && selectedTransactionType) {
|
||||
return getHasMLJob({
|
||||
serviceName,
|
||||
transactionType: selectedTransactionType,
|
||||
http,
|
||||
});
|
||||
}
|
||||
},
|
||||
[serviceName, selectedTransactionType, http],
|
||||
{ showToastOnError: false }
|
||||
);
|
||||
|
||||
// update selectedTransactionType when list of transaction types has loaded
|
||||
useEffect(() => {
|
||||
setSelectedTransactionType(transactionTypes[0]);
|
||||
}, [transactionTypes]);
|
||||
|
||||
if (!serviceName || !selectedTransactionType || isEmpty(transactionTypes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLoadingMLJob = status === FETCH_STATUS.LOADING;
|
||||
const isMlAvailable = status !== FETCH_STATUS.FAILURE;
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={onClose} size="s">
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle',
|
||||
{
|
||||
defaultMessage: 'Enable anomaly detection',
|
||||
}
|
||||
)}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
{!isMlAvailable && (
|
||||
<div>
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.mlNotAvailable',
|
||||
{
|
||||
defaultMessage: 'Machine learning not available',
|
||||
}
|
||||
)}
|
||||
color="warning"
|
||||
iconType="alert"
|
||||
>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.mlNotAvailableDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'Unable to connect to Machine learning. Make sure it is enabled in Kibana to use anomaly detection.',
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
</div>
|
||||
)}
|
||||
{hasMLJob && (
|
||||
<div>
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle',
|
||||
{
|
||||
defaultMessage: 'Job already exists',
|
||||
}
|
||||
)}
|
||||
color="success"
|
||||
iconType="check"
|
||||
>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'There is currently a job running for {serviceName} ({transactionType}).',
|
||||
values: {
|
||||
serviceName,
|
||||
transactionType: selectedTransactionType,
|
||||
},
|
||||
}
|
||||
)}{' '}
|
||||
<MLJobLink
|
||||
serviceName={serviceName}
|
||||
transactionType={selectedTransactionType}
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText',
|
||||
{
|
||||
defaultMessage: 'View existing job',
|
||||
}
|
||||
)}
|
||||
</MLJobLink>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
</div>
|
||||
)}
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription"
|
||||
defaultMessage="Create a machine learning job to calculate anomaly scores on APM transaction durations
|
||||
within the {serviceName} service. When enabled, anomalies are show in two places:
|
||||
The {transactionDurationGraphText} graph will show the expected bounds and annotate
|
||||
the graph if the anomaly score is >=75, and {serviceMapAnnotationText} will display color
|
||||
coded service indicators based on the active anomaly score."
|
||||
values={{
|
||||
serviceName,
|
||||
transactionDurationGraphText: (
|
||||
<b>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText',
|
||||
{
|
||||
defaultMessage: 'transaction duration',
|
||||
}
|
||||
)}
|
||||
</b>
|
||||
),
|
||||
serviceMapAnnotationText: (
|
||||
<b>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.serviceMapAnnotationText',
|
||||
{
|
||||
defaultMessage: 'service maps',
|
||||
}
|
||||
)}
|
||||
</b>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription"
|
||||
defaultMessage="Jobs can be created for each service and transaction type.
|
||||
Once a job is created, you can manage it and see more details on the {mlJobsPageLink}."
|
||||
values={{
|
||||
mlJobsPageLink: (
|
||||
<MLLink>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText',
|
||||
{
|
||||
defaultMessage: 'Machine Learning Job Management page',
|
||||
}
|
||||
)}
|
||||
</MLLink>
|
||||
),
|
||||
}}
|
||||
/>{' '}
|
||||
<em>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.noteText',
|
||||
{
|
||||
defaultMessage:
|
||||
'Note: It might take a few minutes for the job to begin calculating results.',
|
||||
}
|
||||
)}
|
||||
</em>
|
||||
</p>
|
||||
</EuiText>
|
||||
|
||||
<EuiSpacer />
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd">
|
||||
<EuiFlexItem>
|
||||
{transactionTypes.length > 1 ? (
|
||||
<TransactionSelect
|
||||
selectedTransactionType={selectedTransactionType}
|
||||
transactionTypes={transactionTypes}
|
||||
onChange={(value: string) => {
|
||||
setSelectedTransactionType(value);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFormRow>
|
||||
<EuiButton
|
||||
onClick={() =>
|
||||
onClickCreate({ transactionType: selectedTransactionType })
|
||||
}
|
||||
fill
|
||||
disabled={isCreatingJob || hasMLJob || isLoadingMLJob}
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Create job',
|
||||
}
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
}
|
|
@ -4,18 +4,10 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiContextMenu,
|
||||
EuiContextMenuPanelItemDescriptor,
|
||||
EuiPopover,
|
||||
} from '@elastic/eui';
|
||||
import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { memoize } from 'lodash';
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
|
||||
import { LicenseContext } from '../../../../context/LicenseContext';
|
||||
import { MachineLearningFlyout } from './MachineLearningFlyout';
|
||||
import { WatcherFlyout } from './WatcherFlyout';
|
||||
import { ApmPluginContext } from '../../../../context/ApmPluginContext';
|
||||
|
||||
|
@ -26,7 +18,7 @@ interface State {
|
|||
isPopoverOpen: boolean;
|
||||
activeFlyout: FlyoutName;
|
||||
}
|
||||
type FlyoutName = null | 'ML' | 'Watcher';
|
||||
type FlyoutName = null | 'Watcher';
|
||||
|
||||
export class ServiceIntegrations extends React.Component<Props, State> {
|
||||
static contextType = ApmPluginContext;
|
||||
|
@ -34,38 +26,6 @@ export class ServiceIntegrations extends React.Component<Props, State> {
|
|||
|
||||
public state: State = { isPopoverOpen: false, activeFlyout: null };
|
||||
|
||||
public getPanelItems = memoize((mlAvailable: boolean | undefined) => {
|
||||
let panelItems: EuiContextMenuPanelItemDescriptor[] = [];
|
||||
if (mlAvailable) {
|
||||
panelItems = panelItems.concat(this.getMLPanelItems());
|
||||
}
|
||||
return panelItems.concat(this.getWatcherPanelItems());
|
||||
});
|
||||
|
||||
public getMLPanelItems = () => {
|
||||
return [
|
||||
{
|
||||
name: i18n.translate(
|
||||
'xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Enable ML anomaly detection',
|
||||
}
|
||||
),
|
||||
icon: 'machineLearningApp',
|
||||
toolTipContent: i18n.translate(
|
||||
'xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonTooltip',
|
||||
{
|
||||
defaultMessage: 'Set up a machine learning job for this service',
|
||||
}
|
||||
),
|
||||
onClick: () => {
|
||||
this.closePopover();
|
||||
this.openFlyout('ML');
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
public getWatcherPanelItems = () => {
|
||||
const { core } = this.context;
|
||||
|
||||
|
@ -132,42 +92,31 @@ export class ServiceIntegrations extends React.Component<Props, State> {
|
|||
);
|
||||
|
||||
return (
|
||||
<LicenseContext.Consumer>
|
||||
{(license) => (
|
||||
<Fragment>
|
||||
<EuiPopover
|
||||
id="integrations-menu"
|
||||
button={button}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downRight"
|
||||
>
|
||||
<EuiContextMenu
|
||||
initialPanelId={0}
|
||||
panels={[
|
||||
{
|
||||
id: 0,
|
||||
items: this.getPanelItems(
|
||||
license?.getFeature('ml').isAvailable
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiPopover>
|
||||
<MachineLearningFlyout
|
||||
isOpen={this.state.activeFlyout === 'ML'}
|
||||
onClose={this.closeFlyouts}
|
||||
urlParams={this.props.urlParams}
|
||||
/>
|
||||
<WatcherFlyout
|
||||
isOpen={this.state.activeFlyout === 'Watcher'}
|
||||
onClose={this.closeFlyouts}
|
||||
urlParams={this.props.urlParams}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</LicenseContext.Consumer>
|
||||
<>
|
||||
<EuiPopover
|
||||
id="integrations-menu"
|
||||
button={button}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downRight"
|
||||
>
|
||||
<EuiContextMenu
|
||||
initialPanelId={0}
|
||||
panels={[
|
||||
{
|
||||
id: 0,
|
||||
items: this.getWatcherPanelItems(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiPopover>
|
||||
<WatcherFlyout
|
||||
isOpen={this.state.activeFlyout === 'Watcher'}
|
||||
onClose={this.closeFlyouts}
|
||||
urlParams={this.props.urlParams}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,8 +15,6 @@ import React, { MouseEvent } from 'react';
|
|||
import { Buttons } from './Buttons';
|
||||
import { Info } from './Info';
|
||||
import { ServiceMetricFetcher } from './ServiceMetricFetcher';
|
||||
import { AnomalyDetection } from './anomaly_detection';
|
||||
import { ServiceNode } from '../../../../../common/service_map';
|
||||
import { popoverMinWidth } from '../cytoscapeOptions';
|
||||
|
||||
interface ContentsProps {
|
||||
|
@ -70,12 +68,13 @@ export function Contents({
|
|||
</EuiTitle>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</FlexColumnItem>
|
||||
{isService && (
|
||||
{/* //TODO [APM ML] add service health stats here:
|
||||
isService && (
|
||||
<FlexColumnItem>
|
||||
<AnomalyDetection serviceNodeData={selectedNodeData as ServiceNode} />
|
||||
<ServiceHealth serviceNodeData={selectedNodeData} />
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</FlexColumnItem>
|
||||
)}
|
||||
)*/}
|
||||
<FlexColumnItem>
|
||||
{isService ? (
|
||||
<ServiceMetricFetcher serviceName={selectedNodeServiceName} />
|
||||
|
|
|
@ -1,157 +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 { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiTitle,
|
||||
EuiIconTip,
|
||||
EuiHealth,
|
||||
} from '@elastic/eui';
|
||||
import { useTheme } from '../../../../hooks/useTheme';
|
||||
import { fontSize, px } from '../../../../style/variables';
|
||||
import { asInteger } from '../../../../utils/formatters';
|
||||
import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink';
|
||||
import { getSeverityColor, popoverMinWidth } from '../cytoscapeOptions';
|
||||
import { getMetricChangeDescription } from '../../../../../../ml/public';
|
||||
import { ServiceNode } from '../../../../../common/service_map';
|
||||
|
||||
const HealthStatusTitle = styled(EuiTitle)`
|
||||
display: inline;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const VerticallyCentered = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SubduedText = styled.span`
|
||||
color: ${({ theme }) => theme.eui.euiTextSubduedColor};
|
||||
`;
|
||||
|
||||
const EnableText = styled.section`
|
||||
color: ${({ theme }) => theme.eui.euiTextSubduedColor};
|
||||
line-height: 1.4;
|
||||
font-size: ${fontSize};
|
||||
width: ${px(popoverMinWidth)};
|
||||
`;
|
||||
|
||||
export const ContentLine = styled.section`
|
||||
line-height: 2;
|
||||
`;
|
||||
|
||||
interface AnomalyDetectionProps {
|
||||
serviceNodeData: cytoscape.NodeDataDefinition & ServiceNode;
|
||||
}
|
||||
|
||||
export function AnomalyDetection({ serviceNodeData }: AnomalyDetectionProps) {
|
||||
const theme = useTheme();
|
||||
const anomalySeverity = serviceNodeData.anomaly_severity;
|
||||
const anomalyScore = serviceNodeData.anomaly_score;
|
||||
const actualValue = serviceNodeData.actual_value;
|
||||
const typicalValue = serviceNodeData.typical_value;
|
||||
const mlJobId = serviceNodeData.ml_job_id;
|
||||
const hasAnomalyDetectionScore =
|
||||
anomalySeverity !== undefined && anomalyScore !== undefined;
|
||||
const anomalyDescription =
|
||||
hasAnomalyDetectionScore &&
|
||||
actualValue !== undefined &&
|
||||
typicalValue !== undefined
|
||||
? getMetricChangeDescription(actualValue, typicalValue).message
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<HealthStatusTitle size="xxs">
|
||||
<h3>{ANOMALY_DETECTION_TITLE}</h3>
|
||||
</HealthStatusTitle>
|
||||
|
||||
<EuiIconTip type="iInCircle" content={ANOMALY_DETECTION_TOOLTIP} />
|
||||
{!mlJobId && <EnableText>{ANOMALY_DETECTION_DISABLED_TEXT}</EnableText>}
|
||||
</section>
|
||||
{hasAnomalyDetectionScore && (
|
||||
<ContentLine>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<VerticallyCentered>
|
||||
<EuiHealth color={getSeverityColor(theme, anomalySeverity)} />
|
||||
<SubduedText>{ANOMALY_DETECTION_SCORE_METRIC}</SubduedText>
|
||||
</VerticallyCentered>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<div>
|
||||
{getDisplayedAnomalyScore(anomalyScore as number)}
|
||||
{anomalyDescription && (
|
||||
<SubduedText> ({anomalyDescription})</SubduedText>
|
||||
)}
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</ContentLine>
|
||||
)}
|
||||
{mlJobId && !hasAnomalyDetectionScore && (
|
||||
<EnableText>{ANOMALY_DETECTION_NO_DATA_TEXT}</EnableText>
|
||||
)}
|
||||
{mlJobId && (
|
||||
<ContentLine>
|
||||
<MLJobLink external jobId={mlJobId}>
|
||||
{ANOMALY_DETECTION_LINK}
|
||||
</MLJobLink>
|
||||
</ContentLine>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayedAnomalyScore(score: number) {
|
||||
if (score > 0 && score < 1) {
|
||||
return '< 1';
|
||||
}
|
||||
return asInteger(score);
|
||||
}
|
||||
|
||||
const ANOMALY_DETECTION_TITLE = i18n.translate(
|
||||
'xpack.apm.serviceMap.anomalyDetectionPopoverTitle',
|
||||
{ defaultMessage: 'Anomaly Detection' }
|
||||
);
|
||||
|
||||
const ANOMALY_DETECTION_TOOLTIP = i18n.translate(
|
||||
'xpack.apm.serviceMap.anomalyDetectionPopoverTooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'Service health indicators are powered by the anomaly detection feature in machine learning',
|
||||
}
|
||||
);
|
||||
|
||||
const ANOMALY_DETECTION_SCORE_METRIC = i18n.translate(
|
||||
'xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric',
|
||||
{ defaultMessage: 'Score (max.)' }
|
||||
);
|
||||
|
||||
const ANOMALY_DETECTION_LINK = i18n.translate(
|
||||
'xpack.apm.serviceMap.anomalyDetectionPopoverLink',
|
||||
{ defaultMessage: 'View anomalies' }
|
||||
);
|
||||
|
||||
const ANOMALY_DETECTION_DISABLED_TEXT = i18n.translate(
|
||||
'xpack.apm.serviceMap.anomalyDetectionPopoverDisabled',
|
||||
{
|
||||
defaultMessage:
|
||||
'Display service health indicators by enabling anomaly detection from the Integrations menu in the Service details view.',
|
||||
}
|
||||
);
|
||||
|
||||
const ANOMALY_DETECTION_NO_DATA_TEXT = i18n.translate(
|
||||
'xpack.apm.serviceMap.anomalyDetectionPopoverNoData',
|
||||
{
|
||||
defaultMessage: `We couldn't find an anomaly score within the selected time range. See details in the anomaly explorer.`,
|
||||
}
|
||||
);
|
|
@ -22,8 +22,6 @@ import { TransactionCharts } from '../../shared/charts/TransactionCharts';
|
|||
import { TransactionBreakdown } from '../../shared/TransactionBreakdown';
|
||||
import { TransactionList } from './List';
|
||||
import { useRedirect } from './useRedirect';
|
||||
import { useFetcher } from '../../../hooks/useFetcher';
|
||||
import { getHasMLJob } from '../../../services/rest/ml';
|
||||
import { history } from '../../../utils/history';
|
||||
import { useLocation } from '../../../hooks/useLocation';
|
||||
import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext';
|
||||
|
@ -34,7 +32,6 @@ import { PROJECTION } from '../../../../common/projections/typings';
|
|||
import { useUrlParams } from '../../../hooks/useUrlParams';
|
||||
import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes';
|
||||
import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter';
|
||||
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
|
||||
|
||||
function getRedirectLocation({
|
||||
urlParams,
|
||||
|
@ -86,18 +83,6 @@ export function TransactionOverview() {
|
|||
status: transactionListStatus,
|
||||
} = useTransactionList(urlParams);
|
||||
|
||||
const { http } = useApmPluginContext().core;
|
||||
|
||||
const { data: hasMLJob = false } = useFetcher(
|
||||
() => {
|
||||
if (serviceName && transactionType) {
|
||||
return getHasMLJob({ serviceName, transactionType, http });
|
||||
}
|
||||
},
|
||||
[http, serviceName, transactionType],
|
||||
{ showToastOnError: false }
|
||||
);
|
||||
|
||||
const localFiltersConfig: React.ComponentProps<typeof LocalUIFilters> = useMemo(
|
||||
() => ({
|
||||
filterNames: [
|
||||
|
@ -140,7 +125,8 @@ export function TransactionOverview() {
|
|||
<EuiSpacer size="s" />
|
||||
|
||||
<TransactionCharts
|
||||
hasMLJob={hasMLJob}
|
||||
// TODO [APM ML] set hasMLJob prop when ML integration is reintroduced:
|
||||
hasMLJob={false}
|
||||
charts={transactionCharts}
|
||||
location={location}
|
||||
urlParams={urlParams}
|
||||
|
|
|
@ -10,21 +10,6 @@ import { getRenderedHref } from '../../../../utils/testHelpers';
|
|||
import { MLJobLink } from './MLJobLink';
|
||||
|
||||
describe('MLJobLink', () => {
|
||||
it('should produce the correct URL with serviceName', async () => {
|
||||
const href = await getRenderedHref(
|
||||
() => (
|
||||
<MLJobLink
|
||||
serviceName="myServiceName"
|
||||
transactionType="myTransactionType"
|
||||
/>
|
||||
),
|
||||
{ search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location
|
||||
);
|
||||
|
||||
expect(href).toEqual(
|
||||
`/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))`
|
||||
);
|
||||
});
|
||||
it('should produce the correct URL with jobId', async () => {
|
||||
const href = await getRenderedHref(
|
||||
() => (
|
||||
|
|
|
@ -5,28 +5,16 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { getMlJobId } from '../../../../../common/ml_job_constants';
|
||||
import { MLLink } from './MLLink';
|
||||
|
||||
interface PropsServiceName {
|
||||
serviceName: string;
|
||||
transactionType?: string;
|
||||
}
|
||||
interface PropsJobId {
|
||||
interface Props {
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
type Props = (PropsServiceName | PropsJobId) & {
|
||||
external?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const MLJobLink: React.FC<Props> = (props) => {
|
||||
const jobId =
|
||||
'jobId' in props
|
||||
? props.jobId
|
||||
: getMlJobId(props.serviceName, props.transactionType);
|
||||
const query = {
|
||||
ml: { jobIds: [jobId] },
|
||||
ml: { jobIds: [props.jobId] },
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -101,11 +101,13 @@ export class TransactionCharts extends Component<TransactionChartProps> {
|
|||
return null;
|
||||
}
|
||||
|
||||
const { serviceName, transactionType, kuery } = this.props.urlParams;
|
||||
const { serviceName, kuery } = 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
|
||||
|
@ -138,12 +140,7 @@ export class TransactionCharts extends Component<TransactionChartProps> {
|
|||
}
|
||||
)}{' '}
|
||||
</span>
|
||||
<MLJobLink
|
||||
serviceName={serviceName}
|
||||
transactionType={transactionType}
|
||||
>
|
||||
View Job
|
||||
</MLJobLink>
|
||||
<MLJobLink jobId={linkedJobId}>View Job</MLJobLink>
|
||||
</ShiftedEuiText>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
|
|
|
@ -1,123 +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 { HttpSetup } from 'kibana/public';
|
||||
import {
|
||||
PROCESSOR_EVENT,
|
||||
SERVICE_NAME,
|
||||
TRANSACTION_TYPE,
|
||||
} from '../../../common/elasticsearch_fieldnames';
|
||||
import {
|
||||
APM_ML_JOB_GROUP_NAME,
|
||||
getMlJobId,
|
||||
getMlPrefix,
|
||||
encodeForMlApi,
|
||||
} from '../../../common/ml_job_constants';
|
||||
import { callApi } from './callApi';
|
||||
import { ESFilter } from '../../../typings/elasticsearch';
|
||||
import { callApmApi } from './createCallApmApi';
|
||||
|
||||
interface MlResponseItem {
|
||||
id: string;
|
||||
success: boolean;
|
||||
error?: {
|
||||
msg: string;
|
||||
body: string;
|
||||
path: string;
|
||||
response: string;
|
||||
statusCode: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface StartedMLJobApiResponse {
|
||||
datafeeds: MlResponseItem[];
|
||||
jobs: MlResponseItem[];
|
||||
}
|
||||
|
||||
async function getTransactionIndices() {
|
||||
const indices = await callApmApi({
|
||||
method: 'GET',
|
||||
pathname: `/api/apm/settings/apm-indices`,
|
||||
});
|
||||
return indices['apm_oss.transactionIndices'];
|
||||
}
|
||||
|
||||
export async function startMLJob({
|
||||
serviceName,
|
||||
transactionType,
|
||||
http,
|
||||
}: {
|
||||
serviceName: string;
|
||||
transactionType: string;
|
||||
http: HttpSetup;
|
||||
}) {
|
||||
const transactionIndices = await getTransactionIndices();
|
||||
const groups = [
|
||||
APM_ML_JOB_GROUP_NAME,
|
||||
encodeForMlApi(serviceName),
|
||||
encodeForMlApi(transactionType),
|
||||
];
|
||||
const filter: ESFilter[] = [
|
||||
{ term: { [SERVICE_NAME]: serviceName } },
|
||||
{ term: { [PROCESSOR_EVENT]: 'transaction' } },
|
||||
{ term: { [TRANSACTION_TYPE]: transactionType } },
|
||||
];
|
||||
return callApi<StartedMLJobApiResponse>(http, {
|
||||
method: 'POST',
|
||||
pathname: `/api/ml/modules/setup/apm_transaction`,
|
||||
body: {
|
||||
prefix: getMlPrefix(serviceName, transactionType),
|
||||
groups,
|
||||
indexPatternName: transactionIndices,
|
||||
startDatafeed: true,
|
||||
query: {
|
||||
bool: {
|
||||
filter,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// https://www.elastic.co/guide/en/elasticsearch/reference/6.5/ml-get-job.html
|
||||
export interface MLJobApiResponse {
|
||||
count: number;
|
||||
jobs: Array<{
|
||||
job_id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type MLError = Error & { body?: { message?: string } };
|
||||
|
||||
export async function getHasMLJob({
|
||||
serviceName,
|
||||
transactionType,
|
||||
http,
|
||||
}: {
|
||||
serviceName: string;
|
||||
transactionType: string;
|
||||
http: HttpSetup;
|
||||
}) {
|
||||
try {
|
||||
await callApi<MLJobApiResponse>(http, {
|
||||
method: 'GET',
|
||||
pathname: `/api/ml/anomaly_detectors/${getMlJobId(
|
||||
serviceName,
|
||||
transactionType
|
||||
)}`,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
error?.body?.statusCode === 404 &&
|
||||
error?.body?.attributes?.body?.error?.type ===
|
||||
'resource_not_found_exception'
|
||||
) {
|
||||
return false; // false only if ML api responds with resource_not_found_exception
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -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 { getApmMlJobCategory } from './get_service_anomalies';
|
||||
import { Job as AnomalyDetectionJob } from '../../../../ml/server';
|
||||
|
||||
describe('getApmMlJobCategory', () => {
|
||||
it('should match service names with different casings', () => {
|
||||
const mlJob = {
|
||||
job_id: 'testservice-request-high_mean_response_time',
|
||||
groups: ['apm', 'testservice', 'request'],
|
||||
} as AnomalyDetectionJob;
|
||||
const serviceNames = ['testService'];
|
||||
const apmMlJobCategory = getApmMlJobCategory(mlJob, serviceNames);
|
||||
|
||||
expect(apmMlJobCategory).toEqual({
|
||||
jobId: 'testservice-request-high_mean_response_time',
|
||||
serviceName: 'testService',
|
||||
transactionType: 'request',
|
||||
});
|
||||
});
|
||||
|
||||
it('should match service names with spaces', () => {
|
||||
const mlJob = {
|
||||
job_id: 'test_service-request-high_mean_response_time',
|
||||
groups: ['apm', 'test_service', 'request'],
|
||||
} as AnomalyDetectionJob;
|
||||
const serviceNames = ['Test Service'];
|
||||
const apmMlJobCategory = getApmMlJobCategory(mlJob, serviceNames);
|
||||
|
||||
expect(apmMlJobCategory).toEqual({
|
||||
jobId: 'test_service-request-high_mean_response_time',
|
||||
serviceName: 'Test Service',
|
||||
transactionType: 'request',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,166 +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 { intersection } from 'lodash';
|
||||
import { leftJoin } from '../../../common/utils/left_join';
|
||||
import { Job as AnomalyDetectionJob } from '../../../../ml/server';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
import { IEnvOptions } from './get_service_map';
|
||||
import { Setup } from '../helpers/setup_request';
|
||||
import {
|
||||
APM_ML_JOB_GROUP_NAME,
|
||||
encodeForMlApi,
|
||||
} from '../../../common/ml_job_constants';
|
||||
|
||||
async function getApmAnomalyDetectionJobs(
|
||||
setup: Setup
|
||||
): Promise<AnomalyDetectionJob[]> {
|
||||
const { ml } = setup;
|
||||
|
||||
if (!ml) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const { jobs } = await ml.anomalyDetectors.jobs(APM_ML_JOB_GROUP_NAME);
|
||||
return jobs;
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
type ApmMlJobCategory = NonNullable<ReturnType<typeof getApmMlJobCategory>>;
|
||||
|
||||
export const getApmMlJobCategory = (
|
||||
mlJob: AnomalyDetectionJob,
|
||||
serviceNames: string[]
|
||||
) => {
|
||||
const serviceByGroupNameMap = new Map(
|
||||
serviceNames.map((serviceName) => [
|
||||
encodeForMlApi(serviceName),
|
||||
serviceName,
|
||||
])
|
||||
);
|
||||
if (!mlJob.groups.includes(APM_ML_JOB_GROUP_NAME)) {
|
||||
// ML job missing "apm" group name
|
||||
return;
|
||||
}
|
||||
const apmJobGroups = mlJob.groups.filter(
|
||||
(groupName) => groupName !== APM_ML_JOB_GROUP_NAME
|
||||
);
|
||||
const apmJobServiceNames = apmJobGroups.map(
|
||||
(groupName) => serviceByGroupNameMap.get(groupName) || groupName
|
||||
);
|
||||
const [serviceName] = intersection(apmJobServiceNames, serviceNames);
|
||||
if (!serviceName) {
|
||||
// APM ML job service was not found
|
||||
return;
|
||||
}
|
||||
const serviceGroupName = encodeForMlApi(serviceName);
|
||||
const [transactionType] = apmJobGroups.filter(
|
||||
(groupName) => groupName !== serviceGroupName
|
||||
);
|
||||
if (!transactionType) {
|
||||
// APM ML job transaction type was not found.
|
||||
return;
|
||||
}
|
||||
return { jobId: mlJob.job_id, serviceName, transactionType };
|
||||
};
|
||||
|
||||
export type ServiceAnomalies = PromiseReturnType<typeof getServiceAnomalies>;
|
||||
|
||||
export async function getServiceAnomalies(
|
||||
options: IEnvOptions,
|
||||
serviceNames: string[]
|
||||
) {
|
||||
const { start, end, ml } = options.setup;
|
||||
|
||||
if (!ml || serviceNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const apmMlJobs = await getApmAnomalyDetectionJobs(options.setup);
|
||||
if (apmMlJobs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const apmMlJobCategories = apmMlJobs
|
||||
.map((job) => getApmMlJobCategory(job, serviceNames))
|
||||
.filter(
|
||||
(apmJobCategory) => apmJobCategory !== undefined
|
||||
) as ApmMlJobCategory[];
|
||||
const apmJobIds = apmMlJobs.map((job) => job.job_id);
|
||||
const params = {
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{ term: { result_type: 'record' } },
|
||||
{
|
||||
terms: {
|
||||
job_id: apmJobIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
range: {
|
||||
timestamp: { gte: start, lte: end, format: 'epoch_millis' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
jobs: {
|
||||
terms: { field: 'job_id', size: apmJobIds.length },
|
||||
aggs: {
|
||||
top_score_hits: {
|
||||
top_hits: {
|
||||
sort: [{ record_score: { order: 'desc' as const } }],
|
||||
_source: ['record_score', 'timestamp', 'typical', 'actual'],
|
||||
size: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = (await ml.mlSystem.mlAnomalySearch(params)) as {
|
||||
aggregations: {
|
||||
jobs: {
|
||||
buckets: Array<{
|
||||
key: string;
|
||||
top_score_hits: {
|
||||
hits: {
|
||||
hits: Array<{
|
||||
_source: {
|
||||
record_score: number;
|
||||
timestamp: number;
|
||||
typical: number[];
|
||||
actual: number[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const anomalyScores = response.aggregations.jobs.buckets.map((jobBucket) => {
|
||||
const jobId = jobBucket.key;
|
||||
const bucketSource = jobBucket.top_score_hits.hits.hits?.[0]?._source;
|
||||
return {
|
||||
jobId,
|
||||
anomalyScore: bucketSource.record_score,
|
||||
timestamp: bucketSource.timestamp,
|
||||
typical: bucketSource.typical[0],
|
||||
actual: bucketSource.actual[0],
|
||||
};
|
||||
});
|
||||
return leftJoin(apmMlJobCategories, 'jobId', anomalyScores);
|
||||
}
|
|
@ -13,14 +13,9 @@ import { getServicesProjection } from '../../../common/projections/services';
|
|||
import { mergeProjection } from '../../../common/projections/util/merge_projection';
|
||||
import { PromiseReturnType } from '../../../typings/common';
|
||||
import { Setup, SetupTimeRange } from '../helpers/setup_request';
|
||||
import {
|
||||
transformServiceMapResponses,
|
||||
getAllNodes,
|
||||
getServiceNodes,
|
||||
} from './transform_service_map_responses';
|
||||
import { transformServiceMapResponses } from './transform_service_map_responses';
|
||||
import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids';
|
||||
import { getTraceSampleIds } from './get_trace_sample_ids';
|
||||
import { getServiceAnomalies, ServiceAnomalies } from './get_service_anomalies';
|
||||
|
||||
export interface IEnvOptions {
|
||||
setup: Setup & SetupTimeRange;
|
||||
|
@ -132,7 +127,6 @@ async function getServicesData(options: IEnvOptions) {
|
|||
);
|
||||
}
|
||||
|
||||
export { ServiceAnomalies };
|
||||
export type ConnectionsResponse = PromiseReturnType<typeof getConnectionData>;
|
||||
export type ServicesResponse = PromiseReturnType<typeof getServicesData>;
|
||||
export type ServiceMapAPIResponse = PromiseReturnType<typeof getServiceMap>;
|
||||
|
@ -143,19 +137,8 @@ export async function getServiceMap(options: IEnvOptions) {
|
|||
getServicesData(options),
|
||||
]);
|
||||
|
||||
// Derive all related service names from connection and service data
|
||||
const allNodes = getAllNodes(servicesData, connectionData.connections);
|
||||
const serviceNodes = getServiceNodes(allNodes);
|
||||
const serviceNames = serviceNodes.map(
|
||||
(serviceData) => serviceData[SERVICE_NAME]
|
||||
);
|
||||
|
||||
// Get related service anomalies
|
||||
const serviceAnomalies = await getServiceAnomalies(options, serviceNames);
|
||||
|
||||
return transformServiceMapResponses({
|
||||
...connectionData,
|
||||
anomalies: serviceAnomalies,
|
||||
services: servicesData,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,76 +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 { ServiceAnomalies } from './get_service_map';
|
||||
import { addAnomaliesDataToNodes } from './ml_helpers';
|
||||
|
||||
describe('addAnomaliesDataToNodes', () => {
|
||||
it('adds anomalies to nodes', () => {
|
||||
const nodes = [
|
||||
{
|
||||
'service.name': 'opbeans-ruby',
|
||||
'agent.name': 'ruby',
|
||||
'service.environment': null,
|
||||
},
|
||||
{
|
||||
'service.name': 'opbeans-java',
|
||||
'agent.name': 'java',
|
||||
'service.environment': null,
|
||||
},
|
||||
];
|
||||
|
||||
const serviceAnomalies: ServiceAnomalies = [
|
||||
{
|
||||
jobId: 'opbeans-ruby-request-high_mean_response_time',
|
||||
serviceName: 'opbeans-ruby',
|
||||
transactionType: 'request',
|
||||
anomalyScore: 50,
|
||||
timestamp: 1591351200000,
|
||||
actual: 2000,
|
||||
typical: 1000,
|
||||
},
|
||||
{
|
||||
jobId: 'opbeans-java-request-high_mean_response_time',
|
||||
serviceName: 'opbeans-java',
|
||||
transactionType: 'request',
|
||||
anomalyScore: 100,
|
||||
timestamp: 1591351200000,
|
||||
actual: 9000,
|
||||
typical: 3000,
|
||||
},
|
||||
];
|
||||
|
||||
const result = [
|
||||
{
|
||||
'service.name': 'opbeans-ruby',
|
||||
'agent.name': 'ruby',
|
||||
'service.environment': null,
|
||||
anomaly_score: 50,
|
||||
anomaly_severity: 'major',
|
||||
actual_value: 2000,
|
||||
typical_value: 1000,
|
||||
ml_job_id: 'opbeans-ruby-request-high_mean_response_time',
|
||||
},
|
||||
{
|
||||
'service.name': 'opbeans-java',
|
||||
'agent.name': 'java',
|
||||
'service.environment': null,
|
||||
anomaly_score: 100,
|
||||
anomaly_severity: 'critical',
|
||||
actual_value: 9000,
|
||||
typical_value: 3000,
|
||||
ml_job_id: 'opbeans-java-request-high_mean_response_time',
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
addAnomaliesDataToNodes(
|
||||
nodes,
|
||||
(serviceAnomalies as unknown) as ServiceAnomalies
|
||||
)
|
||||
).toEqual(result);
|
||||
});
|
||||
});
|
|
@ -1,67 +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 { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames';
|
||||
import { getSeverity } from '../../../common/ml_job_constants';
|
||||
import { ConnectionNode, ServiceNode } from '../../../common/service_map';
|
||||
import { ServiceAnomalies } from './get_service_map';
|
||||
|
||||
export function addAnomaliesDataToNodes(
|
||||
nodes: ConnectionNode[],
|
||||
serviceAnomalies: ServiceAnomalies
|
||||
) {
|
||||
const anomaliesMap = serviceAnomalies.reduce(
|
||||
(acc, anomalyJob) => {
|
||||
const serviceAnomaly: typeof acc[string] | undefined =
|
||||
acc[anomalyJob.serviceName];
|
||||
const hasAnomalyJob = serviceAnomaly !== undefined;
|
||||
const hasAnomalyScore = serviceAnomaly?.anomaly_score !== undefined;
|
||||
const hasNewAnomalyScore = anomalyJob.anomalyScore !== undefined;
|
||||
const hasNewMaxAnomalyScore =
|
||||
hasNewAnomalyScore &&
|
||||
(!hasAnomalyScore ||
|
||||
(anomalyJob?.anomalyScore ?? 0) >
|
||||
(serviceAnomaly?.anomaly_score ?? 0));
|
||||
|
||||
if (!hasAnomalyJob || hasNewMaxAnomalyScore) {
|
||||
acc[anomalyJob.serviceName] = {
|
||||
anomaly_score: anomalyJob.anomalyScore,
|
||||
actual_value: anomalyJob.actual,
|
||||
typical_value: anomalyJob.typical,
|
||||
ml_job_id: anomalyJob.jobId,
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as {
|
||||
[serviceName: string]: {
|
||||
anomaly_score?: number;
|
||||
actual_value?: number;
|
||||
typical_value?: number;
|
||||
ml_job_id: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const servicesDataWithAnomalies: ServiceNode[] = nodes.map((service) => {
|
||||
const serviceAnomaly = anomaliesMap[service[SERVICE_NAME]];
|
||||
if (serviceAnomaly) {
|
||||
const anomalyScore = serviceAnomaly.anomaly_score;
|
||||
return {
|
||||
...service,
|
||||
anomaly_score: anomalyScore,
|
||||
anomaly_severity: getSeverity(anomalyScore),
|
||||
actual_value: serviceAnomaly.actual_value,
|
||||
typical_value: serviceAnomaly.typical_value,
|
||||
ml_job_id: serviceAnomaly.ml_job_id,
|
||||
};
|
||||
}
|
||||
return service;
|
||||
});
|
||||
|
||||
return servicesDataWithAnomalies;
|
||||
}
|
|
@ -12,7 +12,6 @@ import {
|
|||
SPAN_SUBTYPE,
|
||||
SPAN_TYPE,
|
||||
} from '../../../common/elasticsearch_fieldnames';
|
||||
import { ServiceAnomalies } from './get_service_map';
|
||||
import {
|
||||
transformServiceMapResponses,
|
||||
ServiceMapResponse,
|
||||
|
@ -36,12 +35,9 @@ const javaService = {
|
|||
[AGENT_NAME]: 'java',
|
||||
};
|
||||
|
||||
const serviceAnomalies: ServiceAnomalies = [];
|
||||
|
||||
describe('transformServiceMapResponses', () => {
|
||||
it('maps external destinations to internal services', () => {
|
||||
const response: ServiceMapResponse = {
|
||||
anomalies: serviceAnomalies,
|
||||
services: [nodejsService, javaService],
|
||||
discoveredServices: [
|
||||
{
|
||||
|
@ -73,7 +69,6 @@ describe('transformServiceMapResponses', () => {
|
|||
|
||||
it('collapses external destinations based on span.destination.resource.name', () => {
|
||||
const response: ServiceMapResponse = {
|
||||
anomalies: serviceAnomalies,
|
||||
services: [nodejsService, javaService],
|
||||
discoveredServices: [
|
||||
{
|
||||
|
@ -109,7 +104,6 @@ describe('transformServiceMapResponses', () => {
|
|||
|
||||
it('picks the first span.type/subtype in an alphabetically sorted list', () => {
|
||||
const response: ServiceMapResponse = {
|
||||
anomalies: serviceAnomalies,
|
||||
services: [javaService],
|
||||
discoveredServices: [],
|
||||
connections: [
|
||||
|
@ -148,7 +142,6 @@ describe('transformServiceMapResponses', () => {
|
|||
|
||||
it('processes connections without a matching "service" aggregation', () => {
|
||||
const response: ServiceMapResponse = {
|
||||
anomalies: serviceAnomalies,
|
||||
services: [javaService],
|
||||
discoveredServices: [],
|
||||
connections: [
|
||||
|
|
|
@ -17,12 +17,7 @@ import {
|
|||
ServiceConnectionNode,
|
||||
ExternalConnectionNode,
|
||||
} from '../../../common/service_map';
|
||||
import {
|
||||
ConnectionsResponse,
|
||||
ServicesResponse,
|
||||
ServiceAnomalies,
|
||||
} from './get_service_map';
|
||||
import { addAnomaliesDataToNodes } from './ml_helpers';
|
||||
import { ConnectionsResponse, ServicesResponse } from './get_service_map';
|
||||
|
||||
function getConnectionNodeId(node: ConnectionNode): string {
|
||||
if ('span.destination.service.resource' in node) {
|
||||
|
@ -67,12 +62,11 @@ export function getServiceNodes(allNodes: ConnectionNode[]) {
|
|||
}
|
||||
|
||||
export type ServiceMapResponse = ConnectionsResponse & {
|
||||
anomalies: ServiceAnomalies;
|
||||
services: ServicesResponse;
|
||||
};
|
||||
|
||||
export function transformServiceMapResponses(response: ServiceMapResponse) {
|
||||
const { anomalies, discoveredServices, services, connections } = response;
|
||||
const { discoveredServices, services, connections } = response;
|
||||
|
||||
const allNodes = getAllNodes(services, connections);
|
||||
const serviceNodes = getServiceNodes(allNodes);
|
||||
|
@ -214,18 +208,10 @@ export function transformServiceMapResponses(response: ServiceMapResponse) {
|
|||
return prev.concat(connection);
|
||||
}, []);
|
||||
|
||||
// Add anomlies data
|
||||
const dedupedNodesWithAnomliesData = addAnomaliesDataToNodes(
|
||||
dedupedNodes,
|
||||
anomalies
|
||||
);
|
||||
|
||||
// Put everything together in elements, with everything in the "data" property
|
||||
const elements = [...dedupedConnections, ...dedupedNodesWithAnomliesData].map(
|
||||
(element) => ({
|
||||
data: element,
|
||||
})
|
||||
);
|
||||
const elements = [...dedupedConnections, ...dedupedNodes].map((element) => ({
|
||||
data: element,
|
||||
}));
|
||||
|
||||
return { elements };
|
||||
}
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`anomalyAggsFetcher when ES returns valid response should call client with correct query 1`] = `
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"ml_avg_response_times": Object {
|
||||
"aggs": Object {
|
||||
"anomaly_score": Object {
|
||||
"max": Object {
|
||||
"field": "anomaly_score",
|
||||
},
|
||||
},
|
||||
"lower": Object {
|
||||
"min": Object {
|
||||
"field": "model_lower",
|
||||
},
|
||||
},
|
||||
"upper": Object {
|
||||
"max": Object {
|
||||
"field": "model_upper",
|
||||
},
|
||||
},
|
||||
},
|
||||
"date_histogram": Object {
|
||||
"extended_bounds": Object {
|
||||
"max": 200000,
|
||||
"min": 90000,
|
||||
},
|
||||
"field": "timestamp",
|
||||
"fixed_interval": "myInterval",
|
||||
"min_doc_count": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"job_id": "myservicename-mytransactiontype-high_mean_response_time",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"exists": Object {
|
||||
"field": "bucket_span",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"timestamp": Object {
|
||||
"format": "epoch_millis",
|
||||
"gte": 90000,
|
||||
"lte": 200000,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
|
@ -1,38 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`getAnomalySeries should match snapshot 1`] = `
|
||||
Object {
|
||||
"anomalyBoundaries": Array [
|
||||
Object {
|
||||
"x": 5000,
|
||||
"y": 200,
|
||||
"y0": 20,
|
||||
},
|
||||
Object {
|
||||
"x": 15000,
|
||||
"y": 100,
|
||||
"y0": 20,
|
||||
},
|
||||
Object {
|
||||
"x": 25000,
|
||||
"y": 50,
|
||||
"y0": 10,
|
||||
},
|
||||
Object {
|
||||
"x": 30000,
|
||||
"y": 50,
|
||||
"y0": 10,
|
||||
},
|
||||
],
|
||||
"anomalyScore": Array [
|
||||
Object {
|
||||
"x": 25000,
|
||||
"x0": 15000,
|
||||
},
|
||||
Object {
|
||||
"x": 35000,
|
||||
"x0": 25000,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
|
@ -1,33 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`anomalySeriesTransform should match snapshot 1`] = `
|
||||
Object {
|
||||
"anomalyBoundaries": Array [
|
||||
Object {
|
||||
"x": 10000,
|
||||
"y": 200,
|
||||
"y0": 20,
|
||||
},
|
||||
Object {
|
||||
"x": 15000,
|
||||
"y": 100,
|
||||
"y0": 20,
|
||||
},
|
||||
Object {
|
||||
"x": 25000,
|
||||
"y": 50,
|
||||
"y0": 10,
|
||||
},
|
||||
],
|
||||
"anomalyScore": Array [
|
||||
Object {
|
||||
"x": 25000,
|
||||
"x0": 15000,
|
||||
},
|
||||
Object {
|
||||
"x": 25000,
|
||||
"x0": 25000,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
|
@ -1,76 +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 { anomalySeriesFetcher, ESResponse } from './fetcher';
|
||||
|
||||
describe('anomalyAggsFetcher', () => {
|
||||
describe('when ES returns valid response', () => {
|
||||
let response: ESResponse | undefined;
|
||||
let clientSpy: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
clientSpy = jest.fn().mockReturnValue('ES Response');
|
||||
response = await anomalySeriesFetcher({
|
||||
serviceName: 'myServiceName',
|
||||
transactionType: 'myTransactionType',
|
||||
intervalString: 'myInterval',
|
||||
mlBucketSize: 10,
|
||||
setup: {
|
||||
ml: {
|
||||
mlSystem: {
|
||||
mlAnomalySearch: clientSpy,
|
||||
},
|
||||
} as any,
|
||||
start: 100000,
|
||||
end: 200000,
|
||||
} as any,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call client with correct query', () => {
|
||||
expect(clientSpy.mock.calls).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should return correct response', () => {
|
||||
expect(response).toBe('ES Response');
|
||||
});
|
||||
});
|
||||
|
||||
it('should swallow HTTP errors', () => {
|
||||
const httpError = new Error('anomaly lookup failed') as any;
|
||||
httpError.statusCode = 418;
|
||||
const failedRequestSpy = jest.fn(() => Promise.reject(httpError));
|
||||
|
||||
return expect(
|
||||
anomalySeriesFetcher({
|
||||
setup: {
|
||||
ml: {
|
||||
mlSystem: {
|
||||
mlAnomalySearch: failedRequestSpy,
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
} as any)
|
||||
).resolves.toEqual(undefined);
|
||||
});
|
||||
|
||||
it('should throw other errors', () => {
|
||||
const otherError = new Error('anomaly lookup ASPLODED') as any;
|
||||
const failedRequestSpy = jest.fn(() => Promise.reject(otherError));
|
||||
|
||||
return expect(
|
||||
anomalySeriesFetcher({
|
||||
setup: {
|
||||
ml: {
|
||||
mlSystem: {
|
||||
mlAnomalySearch: failedRequestSpy,
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(otherError);
|
||||
});
|
||||
});
|
|
@ -1,90 +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 { getMlJobId } from '../../../../../common/ml_job_constants';
|
||||
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,
|
||||
}: {
|
||||
serviceName: string;
|
||||
transactionType: string;
|
||||
intervalString: string;
|
||||
mlBucketSize: number;
|
||||
setup: Setup & SetupTimeRange;
|
||||
}) {
|
||||
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 jobId = getMlJobId(serviceName, transactionType);
|
||||
|
||||
const params = {
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{ term: { job_id: jobId } },
|
||||
{ exists: { field: 'bucket_span' } },
|
||||
{
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
|
@ -1,65 +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 { getMlJobId } from '../../../../../common/ml_job_constants';
|
||||
import { Setup, SetupTimeRange } from '../../../helpers/setup_request';
|
||||
|
||||
interface IOptions {
|
||||
serviceName: string;
|
||||
transactionType: string;
|
||||
setup: Setup & SetupTimeRange;
|
||||
}
|
||||
|
||||
interface ESResponse {
|
||||
bucket_span: number;
|
||||
}
|
||||
|
||||
export async function getMlBucketSize({
|
||||
serviceName,
|
||||
transactionType,
|
||||
setup,
|
||||
}: IOptions): Promise<number> {
|
||||
const { ml, start, end } = setup;
|
||||
if (!ml) {
|
||||
return 0;
|
||||
}
|
||||
const jobId = getMlJobId(serviceName, transactionType);
|
||||
|
||||
const params = {
|
||||
body: {
|
||||
_source: 'bucket_span',
|
||||
size: 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);
|
||||
return resp.hits.hits[0]?._source.bucket_span || 0;
|
||||
} catch (err) {
|
||||
const isHttpError = 'statusCode' in err;
|
||||
if (isHttpError) {
|
||||
return 0;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
|
@ -1,83 +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 { getAnomalySeries } from '.';
|
||||
import { mlAnomalyResponse } from './mock_responses/ml_anomaly_response';
|
||||
import { mlBucketSpanResponse } from './mock_responses/ml_bucket_span_response';
|
||||
import { PromiseReturnType } from '../../../../../../observability/typings/common';
|
||||
import { APMConfig } from '../../../..';
|
||||
|
||||
describe('getAnomalySeries', () => {
|
||||
let avgAnomalies: PromiseReturnType<typeof getAnomalySeries>;
|
||||
beforeEach(async () => {
|
||||
const clientSpy = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mlBucketSpanResponse)
|
||||
.mockResolvedValueOnce(mlAnomalyResponse);
|
||||
|
||||
avgAnomalies = await getAnomalySeries({
|
||||
serviceName: 'myServiceName',
|
||||
transactionType: 'myTransactionType',
|
||||
transactionName: undefined,
|
||||
timeSeriesDates: [100, 100000],
|
||||
setup: {
|
||||
start: 0,
|
||||
end: 500000,
|
||||
client: { search: () => {} } as any,
|
||||
internalClient: { search: () => {} } as any,
|
||||
config: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => 'myIndex',
|
||||
}
|
||||
) as APMConfig,
|
||||
uiFiltersES: [],
|
||||
indices: {
|
||||
'apm_oss.sourcemapIndices': 'myIndex',
|
||||
'apm_oss.errorIndices': 'myIndex',
|
||||
'apm_oss.onboardingIndices': 'myIndex',
|
||||
'apm_oss.spanIndices': 'myIndex',
|
||||
'apm_oss.transactionIndices': 'myIndex',
|
||||
'apm_oss.metricsIndices': 'myIndex',
|
||||
apmAgentConfigurationIndex: 'myIndex',
|
||||
apmCustomLinkIndex: 'myIndex',
|
||||
},
|
||||
dynamicIndexPattern: null as any,
|
||||
ml: {
|
||||
mlSystem: {
|
||||
mlAnomalySearch: clientSpy,
|
||||
mlCapabilities: async () => ({ isPlatinumOrTrialLicense: true }),
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove buckets lower than threshold and outside date range from anomalyScore', () => {
|
||||
expect(avgAnomalies!.anomalyScore).toEqual([
|
||||
{ x0: 15000, x: 25000 },
|
||||
{ x0: 25000, x: 35000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove buckets outside date range from anomalyBoundaries', () => {
|
||||
expect(
|
||||
avgAnomalies!.anomalyBoundaries!.filter(
|
||||
(bucket) => bucket.x < 100 || bucket.x > 100000
|
||||
).length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('should remove buckets with null from anomalyBoundaries', () => {
|
||||
expect(
|
||||
avgAnomalies!.anomalyBoundaries!.filter((p) => p.y === null).length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('should match snapshot', async () => {
|
||||
expect(avgAnomalies).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -4,15 +4,17 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getBucketSize } from '../../../helpers/get_bucket_size';
|
||||
import {
|
||||
Setup,
|
||||
SetupTimeRange,
|
||||
SetupUIFilters,
|
||||
} from '../../../helpers/setup_request';
|
||||
import { anomalySeriesFetcher } from './fetcher';
|
||||
import { getMlBucketSize } from './get_ml_bucket_size';
|
||||
import { anomalySeriesTransform } from './transform';
|
||||
import { Coordinate, RectCoordinate } from '../../../../../typings/timeseries';
|
||||
|
||||
interface AnomalyTimeseries {
|
||||
anomalyBoundaries: Coordinate[];
|
||||
anomalyScore: RectCoordinate[];
|
||||
}
|
||||
|
||||
export async function getAnomalySeries({
|
||||
serviceName,
|
||||
|
@ -26,7 +28,7 @@ export async function getAnomalySeries({
|
|||
transactionName: string | undefined;
|
||||
timeSeriesDates: number[];
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
}) {
|
||||
}): Promise<void | AnomalyTimeseries> {
|
||||
// don't fetch anomalies for transaction details page
|
||||
if (transactionName) {
|
||||
return;
|
||||
|
@ -53,29 +55,6 @@ export async function getAnomalySeries({
|
|||
return;
|
||||
}
|
||||
|
||||
const mlBucketSize = await getMlBucketSize({
|
||||
serviceName,
|
||||
transactionType,
|
||||
setup,
|
||||
});
|
||||
|
||||
const { start, end } = setup;
|
||||
const { intervalString, bucketSize } = getBucketSize(start, end, 'auto');
|
||||
|
||||
const esResponse = await anomalySeriesFetcher({
|
||||
serviceName,
|
||||
transactionType,
|
||||
intervalString,
|
||||
mlBucketSize,
|
||||
setup,
|
||||
});
|
||||
|
||||
return esResponse
|
||||
? anomalySeriesTransform(
|
||||
esResponse,
|
||||
mlBucketSize,
|
||||
bucketSize,
|
||||
timeSeriesDates
|
||||
)
|
||||
: undefined;
|
||||
// TODO [APM ML] return a series of anomaly scores, upper & lower bounds for the given timeSeriesDates
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -1,127 +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 { ESResponse } from '../fetcher';
|
||||
|
||||
export const mlAnomalyResponse: ESResponse = ({
|
||||
took: 3,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 5,
|
||||
successful: 5,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: 10,
|
||||
max_score: 0,
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
ml_avg_response_times: {
|
||||
buckets: [
|
||||
{
|
||||
key_as_string: '2018-07-02T09:16:40.000Z',
|
||||
key: 0,
|
||||
doc_count: 0,
|
||||
anomaly_score: {
|
||||
value: null,
|
||||
},
|
||||
upper: {
|
||||
value: 200,
|
||||
},
|
||||
lower: {
|
||||
value: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
key_as_string: '2018-07-02T09:25:00.000Z',
|
||||
key: 5000,
|
||||
doc_count: 4,
|
||||
anomaly_score: {
|
||||
value: null,
|
||||
},
|
||||
upper: {
|
||||
value: null,
|
||||
},
|
||||
lower: {
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
key_as_string: '2018-07-02T09:33:20.000Z',
|
||||
key: 10000,
|
||||
doc_count: 0,
|
||||
anomaly_score: {
|
||||
value: null,
|
||||
},
|
||||
upper: {
|
||||
value: null,
|
||||
},
|
||||
lower: {
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
key_as_string: '2018-07-02T09:41:40.000Z',
|
||||
key: 15000,
|
||||
doc_count: 2,
|
||||
anomaly_score: {
|
||||
value: 90,
|
||||
},
|
||||
upper: {
|
||||
value: 100,
|
||||
},
|
||||
lower: {
|
||||
value: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
key_as_string: '2018-07-02T09:50:00.000Z',
|
||||
key: 20000,
|
||||
doc_count: 0,
|
||||
anomaly_score: {
|
||||
value: null,
|
||||
},
|
||||
upper: {
|
||||
value: null,
|
||||
},
|
||||
lower: {
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
key_as_string: '2018-07-02T09:58:20.000Z',
|
||||
key: 25000,
|
||||
doc_count: 2,
|
||||
anomaly_score: {
|
||||
value: 100,
|
||||
},
|
||||
upper: {
|
||||
value: 50,
|
||||
},
|
||||
lower: {
|
||||
value: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
key_as_string: '2018-07-02T10:15:00.000Z',
|
||||
key: 30000,
|
||||
doc_count: 2,
|
||||
anomaly_score: {
|
||||
value: 0,
|
||||
},
|
||||
upper: {
|
||||
value: null,
|
||||
},
|
||||
lower: {
|
||||
value: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown) as ESResponse;
|
|
@ -1,31 +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.
|
||||
*/
|
||||
|
||||
export const mlBucketSpanResponse = {
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: 192,
|
||||
max_score: 1.0,
|
||||
hits: [
|
||||
{
|
||||
_index: '.ml-anomalies-shared',
|
||||
_id:
|
||||
'opbeans-go-request-high_mean_response_time_model_plot_1542636000000_900_0_29791_0',
|
||||
_score: 1.0,
|
||||
_source: {
|
||||
bucket_span: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
|
@ -1,303 +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 { ESResponse } from './fetcher';
|
||||
import { mlAnomalyResponse } from './mock_responses/ml_anomaly_response';
|
||||
import { anomalySeriesTransform, replaceFirstAndLastBucket } from './transform';
|
||||
|
||||
describe('anomalySeriesTransform', () => {
|
||||
it('should match snapshot', () => {
|
||||
const getMlBucketSize = 10;
|
||||
const bucketSize = 5;
|
||||
const timeSeriesDates = [10000, 25000];
|
||||
const anomalySeries = anomalySeriesTransform(
|
||||
mlAnomalyResponse,
|
||||
getMlBucketSize,
|
||||
bucketSize,
|
||||
timeSeriesDates
|
||||
);
|
||||
expect(anomalySeries).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('anomalyScoreSeries', () => {
|
||||
it('should only returns bucket within range and above threshold', () => {
|
||||
const esResponse = getESResponse([
|
||||
{
|
||||
key: 0,
|
||||
anomaly_score: { value: 90 },
|
||||
},
|
||||
{
|
||||
key: 5000,
|
||||
anomaly_score: { value: 0 },
|
||||
},
|
||||
{
|
||||
key: 10000,
|
||||
anomaly_score: { value: 90 },
|
||||
},
|
||||
{
|
||||
key: 15000,
|
||||
anomaly_score: { value: 0 },
|
||||
},
|
||||
{
|
||||
key: 20000,
|
||||
anomaly_score: { value: 90 },
|
||||
},
|
||||
]);
|
||||
|
||||
const getMlBucketSize = 5;
|
||||
const bucketSize = 5;
|
||||
const timeSeriesDates = [5000, 15000];
|
||||
const anomalySeries = anomalySeriesTransform(
|
||||
esResponse,
|
||||
getMlBucketSize,
|
||||
bucketSize,
|
||||
timeSeriesDates
|
||||
);
|
||||
|
||||
const buckets = anomalySeries!.anomalyScore;
|
||||
expect(buckets).toEqual([{ x0: 10000, x: 15000 }]);
|
||||
});
|
||||
|
||||
it('should decrease the x-value to avoid going beyond last date', () => {
|
||||
const esResponse = getESResponse([
|
||||
{
|
||||
key: 0,
|
||||
anomaly_score: { value: 0 },
|
||||
},
|
||||
{
|
||||
key: 5000,
|
||||
anomaly_score: { value: 90 },
|
||||
},
|
||||
]);
|
||||
|
||||
const getMlBucketSize = 10;
|
||||
const bucketSize = 5;
|
||||
const timeSeriesDates = [0, 10000];
|
||||
const anomalySeries = anomalySeriesTransform(
|
||||
esResponse,
|
||||
getMlBucketSize,
|
||||
bucketSize,
|
||||
timeSeriesDates
|
||||
);
|
||||
|
||||
const buckets = anomalySeries!.anomalyScore;
|
||||
expect(buckets).toEqual([{ x0: 5000, x: 10000 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anomalyBoundariesSeries', () => {
|
||||
it('should trim buckets to time range', () => {
|
||||
const esResponse = getESResponse([
|
||||
{
|
||||
key: 0,
|
||||
upper: { value: 15 },
|
||||
lower: { value: 10 },
|
||||
},
|
||||
{
|
||||
key: 5000,
|
||||
upper: { value: 25 },
|
||||
lower: { value: 20 },
|
||||
},
|
||||
{
|
||||
key: 10000,
|
||||
upper: { value: 35 },
|
||||
lower: { value: 30 },
|
||||
},
|
||||
{
|
||||
key: 15000,
|
||||
upper: { value: 45 },
|
||||
lower: { value: 40 },
|
||||
},
|
||||
]);
|
||||
|
||||
const mlBucketSize = 10;
|
||||
const bucketSize = 5;
|
||||
const timeSeriesDates = [5000, 10000];
|
||||
const anomalySeries = anomalySeriesTransform(
|
||||
esResponse,
|
||||
mlBucketSize,
|
||||
bucketSize,
|
||||
timeSeriesDates
|
||||
);
|
||||
|
||||
const buckets = anomalySeries!.anomalyBoundaries;
|
||||
expect(buckets).toEqual([
|
||||
{ x: 5000, y: 25, y0: 20 },
|
||||
{ x: 10000, y: 35, y0: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should replace first bucket in range', () => {
|
||||
const esResponse = getESResponse([
|
||||
{
|
||||
key: 0,
|
||||
anomaly_score: { value: 0 },
|
||||
upper: { value: 15 },
|
||||
lower: { value: 10 },
|
||||
},
|
||||
{
|
||||
key: 5000,
|
||||
anomaly_score: { value: 0 },
|
||||
upper: { value: null },
|
||||
lower: { value: null },
|
||||
},
|
||||
{
|
||||
key: 10000,
|
||||
anomaly_score: { value: 0 },
|
||||
upper: { value: 25 },
|
||||
lower: { value: 20 },
|
||||
},
|
||||
]);
|
||||
|
||||
const getMlBucketSize = 10;
|
||||
const bucketSize = 5;
|
||||
const timeSeriesDates = [5000, 10000];
|
||||
const anomalySeries = anomalySeriesTransform(
|
||||
esResponse,
|
||||
getMlBucketSize,
|
||||
bucketSize,
|
||||
timeSeriesDates
|
||||
);
|
||||
|
||||
const buckets = anomalySeries!.anomalyBoundaries;
|
||||
expect(buckets).toEqual([
|
||||
{ x: 5000, y: 15, y0: 10 },
|
||||
{ x: 10000, y: 25, y0: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should replace last bucket in range', () => {
|
||||
const esResponse = getESResponse([
|
||||
{
|
||||
key: 0,
|
||||
anomaly_score: { value: 0 },
|
||||
upper: { value: 15 },
|
||||
lower: { value: 10 },
|
||||
},
|
||||
{
|
||||
key: 5000,
|
||||
anomaly_score: { value: 0 },
|
||||
upper: { value: null },
|
||||
lower: { value: null },
|
||||
},
|
||||
{
|
||||
key: 10000,
|
||||
anomaly_score: { value: 0 },
|
||||
upper: { value: null },
|
||||
lower: { value: null },
|
||||
},
|
||||
]);
|
||||
|
||||
const getMlBucketSize = 10;
|
||||
const bucketSize = 5;
|
||||
const timeSeriesDates = [5000, 10000];
|
||||
const anomalySeries = anomalySeriesTransform(
|
||||
esResponse,
|
||||
getMlBucketSize,
|
||||
bucketSize,
|
||||
timeSeriesDates
|
||||
);
|
||||
|
||||
const buckets = anomalySeries!.anomalyBoundaries;
|
||||
expect(buckets).toEqual([
|
||||
{ x: 5000, y: 15, y0: 10 },
|
||||
{ x: 10000, y: 15, y0: 10 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceFirstAndLastBucket', () => {
|
||||
it('should extend the first bucket', () => {
|
||||
const buckets = [
|
||||
{
|
||||
x: 0,
|
||||
lower: 10,
|
||||
upper: 20,
|
||||
},
|
||||
{
|
||||
x: 5,
|
||||
lower: null,
|
||||
upper: null,
|
||||
},
|
||||
{
|
||||
x: 10,
|
||||
lower: null,
|
||||
upper: null,
|
||||
},
|
||||
{
|
||||
x: 15,
|
||||
lower: 30,
|
||||
upper: 40,
|
||||
},
|
||||
];
|
||||
|
||||
const timeSeriesDates = [10, 15];
|
||||
expect(replaceFirstAndLastBucket(buckets as any, timeSeriesDates)).toEqual([
|
||||
{ x: 10, lower: 10, upper: 20 },
|
||||
{ x: 15, lower: 30, upper: 40 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extend the last bucket', () => {
|
||||
const buckets = [
|
||||
{
|
||||
x: 10,
|
||||
lower: 30,
|
||||
upper: 40,
|
||||
},
|
||||
{
|
||||
x: 15,
|
||||
lower: null,
|
||||
upper: null,
|
||||
},
|
||||
{
|
||||
x: 20,
|
||||
lower: null,
|
||||
upper: null,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const timeSeriesDates = [10, 15, 20];
|
||||
expect(replaceFirstAndLastBucket(buckets, timeSeriesDates)).toEqual([
|
||||
{ x: 10, lower: 30, upper: 40 },
|
||||
{ x: 15, lower: null, upper: null },
|
||||
{ x: 20, lower: 30, upper: 40 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function getESResponse(buckets: any): ESResponse {
|
||||
return ({
|
||||
took: 3,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 5,
|
||||
successful: 5,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: 10,
|
||||
max_score: 0,
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
ml_avg_response_times: {
|
||||
buckets: buckets.map((bucket: any) => {
|
||||
return {
|
||||
...bucket,
|
||||
lower: { value: bucket?.lower?.value || null },
|
||||
upper: { value: bucket?.upper?.value || null },
|
||||
anomaly_score: {
|
||||
value: bucket?.anomaly_score?.value || null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown) as ESResponse;
|
||||
}
|
|
@ -1,126 +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[]
|
||||
) {
|
||||
const buckets =
|
||||
response.aggregations?.ml_avg_response_times.buckets.map(getBucket) || [];
|
||||
|
||||
const bucketSizeInMillis = Math.max(bucketSize, mlBucketSize) * 1000;
|
||||
|
||||
return {
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
|
@ -4296,21 +4296,6 @@
|
|||
"xpack.apm.serviceDetails.alertsMenu.errorRate": "エラー率",
|
||||
"xpack.apm.serviceDetails.alertsMenu.transactionDuration": "トランザクション期間",
|
||||
"xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts": "アクティブアラートを表示",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription": "現在 {serviceName} ({transactionType}) の実行中のジョブがあります。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText": "既存のジョブを表示",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle": "ジョブが既に存在します",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText": "トランザクション時間のグラフ",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel": "ジョブを作成",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle": "異常検知を有効にする",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText": "現在 {serviceName} ({transactionType}) の分析を実行中です。応答時間グラフに結果が追加されるまで少し時間がかかる場合があります。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText": "ジョブを表示",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle": "ジョブが作成されました",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText": "現在のライセンスでは機械学習ジョブの作成が許可されていないか、ジョブが既に存在する可能性があります。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle": "ジョブの作成に失敗",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription": "ジョブはそれぞれのサービス + トランザクションタイプの組み合わせに対して作成できます。ジョブの作成後、{mlJobsPageLink} で管理と詳細の確認ができます。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText": "機械学習ジョブの管理ページ",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.noteText": "注:ジョブが結果の計算を開始するまでに少し時間がかかる場合があります。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.selectTransactionTypeLabel": "このジョブのトランザクションタイプを選択してください",
|
||||
"xpack.apm.serviceDetails.enableErrorReportsPanel.actionsDescription": "レポートはメールで送信するか Slack チャンネルに投稿できます。各レポートにはオカランス別のトップ 10 のエラーが含まれます。",
|
||||
"xpack.apm.serviceDetails.enableErrorReportsPanel.actionsTitle": "アクション",
|
||||
"xpack.apm.serviceDetails.enableErrorReportsPanel.conditionTitle": "コンディション",
|
||||
|
@ -4346,8 +4331,6 @@
|
|||
"xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationText": "ユーザーにウォッチ作成のパーミッションがあることを確認してください。",
|
||||
"xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationTitle": "ウォッチの作成に失敗",
|
||||
"xpack.apm.serviceDetails.errorsTabLabel": "エラー",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonLabel": "ML 異常検知を有効にする",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonTooltip": "このサービスの機械学習ジョブをセットアップします",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.enableWatcherErrorReportsButtonLabel": "ウォッチエラーレポートを有効にする",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.integrationsButtonLabel": "統合",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.viewWatchesButtonLabel": "既存のウォッチを表示",
|
||||
|
@ -4357,9 +4340,6 @@
|
|||
"xpack.apm.serviceDetails.metricsTabLabel": "メトリック",
|
||||
"xpack.apm.serviceDetails.nodesTabLabel": "JVM",
|
||||
"xpack.apm.serviceDetails.transactionsTabLabel": "トランザクション",
|
||||
"xpack.apm.serviceMap.anomalyDetectionPopoverLink": "異常を表示",
|
||||
"xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric": "スコア(最大)",
|
||||
"xpack.apm.serviceMap.anomalyDetectionPopoverTitle": "異常検知",
|
||||
"xpack.apm.serviceMap.avgCpuUsagePopoverMetric": "CPU使用状況 (平均)",
|
||||
"xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric": "1分あたりのエラー(平均)",
|
||||
"xpack.apm.serviceMap.avgMemoryUsagePopoverMetric": "メモリー使用状況(平均)",
|
||||
|
|
|
@ -4299,21 +4299,6 @@
|
|||
"xpack.apm.serviceDetails.alertsMenu.errorRate": "错误率",
|
||||
"xpack.apm.serviceDetails.alertsMenu.transactionDuration": "事务持续时间",
|
||||
"xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts": "查看活动的告警",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription": "当前有 {serviceName}({transactionType})的作业正在运行。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText": "查看现有作业",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle": "作业已存在",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText": "事务持续时间图表",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel": "创建作业",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle": "启用异常检测",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText": "现在正在运行对 {serviceName}({transactionType})的分析。可能要花费点时间,才会将结果添加响应时间图表。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText": "查看作业",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle": "作业已成功创建",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText": "您当前的许可可能不允许创建 Machine Learning 作业,或者此作业可能已存在。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle": "作业创建失败",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription": "可以创建每个服务 + 事务类型组合的作业。创建作业后,可以在 {mlJobsPageLink}中管理作业以及查看更多详细信息。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText": "Machine Learning 作业管理页面",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.noteText": "注意:可能要过几分钟后,作业才会开始计算结果。",
|
||||
"xpack.apm.serviceDetails.enableAnomalyDetectionPanel.selectTransactionTypeLabel": "为此作业选择事务类型",
|
||||
"xpack.apm.serviceDetails.enableErrorReportsPanel.actionsDescription": "可以通过电子邮件发送报告或将报告发布到 Slack 频道。每个报告将包括按发生次数排序的前 10 个错误。",
|
||||
"xpack.apm.serviceDetails.enableErrorReportsPanel.actionsTitle": "操作",
|
||||
"xpack.apm.serviceDetails.enableErrorReportsPanel.conditionTitle": "条件",
|
||||
|
@ -4349,8 +4334,6 @@
|
|||
"xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationText": "确保您的用户有权创建监视。",
|
||||
"xpack.apm.serviceDetails.enableErrorReportsPanel.watchCreationFailedNotificationTitle": "监视创建失败",
|
||||
"xpack.apm.serviceDetails.errorsTabLabel": "错误",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonLabel": "启用 ML 异常检测",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonTooltip": "为此服务设置 Machine Learning 作业",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.enableWatcherErrorReportsButtonLabel": "启用 Watcher 错误报告",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.integrationsButtonLabel": "集成",
|
||||
"xpack.apm.serviceDetails.integrationsMenu.viewWatchesButtonLabel": "查看现有监视",
|
||||
|
@ -4360,9 +4343,6 @@
|
|||
"xpack.apm.serviceDetails.metricsTabLabel": "指标",
|
||||
"xpack.apm.serviceDetails.nodesTabLabel": "JVM",
|
||||
"xpack.apm.serviceDetails.transactionsTabLabel": "事务",
|
||||
"xpack.apm.serviceMap.anomalyDetectionPopoverLink": "查看异常",
|
||||
"xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric": "分数(最大)",
|
||||
"xpack.apm.serviceMap.anomalyDetectionPopoverTitle": "异常检测",
|
||||
"xpack.apm.serviceMap.avgCpuUsagePopoverMetric": "CPU 使用(平均)",
|
||||
"xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric": "每分钟错误数(平均)",
|
||||
"xpack.apm.serviceMap.avgMemoryUsagePopoverMetric": "内存使用(平均)",
|
||||
|
|
Loading…
Reference in a new issue