[APM] ML integration (#19791)

This commit is contained in:
Søren Louv-Jansen 2018-07-04 14:18:19 +02:00 committed by GitHub
parent ea0395fd1e
commit 90528194da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 7734 additions and 2343 deletions

View file

@ -0,0 +1,41 @@
/*
* 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.
*/
function getUiSettingsClient() {
return {
get: key => {
switch (key) {
case 'timepicker:timeDefaults':
return { from: 'now-15m', to: 'now', mode: 'quick' };
case 'timepicker:refreshIntervalDefaults':
return { display: 'Off', pause: false, value: 0 };
default:
throw new Error(`Unexpected config key: ${key}`);
}
}
};
}
function addBasePath(path) {
return path;
}
function getInjected(key) {
switch (key) {
case 'apmIndexPattern':
return 'apm*';
case 'mlEnabled':
return true;
default:
throw new Error(`Unexpected config key: ${key}`);
}
}
export default {
getInjected,
addBasePath,
getUiSettingsClient
};

View file

@ -28,6 +28,7 @@ export function apm(kibana) {
injectDefaultVars(server) {
const config = server.config();
return {
mlEnabled: config.get('xpack.ml.enabled'),
apmUiEnabled: config.get('xpack.apm.ui.enabled'),
apmIndexPattern: config.get('xpack.apm.indexPattern')
};

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"baseUrl": "../../../.",
"paths": {
"ui/*": ["src/ui/public/*"]
}
},
"exclude": ["node_modules", "**/node_modules/*", "build"]
}

View file

@ -1,87 +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 React, { Fragment, Component } from 'react';
import {
EuiButton,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiPopover
} from '@elastic/eui';
import chrome from 'ui/chrome';
export default class OpenWatcherDialogButton extends Component {
state = {
isPopoverOpen: false
};
onButtonClick = () => {
this.setState(prevState => ({
isPopoverOpen: !prevState.isPopoverOpen
}));
};
closePopover = () => {
this.setState({
isPopoverOpen: false
});
};
render() {
const watcherButton = (
<EuiButton
size="s"
iconType="arrowDown"
iconSide="right"
onClick={this.onButtonClick}
>
Watcher
</EuiButton>
);
const items = [
<EuiContextMenuItem
key="create"
icon="plusInCircle"
onClick={() => {
this.closePopover();
this.props.onOpenFlyout();
}}
>
Create new watch
</EuiContextMenuItem>,
<EuiContextMenuItem
key="view"
icon="tableOfContents"
onClick={() => {
window.location = chrome.addBasePath(
'/app/kibana#/management/elasticsearch/watcher/'
);
}}
>
View existing watches
</EuiContextMenuItem>
];
return (
<Fragment>
<EuiPopover
id="watcher"
button={watcherButton}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downRight"
ownFocus
>
<div style={{ width: '210px' }}>
<EuiContextMenuPanel items={items} />
</div>
</EuiPopover>
</Fragment>
);
}
}

View file

@ -0,0 +1,66 @@
/*
* 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 React, { Component } from 'react';
import { EuiButton, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui';
export default class WatcherButton extends Component {
state = {
isPopoverOpen: false
};
onButtonClick = () => this.setState({ isPopoverOpen: true });
closePopover = () => this.setState({ isPopoverOpen: false });
popOverPanels = [
{
id: 0,
title: 'Watcher',
items: [
{
name: 'Enable error reports',
icon: <EuiIcon type="plusInCircle" size="m" />,
onClick: () => {
this.closePopover();
this.props.onOpenFlyout();
}
},
{
name: 'View existing watches',
icon: 'tableOfContents',
href: '/app/kibana#/management/elasticsearch/watcher/',
target: '_blank',
onClick: () => this.closePopover()
}
]
}
];
button = (
<EuiButton
size="s"
iconType="arrowDown"
iconSide="right"
onClick={this.onButtonClick}
>
Integrations
</EuiButton>
);
render() {
return (
<EuiPopover
button={this.button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downRight"
>
<EuiContextMenu initialPanelId={0} panels={this.popOverPanels} />
</EuiPopover>
);
}
}

View file

@ -12,7 +12,6 @@ import _ from 'lodash';
import {
EuiButton,
EuiButtonEmpty,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
@ -203,7 +202,6 @@ export default class WatcherFlyout extends Component {
<KibanaLink
pathname={'/app/kibana'}
hash={`/management/elasticsearch/watcher/watches/watch/${id}`}
query={{}}
>
View watch.
</KibanaLink>
@ -242,7 +240,7 @@ export default class WatcherFlyout extends Component {
return { value: `${hour}:00`, text: `${hour}:00 UTC` };
});
const flyoutContent = (
const flyoutBody = (
<EuiText>
<p>
This form will assist in creating a Watch that can notify you of error
@ -420,33 +418,23 @@ export default class WatcherFlyout extends Component {
<EuiFlyout onClose={this.props.onClose} size="s">
<EuiFlyoutHeader>
<EuiTitle>
<h2>Create new watch assistant</h2>
<h2>Enable error reports</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>{flyoutContent}</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={this.props.onClose}
flush="left"
>
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={this.createWatch}
fill
disabled={
!this.state.actions.email && !this.state.actions.slack
}
>
Create watch
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlyoutBody>{flyoutBody}</EuiFlyoutBody>
<EuiFlyoutFooter
style={{
flexDirection: 'row-reverse',
display: 'flex'
}}
>
<EuiButton
onClick={this.createWatch}
fill
disabled={!this.state.actions.email && !this.state.actions.slack}
>
Create watch
</EuiButton>
</EuiFlyoutFooter>
</EuiFlyout>
);

View file

@ -7,7 +7,7 @@
import { createErrorGroupWatch } from '../createErrorGroupWatch';
import mustache from 'mustache';
import chrome from 'ui/chrome';
import * as rest from '../../../../../services/rest';
import * as rest from '../../../../../services/rest/watcher';
import { isObject, isArray, isString } from 'lodash';
import esResponse from './esResponse.json';

View file

@ -17,7 +17,7 @@ import {
ERROR_EXC_HANDLED,
ERROR_CULPRIT
} from '../../../../../common/constants';
import { createWatch } from '../../../../services/rest';
import { createWatch } from '../../../../services/rest/watcher';
function getSlackPathUrl(slackUrl) {
if (slackUrl) {

View file

@ -10,7 +10,7 @@ import { HeaderContainer } from '../../shared/UIComponents';
import TabNavigation from '../../shared/TabNavigation';
import List from './List';
import WatcherFlyout from './Watcher/WatcherFlyOut';
import OpenWatcherDialogButton from './Watcher/OpenWatcherDialogButton';
import WatcherButton from './Watcher/WatcherButton';
import { ErrorGroupDetailsRequest } from '../../../store/reactReduxRequest/errorGroupList';
class ErrorGroupOverview extends Component {
@ -35,7 +35,7 @@ class ErrorGroupOverview extends Component {
<HeaderContainer>
<h1>{serviceName}</h1>
{license.data.features.watcher.isAvailable && (
<OpenWatcherDialogButton onOpenFlyout={this.onOpenFlyout} />
<WatcherButton onOpenFlyout={this.onOpenFlyout} />
)}
</HeaderContainer>
@ -59,7 +59,9 @@ class ErrorGroupOverview extends Component {
}
ErrorGroupOverview.propTypes = {
location: PropTypes.object.isRequired
license: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
urlParams: PropTypes.object.isRequired
};
export default ErrorGroupOverview;

View file

@ -30,8 +30,8 @@ class Breadcrumbs extends React.Component {
{isLast ? (
<span
ref={node => {
if (node && document.title !== node.innerText) {
document.title = capitalize(node.innerText);
if (node && document.title !== node.textContent) {
document.title = capitalize(node.textContent);
}
}}
>

View file

@ -4,31 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock(
'ui/chrome',
() => ({
getUiSettingsClient: () => {
return {
get: key => {
switch (key) {
case 'timepicker:timeDefaults':
return { from: 'now-15m', to: 'now', mode: 'quick' };
case 'timepicker:refreshIntervalDefaults':
return { display: 'Off', pause: false, value: 0 };
default:
throw new Error(`Unexpected config key: ${key}`);
}
}
};
}
}),
{ virtual: true }
);
import React from 'react';
import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom';
jest.mock('../../../../utils/timepicker', () => {});
import Breadcrumbs from '../Breadcrumbs';
import { toJson } from '../../../../utils/testHelpers';

View file

@ -7,7 +7,7 @@
import React, { Component } from 'react';
import { STATUS } from '../../../constants';
import { isEmpty } from 'lodash';
import { loadAgentStatus } from '../../../services/rest';
import { loadAgentStatus } from '../../../services/rest/apm';
import { KibanaLink } from '../../../utils/url';
import { EuiButton } from '@elastic/eui';
import List from './List';
@ -75,7 +75,7 @@ class ServiceOverview extends Component {
function SetupInstructionsLink({ buttonFill = false }) {
return (
<KibanaLink pathname={'/app/kibana'} hash={'/home/tutorial/apm'} query={{}}>
<KibanaLink pathname={'/app/kibana'} hash={'/home/tutorial/apm'}>
<EuiButton size="s" color="primary" fill={buttonFill}>
Setup Instructions
</EuiButton>

View file

@ -33,6 +33,13 @@ const Heading = styled.div`
const Legends = styled.div`
display: flex;
div {
margin-right: ${px(unit)};
&:last-child {
margin-right: 0;
}
}
`;
export default function TimelineHeader({ legends, transactionName }) {

View file

@ -10,6 +10,7 @@ import { getUrlParams } from '../../../store/urlParams';
function mapStateToProps(state = {}) {
return {
location: state.location,
urlParams: getUrlParams(state)
};
}

View file

@ -13,14 +13,16 @@ import Charts from '../../shared/charts/TransactionCharts';
import { TransactionDistributionRequest } from '../../../store/reactReduxRequest/transactionDistribution';
import { TransactionDetailsRequest } from '../../../store/reactReduxRequest/transactionDetails';
function TransactionDetails({ urlParams }) {
function TransactionDetails({ urlParams, location }) {
return (
<div>
<HeaderLarge>{urlParams.transactionName}</HeaderLarge>
<DetailsChartsRequest
urlParams={urlParams}
render={({ data }) => <Charts charts={data} urlParams={urlParams} />}
render={({ data }) => (
<Charts charts={data} urlParams={urlParams} location={location} />
)}
/>
<TransactionDistributionRequest

View file

@ -0,0 +1,66 @@
/*
* 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 React, { Component } from 'react';
import { EuiButton, EuiPopover, EuiIcon, EuiContextMenu } from '@elastic/eui';
export default class DynamicBaselineButton extends Component {
state = {
isPopoverOpen: false
};
onButtonClick = () => this.setState({ isPopoverOpen: true });
closePopover = () => this.setState({ isPopoverOpen: false });
popOverPanels = [
{
id: 0,
title: 'Machine Learning',
items: [
{
name: 'Anomaly detection (BETA)',
icon: <EuiIcon type="stats" size="m" />,
onClick: () => {
this.closePopover();
this.props.onOpenFlyout();
}
},
{
name: 'View existing jobs',
icon: 'tableOfContents',
href: '/app/ml',
target: '_blank',
onClick: () => this.closePopover()
}
]
}
];
button = (
<EuiButton
size="s"
iconType="arrowDown"
iconSide="right"
onClick={this.onButtonClick}
>
Integrations
</EuiButton>
);
render() {
return (
<EuiPopover
button={this.button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downRight"
>
<EuiContextMenu initialPanelId={0} panels={this.popOverPanels} />
</EuiPopover>
);
}
}

View file

@ -0,0 +1,236 @@
/*
* 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 React, { Component } from 'react';
import PropTypes from 'prop-types';
import { startMlJob } from '../../../../services/rest/ml';
import { getAPMIndexPattern } from '../../../../services/rest/savedObjects';
import {
EuiButton,
EuiCallOut,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiGlobalToastList,
EuiText,
EuiTitle,
EuiSpacer,
EuiBetaBadge
} from '@elastic/eui';
import { getMlJobUrl } from '../../../../utils/url';
export default class DynamicBaselineFlyout extends Component {
state = {
toasts: [],
isLoading: false,
hasIndexPattern: null
};
componentDidMount() {
getAPMIndexPattern().then(indexPattern => {
this.setState({ hasIndexPattern: indexPattern != null });
});
}
createDynamicBaseline = async () => {
this.setState({ isLoading: true });
try {
const { serviceName, transactionType } = this.props;
if (serviceName && transactionType) {
const res = await startMlJob({ serviceName, transactionType });
const didSucceed = res.datafeeds[0].success && res.jobs[0].success;
if (!didSucceed) {
throw new Error('Creating dynamic baseline failed');
}
this.addSuccessToast();
}
} catch (e) {
console.error(e);
this.addErrorToast();
}
this.setState({ isLoading: false });
this.props.onClose();
};
addErrorToast = () => {
const { serviceName, transactionType, location } = this.props;
this.setState({
toasts: [
{
id: 2,
title: 'Job already exists',
color: 'warning',
text: (
<p>
There&apos;s already a job running for anomaly detection on{' '}
{serviceName} ({transactionType}).{' '}
<a href={getMlJobUrl(serviceName, transactionType, location)}>
View existing job
</a>
</p>
)
}
]
});
};
addSuccessToast = () => {
const { serviceName, transactionType, location } = this.props;
this.setState({
toasts: [
{
id: 1,
title: 'Job successfully created',
color: 'success',
text: (
<p>
The analysis is now running for {serviceName} ({transactionType}).
It might take a while before results are added to the response
times graph.{' '}
<a href={getMlJobUrl(serviceName, transactionType, location)}>
View job
</a>
</p>
)
}
]
});
};
removeToasts = () => {
this.setState({
toasts: []
});
};
render() {
const {
hasDynamicBaseline,
isOpen,
location,
onClose,
serviceName,
transactionType
} = this.props;
const { isLoading, hasIndexPattern, toasts } = this.state;
const flyout = (
<EuiFlyout onClose={onClose} size="s">
<EuiFlyoutHeader>
<EuiTitle>
<h2>Enable anomaly detection on response times</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiBetaBadge
label="Beta"
tooltipContent="This feature is currently in beta. Please help us by reporting any bugs."
/>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{hasDynamicBaseline && (
<div>
<EuiCallOut
title={<span>Job already exists</span>}
color="success"
iconType="check"
>
<p>
There is currently a job running for {serviceName} ({
transactionType
}).{' '}
<a href={getMlJobUrl(serviceName, transactionType, location)}>
View existing job
</a>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</div>
)}
{!hasIndexPattern && (
<div>
<EuiCallOut
title={
<span>
No APM index pattern available. To create a job, please
import the APM index pattern via the{' '}
<a href="/app/kibana#/home/tutorial/apm">
Setup Instructions
</a>
</span>
}
color="warning"
iconType="alert"
/>
<EuiSpacer size="m" />
</div>
)}
<EuiText>
<p>
This integration will start a new Machine Learning job that is
predefined to calculate anomaly scores on response times on APM
transactions. Once enabled, the response time graph will show the
expected bounds from the Machine Learning job and annotate the
graph once the anomaly score is &gt;=75.
</p>
<img
src="/plugins/apm/images/apm-ml-anomaly-detection-example.png"
alt="Anomaly detection on response times in APM"
/>
<p>
Jobs can be created per transaction type and based on the average
response time. Once a job is created, you can manage it and see
more details in the{' '}
<a href="/app/ml">Machine Learning jobs management page</a>. It
might take some time for the job to calculate the results. Please
refresh the graph a few minutes after creating the job.
</p>
<p>
<a href="#">Learn more</a> about the Machine Learning integration.
</p>
</EuiText>
</EuiFlyoutBody>
<EuiFlyoutFooter
style={{
flexDirection: 'row-reverse',
display: 'flex'
}}
>
<EuiButton
onClick={this.createDynamicBaseline}
fill
disabled={isLoading || hasDynamicBaseline || !hasIndexPattern}
>
Create new job
</EuiButton>
</EuiFlyoutFooter>
</EuiFlyout>
);
return (
<React.Fragment>
{isOpen && flyout}
<EuiGlobalToastList
toasts={toasts}
dismissToast={this.removeToasts}
toastLifeTimeMs={5000}
/>
</React.Fragment>
);
}
}
DynamicBaselineFlyout.propTypes = {
hasDynamicBaseline: PropTypes.bool.isRequired,
isOpen: PropTypes.bool.isRequired,
location: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
serviceName: PropTypes.string,
transactionType: PropTypes.string
};

View file

@ -4,35 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock(
'ui/chrome',
() => ({
getUiSettingsClient: () => {
return {
get: key => {
switch (key) {
case 'timepicker:timeDefaults':
return { from: 'now-15m', to: 'now', mode: 'quick' };
case 'timepicker:refreshIntervalDefaults':
return { display: 'Off', pause: false, value: 0 };
default:
throw new Error(`Unexpected config key: ${key}`);
}
}
};
}
}),
{ virtual: true }
);
import React from 'react';
import { shallow } from 'enzyme';
import TransactionOverview from '../view';
import { toJson } from '../../../../utils/testHelpers';
jest.mock('../../../../utils/timepicker', () => {});
const setup = () => {
const props = {
changeTransactionSorting: () => {},
license: {
data: {
features: {
ml: { isAvailable: true }
}
}
},
transactionSorting: {},
hasDynamicBaseline: false,
location: {},
urlParams: { transactionType: 'request', serviceName: 'MyServiceName' }
};

View file

@ -2,9 +2,22 @@
exports[`TransactionOverview should not call loadTransactionList without any props 1`] = `
<div>
<styled.h1>
MyServiceName
</styled.h1>
<styled.div>
<h1>
MyServiceName
</h1>
<DynamicBaselineButton
onOpenFlyout={[Function]}
/>
</styled.div>
<DynamicBaselineFlyout
hasDynamicBaseline={false}
isOpen={false}
location={Object {}}
onClose={[Function]}
serviceName="MyServiceName"
transactionType="request"
/>
<Connect(TabNavigation) />
<OverviewChartsRequest
render={[Function]}

View file

@ -8,12 +8,16 @@ import { connect } from 'react-redux';
import TransactionOverview from './view';
import { getUrlParams } from '../../../store/urlParams';
import sorting, { changeTransactionSorting } from '../../../store/sorting';
import { hasDynamicBaseline } from '../../../store/reactReduxRequest/overviewCharts';
import { getLicense } from '../../../store/reactReduxRequest/license';
function mapStateToProps(state = {}) {
return {
urlParams: getUrlParams(state),
transactionSorting: sorting(state, 'transaction').sorting.transaction
hasDynamicBaseline: hasDynamicBaseline(state),
location: state.location,
transactionSorting: sorting(state, 'transaction').sorting.transaction,
license: getLicense(state)
};
}

View file

@ -4,16 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiIconTip } from '@elastic/eui';
import styled from 'styled-components';
import chrome from 'ui/chrome';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { HeaderLarge, HeaderMedium } from '../../shared/UIComponents';
import { HeaderContainer, HeaderMedium } from '../../shared/UIComponents';
import TabNavigation from '../../shared/TabNavigation';
import Charts from '../../shared/charts/TransactionCharts';
import { getMlJobUrl } from '../../../utils/url';
import List from './List';
import { units, px, fontSizes } from '../../../style/variables';
import { OverviewChartsRequest } from '../../../store/reactReduxRequest/overviewCharts';
import { TransactionListRequest } from '../../../store/reactReduxRequest/transactionList';
import { ServiceDetailsRequest } from '../../../store/reactReduxRequest/serviceDetails';
import DynamicBaselineButton from './DynamicBaseline/Button';
import DynamicBaselineFlyout from './DynamicBaseline/Flyout';
function ServiceDetailsAndTransactionList({ urlParams, render }) {
return (
<ServiceDetailsRequest
@ -35,46 +43,117 @@ function ServiceDetailsAndTransactionList({ urlParams, render }) {
);
}
export default function TransactionOverview({
changeTransactionSorting,
transactionSorting,
urlParams
}) {
const { serviceName, transactionType } = urlParams;
const MLTipContainer = styled.div`
display: flex;
align-items: center;
font-size: ${fontSizes.small};
`;
return (
<div>
<HeaderLarge>{serviceName}</HeaderLarge>
const MLText = styled.div`
margin-left: ${px(units.half)};
`;
<TabNavigation />
class TransactionOverview extends Component {
state = {
isFlyoutOpen: false
};
<OverviewChartsRequest
urlParams={urlParams}
render={({ data }) => <Charts charts={data} urlParams={urlParams} />}
/>
onOpenFlyout = () => this.setState({ isFlyoutOpen: true });
onCloseFlyout = () => this.setState({ isFlyoutOpen: false });
<HeaderMedium>{transactionTypeLabel(transactionType)}</HeaderMedium>
render() {
const {
changeTransactionSorting,
hasDynamicBaseline,
license,
location,
transactionSorting,
urlParams
} = this.props;
<ServiceDetailsAndTransactionList
urlParams={urlParams}
render={({ serviceDetails, transactionList }) => {
return (
<List
agentName={serviceDetails.agentName}
serviceName={serviceName}
type={transactionType}
items={transactionList}
changeTransactionSorting={changeTransactionSorting}
transactionSorting={transactionSorting}
const { serviceName, transactionType } = urlParams;
const mlEnabled = chrome.getInjected('mlEnabled');
const ChartHeaderContent =
hasDynamicBaseline && license.data.features.ml.isAvailable ? (
<MLTipContainer>
<EuiIconTip content="The stream around the average response time shows the expected bounds. An annotation is shown for anomaly scores &gt;= 75." />
<MLText>
Machine Learning:{' '}
<a
href={getMlJobUrl(
serviceName,
transactionType,
this.props.location
)}
>
View Job
</a>
</MLText>
</MLTipContainer>
) : null;
return (
<div>
<HeaderContainer>
<h1>{serviceName}</h1>
{license.data.features.ml.isAvailable &&
mlEnabled && (
<DynamicBaselineButton onOpenFlyout={this.onOpenFlyout} />
)}
</HeaderContainer>
<DynamicBaselineFlyout
hasDynamicBaseline={hasDynamicBaseline}
isOpen={this.state.isFlyoutOpen}
location={location}
onClose={this.onCloseFlyout}
serviceName={serviceName}
transactionType={transactionType}
/>
<TabNavigation />
<OverviewChartsRequest
urlParams={urlParams}
render={({ data }) => (
<Charts
charts={data}
urlParams={urlParams}
location={location}
ChartHeaderContent={ChartHeaderContent}
/>
);
}}
/>
</div>
);
)}
/>
<HeaderMedium>{transactionTypeLabel(transactionType)}</HeaderMedium>
<ServiceDetailsAndTransactionList
urlParams={urlParams}
render={({ serviceDetails, transactionList }) => {
return (
<List
agentName={serviceDetails.agentName}
serviceName={serviceName}
type={transactionType}
items={transactionList}
changeTransactionSorting={changeTransactionSorting}
transactionSorting={transactionSorting}
/>
);
}}
/>
</div>
);
}
}
TransactionOverview.propTypes = {
changeTransactionSorting: PropTypes.func.isRequired,
hasDynamicBaseline: PropTypes.bool.isRequired,
license: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
transactionSorting: PropTypes.object.isRequired,
urlParams: PropTypes.object.isRequired
};
@ -89,3 +168,5 @@ function transactionTypeLabel(type) {
return type;
}
}
export default TransactionOverview;

View file

@ -13,11 +13,8 @@ import {
legacyEncodeURIComponent
} from '../../../utils/url';
import { debounce } from 'lodash';
import { EuiFieldSearch } from '@elastic/eui';
import { getAPMIndexPattern } from '../../../services/rest';
import { getAPMIndexPattern } from '../../../services/rest/savedObjects';
import { convertKueryToEsQuery, getSuggestions } from '../../../services/kuery';
import styled from 'styled-components';

View file

@ -13,43 +13,43 @@ import SelectionMarker from './SelectionMarker';
import { MarkSeries, VerticalGridLines } from 'react-vis';
import Tooltip from '../Tooltip';
function getPointByX(serie, x) {
return serie.data.find(point => point.x === x);
}
class InteractivePlot extends PureComponent {
getMarkPoints = hoverIndex => {
if (!this.props.series[0].data[hoverIndex]) {
return [];
}
return this.props.series.map(serie => {
const { x, y } = serie.data[hoverIndex] || {};
return {
x,
y,
color: serie.color
};
});
getMarkPoints = hoverX => {
return this.props.series
.filter(serie =>
serie.data.some(point => point.x === hoverX && point.y != null)
)
.map(serie => {
const { x, y } = getPointByX(serie, hoverX) || {};
return {
x,
y,
color: serie.color
};
});
};
getTooltipPoints = hoverIndex => {
if (!this.props.series[0].data[hoverIndex]) {
return [];
}
return this.props.series.map(serie => ({
color: serie.color,
value: this.props.formatTooltipValue(_.get(serie.data[hoverIndex], 'y')),
text: serie.titleShort || serie.title
}));
};
getHoveredX = hoverIndex => {
const defaultSerie = this.props.series[0].data;
return _.get(defaultSerie[hoverIndex], 'x');
getTooltipPoints = hoverX => {
return this.props.series
.filter(series => !series.hideTooltipValue)
.map(serie => {
const point = getPointByX(serie, hoverX) || {};
return {
color: serie.color,
value: this.props.formatTooltipValue(point),
text: serie.titleShort || serie.title
};
});
};
render() {
const {
plotValues,
hoverIndex,
hoverX,
series,
isDrawing,
selectionStart,
@ -60,24 +60,19 @@ class InteractivePlot extends PureComponent {
return null;
}
const tooltipPoints = this.getTooltipPoints(hoverIndex);
const markPoints = this.getMarkPoints(hoverIndex);
const hoveredX = this.getHoveredX(hoverIndex);
const tooltipPoints = this.getTooltipPoints(hoverX);
const markPoints = this.getMarkPoints(hoverX);
const { x, yTickValues } = plotValues;
const yValueMiddle = yTickValues[1];
return (
<SharedPlot plotValues={plotValues}>
{hoveredX && (
<Tooltip
tooltipPoints={tooltipPoints}
x={hoveredX}
y={yValueMiddle}
/>
{hoverX && (
<Tooltip tooltipPoints={tooltipPoints} x={hoverX} y={yValueMiddle} />
)}
{hoveredX && <MarkSeries data={markPoints} colorType="literal" />}
{hoveredX && <VerticalGridLines tickValues={[hoveredX]} />}
{hoverX && <MarkSeries data={markPoints} colorType="literal" />}
{hoverX && <VerticalGridLines tickValues={[hoverX]} />}
{isDrawing &&
selectionEnd !== null && (
@ -90,7 +85,7 @@ class InteractivePlot extends PureComponent {
InteractivePlot.propTypes = {
formatTooltipValue: PropTypes.func.isRequired,
hoverIndex: PropTypes.number,
hoverX: PropTypes.number,
isDrawing: PropTypes.bool.isRequired,
plotValues: PropTypes.object.isRequired,
selectionEnd: PropTypes.number,

View file

@ -9,6 +9,7 @@ import React from 'react';
import styled from 'styled-components';
import Legend from '../Legend';
import {
unit,
units,
fontSizes,
px,
@ -20,6 +21,13 @@ const Container = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
div {
margin-right: ${px(unit)};
&:last-child {
margin-right: 0;
}
}
`;
const LegendContent = styled.span`
@ -53,9 +61,9 @@ function MoreSeries({ hiddenSeriesCount }) {
}
export default function Legends({
noHits,
clickLegend,
hiddenSeriesCount,
noHits,
series,
seriesEnabledState,
truncateLegends
@ -67,6 +75,9 @@ export default function Legends({
return (
<Container>
{series.map((serie, i) => {
if (serie.hideLegend) {
return null;
}
const text = (
<LegendContent>
{truncateLegends ? (
@ -95,7 +106,6 @@ export default function Legends({
}
Legends.propTypes = {
chartTitle: PropTypes.string,
clickLegend: PropTypes.func.isRequired,
hiddenSeriesCount: PropTypes.number.isRequired,
noHits: PropTypes.bool.isRequired,

View file

@ -13,13 +13,21 @@ import {
} from 'react-vis';
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import { last } from 'lodash';
import StatusText from './StatusText';
import { SharedPlot } from './plotUtils';
const X_TICK_TOTAL = 7;
class StaticPlot extends PureComponent {
getSerie(serie) {
getVisSeries(series, plotValues) {
return series
.slice()
.reverse()
.map(serie => this.getSerie(serie, plotValues));
}
getSerie(serie, plotValues) {
switch (serie.type) {
case 'line':
return (
@ -45,6 +53,27 @@ class StaticPlot extends PureComponent {
fill={serie.areaColor}
/>
);
case 'areaMaxHeight':
const yMax = last(plotValues.yTickValues);
const data = serie.data.map(p => ({
x: p.x,
y0: 0,
y: p.y ? yMax : null
}));
return (
<AreaSeries
getNull={d => d.y !== null}
key={serie.title}
xType="time"
curve={'curveMonotoneX'}
data={data}
color={serie.color}
stroke={serie.color}
fill={serie.areaColor}
/>
);
default:
throw new Error(`Unknown type ${serie.type}`);
}
@ -63,10 +92,7 @@ class StaticPlot extends PureComponent {
{noHits ? (
<StatusText text="No data within this time range." />
) : (
series
.slice()
.reverse()
.map(this.getSerie)
this.getVisSeries(series, plotValues)
)}
</SharedPlot>
);

View file

@ -11,12 +11,18 @@ import React, { PureComponent } from 'react';
import { SharedPlot } from './plotUtils';
function getXValuesCombined(series) {
return _.union(...series.map(serie => serie.data.map(p => p.x))).map(x => ({
x
}));
}
class VoronoiPlot extends PureComponent {
render() {
const { series, plotValues, noHits } = this.props;
const { XY_MARGIN, XY_HEIGHT, XY_WIDTH, x } = plotValues;
const defaultSerieData = _.get(series, '[0].data');
if (!defaultSerieData || noHits) {
const xValuesCombined = getXValuesCombined(series);
if (!xValuesCombined || noHits) {
return null;
}
@ -27,7 +33,7 @@ class VoronoiPlot extends PureComponent {
>
<Voronoi
extent={[[XY_MARGIN.left, XY_MARGIN.top], [XY_WIDTH, XY_HEIGHT]]}
nodes={defaultSerieData}
nodes={xValuesCombined}
onHover={this.props.onHover}
onMouseDown={this.props.onMouseDown}
onMouseUp={this.props.onMouseUp}

View file

@ -8,7 +8,6 @@ import _ from 'lodash';
import { makeWidthFlexible } from 'react-vis';
import PropTypes from 'prop-types';
import React, { PureComponent, Fragment } from 'react';
import styled from 'styled-components';
import Legends from './Legends';
import StaticPlot from './StaticPlot';
@ -16,14 +15,12 @@ import InteractivePlot from './InteractivePlot';
import VoronoiPlot from './VoronoiPlot';
import { createSelector } from 'reselect';
import { getPlotValues } from './plotUtils';
import { fontSizes, units, px } from '../../../../style/variables';
const Title = styled.div`
font-size: ${fontSizes.large};
margin-bottom: ${px(units.half)};
`;
const VISIBLE_LEGEND_COUNT = 4;
const VISIBLE_SERIES_COUNT = 4;
function getHiddenLegendCount(series) {
return series.filter(serie => serie.hideLegend).length;
}
export class InnerCustomPlot extends PureComponent {
state = {
@ -49,7 +46,12 @@ export class InnerCustomPlot extends PureComponent {
getVisibleSeries = createSelector(
state => state.series,
series => series.slice(0, VISIBLE_SERIES_COUNT)
series => {
return series.slice(
0,
VISIBLE_LEGEND_COUNT + getHiddenLegendCount(series)
);
}
);
clickLegend = i => {
@ -91,10 +93,7 @@ export class InnerCustomPlot extends PureComponent {
};
onHover = node => {
const index = this.props.series[0].data.findIndex(
item => item.x === node.x
);
this.props.onHover(index);
this.props.onHover(node.x);
if (this.state.isDrawing) {
this.setState({ selectionEnd: node.x });
@ -102,13 +101,16 @@ export class InnerCustomPlot extends PureComponent {
};
render() {
const { chartTitle, series, truncateLegends, noHits, width } = this.props;
const { series, truncateLegends, noHits, width } = this.props;
if (_.isEmpty(series) || !width) {
return null;
}
const hiddenSeriesCount = Math.max(series.length - VISIBLE_SERIES_COUNT, 0);
const hiddenSeriesCount = Math.max(
series.length - VISIBLE_LEGEND_COUNT - getHiddenLegendCount(series),
0
);
const visibleSeries = this.getVisibleSeries({ series });
const enabledSeries = this.getEnabledSeries({
visibleSeries,
@ -126,11 +128,8 @@ export class InnerCustomPlot extends PureComponent {
return (
<Fragment>
<Title>{chartTitle}</Title>
<Legends
noHits={noHits}
chartTitle={chartTitle}
truncateLegends={truncateLegends}
series={visibleSeries}
hiddenSeriesCount={hiddenSeriesCount}
@ -149,7 +148,7 @@ export class InnerCustomPlot extends PureComponent {
<InteractivePlot
plotValues={plotValues}
hoverIndex={this.props.hoverIndex}
hoverX={this.props.hoverX}
series={enabledSeries}
formatTooltipValue={this.props.formatTooltipValue}
isDrawing={this.state.isDrawing}
@ -174,7 +173,7 @@ export class InnerCustomPlot extends PureComponent {
InnerCustomPlot.propTypes = {
formatTooltipValue: PropTypes.func,
hoverIndex: PropTypes.number,
hoverX: PropTypes.number,
noHits: PropTypes.bool.isRequired,
onHover: PropTypes.func.isRequired,
onMouseLeave: PropTypes.func.isRequired,
@ -186,7 +185,8 @@ InnerCustomPlot.propTypes = {
};
InnerCustomPlot.defaultProps = {
formatTooltipValue: y => y,
formatTooltipValue: p => p.y,
tickFormatX: undefined,
tickFormatY: y => y,
truncateLegends: false
};

View file

@ -17,7 +17,7 @@ const XY_HEIGHT = unit * 16;
const XY_MARGIN = {
top: unit,
left: unit * 5,
right: unit,
right: 0,
bottom: unit * 2
};

View file

@ -49,7 +49,7 @@ describe('when response has data', () => {
describe('Initially', () => {
it('should have 3 enabled series', () => {
expect(wrapper.find('AreaSeries').length).toBe(3);
expect(wrapper.find('LineSeries').length).toBe(3);
});
it('should have 3 legends ', () => {
@ -96,7 +96,7 @@ describe('when response has data', () => {
});
it('should have 2 enabled series', () => {
expect(wrapper.find('AreaSeries').length).toBe(2);
expect(wrapper.find('LineSeries').length).toBe(2);
});
it('should add disabled prop to Legends', () => {
@ -157,19 +157,20 @@ describe('when response has data', () => {
});
describe('when hovering over', () => {
const index = 22;
beforeEach(() => {
wrapper
.find('.rv-voronoi__cell')
.at(22)
.at(index)
.simulate('mouseOver');
});
it('should call onHover', () => {
expect(onHover).toHaveBeenCalledWith(22);
expect(onHover).toHaveBeenCalledWith(responseWithData.dates[index]);
});
});
describe('when setting hoverIndex', () => {
describe('when setting hoverX', () => {
beforeEach(() => {
// Avoid timezone issues in snapshots
jest.spyOn(moment.prototype, 'format').mockImplementation(function() {
@ -177,9 +178,9 @@ describe('when response has data', () => {
});
// Simulate hovering over multiple buckets
wrapper.setProps({ hoverIndex: 13 });
wrapper.setProps({ hoverIndex: 14 });
wrapper.setProps({ hoverIndex: 15 });
wrapper.setProps({ hoverX: responseWithData.dates[13] });
wrapper.setProps({ hoverX: responseWithData.dates[14] });
wrapper.setProps({ hoverX: responseWithData.dates[15] });
});
it('should display tooltip', () => {

View file

@ -131,7 +131,8 @@
2547299.079999993,
4586742.89999998,
0
]
],
"avgAnomalies": {}
},
"tpmBuckets": [
{
@ -283,6 +284,6 @@
]
}
],
"weightedAverage": 467582.45401459857,
"overallAvgDuration": 467582.45401459857,
"totalHits": 999
}

View file

@ -6,13 +6,7 @@
import React, { PureComponent } from 'react';
import styled from 'styled-components';
import {
unit,
units,
px,
colors,
fontSizes
} from '../../../../style/variables';
import { units, px, colors, fontSizes } from '../../../../style/variables';
const Container = styled.div`
display: flex;
@ -21,7 +15,6 @@ const Container = styled.div`
color: ${colors.gray2};
cursor: ${props => (props.clickable ? 'pointer' : 'initial')};
opacity: ${props => (props.disabled ? 0.4 : 1)};
margin-right: ${px(unit)};
user-select: none;
`;

View file

@ -9,7 +9,7 @@ import PropTypes from 'prop-types';
import CustomPlot from '../CustomPlot';
import { asMillis, tpmUnit, asInteger } from '../../../../utils/formatters';
import styled from 'styled-components';
import { units, unit, px } from '../../../../style/variables';
import { units, unit, px, fontSizes } from '../../../../style/variables';
import { timefilter } from 'ui/timefilter';
import moment from 'moment';
@ -35,15 +35,26 @@ const Chart = styled.div`
}
`;
const ChartHeader = styled.div`
display: flex;
justify-content: space-between;
margin-bottom: ${px(units.half)};
`;
const ChartTitle = styled.div`
font-weight: 600;
font-size: ${fontSizes.large};
`;
export class Charts extends Component {
state = {
hoverIndex: null
hoverX: null
};
onHover = hoverIndex => this.setState({ hoverIndex });
onMouseLeave = () => this.setState({ hoverIndex: null });
onHover = hoverX => this.setState({ hoverX });
onMouseLeave = () => this.setState({ hoverX: null });
onSelectionEnd = selection => {
this.setState({ hoverIndex: null });
this.setState({ hoverX: null });
timefilter.setTime({
from: moment(selection.start).toISOString(),
to: moment(selection.end).toISOString(),
@ -55,11 +66,11 @@ export class Charts extends Component {
return this.props.charts.noHits ? '- ms' : asMillis(t);
};
getResponseTimeTooltipFormatter = t => {
getResponseTimeTooltipFormatter = (p = {}) => {
if (this.props.charts.noHits) {
return '- ms';
} else {
return t == null ? 'N/A' : asMillis(t);
return p.y == null ? 'N/A' : asMillis(p.y);
}
};
@ -69,37 +80,46 @@ export class Charts extends Component {
return charts.noHits ? `- ${unit}` : `${asInteger(t)} ${unit}`;
};
getTPMTooltipFormatter = (p = {}) => {
return this.getTPMFormatter(p.y);
};
render() {
const { charts, urlParams } = this.props;
const { noHits, responseTimeSeries, tpmSeries } = charts;
const { noHits, responseTimeSeries, tpmSeries } = this.props.charts;
const { transactionType } = this.props.urlParams;
return (
<ChartsWrapper>
<Chart>
<ChartHeader>
<ChartTitle>{responseTimeLabel(transactionType)}</ChartTitle>
{this.props.ChartHeaderContent}
</ChartHeader>
<CustomPlot
noHits={noHits}
chartTitle={responseTimeLabel(urlParams.transactionType)}
series={responseTimeSeries}
onHover={this.onHover}
onMouseLeave={this.onMouseLeave}
onSelectionEnd={this.onSelectionEnd}
hoverIndex={this.state.hoverIndex}
hoverX={this.state.hoverX}
tickFormatY={this.getResponseTimeTickFormatter}
formatTooltipValue={this.getResponseTimeTooltipFormatter}
/>
</Chart>
<Chart>
<ChartHeader>
<ChartTitle>{tpmLabel(transactionType)}</ChartTitle>
</ChartHeader>
<CustomPlot
noHits={noHits}
chartTitle={tpmLabel(urlParams.transactionType)}
series={tpmSeries}
onHover={this.onHover}
onMouseLeave={this.onMouseLeave}
onSelectionEnd={this.onSelectionEnd}
hoverIndex={this.state.hoverIndex}
hoverX={this.state.hoverX}
tickFormatY={this.getTPMFormatter}
formatTooltipValue={this.getTPMFormatter}
formatTooltipValue={this.getTPMTooltipFormatter}
truncateLegends
/>
</Chart>
@ -124,8 +144,14 @@ function responseTimeLabel(type) {
}
Charts.propTypes = {
urlParams: PropTypes.object.isRequired,
charts: PropTypes.object.isRequired
charts: PropTypes.object.isRequired,
ChartHeaderContent: PropTypes.object,
location: PropTypes.object.isRequired,
urlParams: PropTypes.object.isRequired
};
Charts.defaultProps = {
ChartHeaderContent: null
};
export default Charts;

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -27,10 +27,9 @@ import LicenseChecker from './components/app/Main/LicenseChecker';
import { history } from './utils/url';
chrome.setRootTemplate(template);
const store = configureStore();
initTimepicker(history, store.dispatch, () => {
initTimepicker(history, store.dispatch).then(() => {
ReactDOM.render(
<Router history={history}>
<Breadcrumbs />

View file

@ -6,9 +6,9 @@
import * as kfetchModule from 'ui/kfetch';
import { SessionStorageMock } from './SessionStorageMock';
import { callApi } from '../rest';
import { callApi } from '../rest/callApi';
describe('rest', () => {
describe('callApi', () => {
let kfetchSpy;
beforeEach(() => {

View file

@ -4,64 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import 'isomorphic-fetch';
import { camelizeKeys } from 'humps';
import { kfetch } from 'ui/kfetch';
import { memoize, isEmpty, first, startsWith } from 'lodash';
import chrome from 'ui/chrome';
import { convertKueryToEsQuery } from './kuery';
import { getFromSavedObject } from 'ui/index_patterns/static_utils';
function fetchOptionsWithDebug(fetchOptions) {
const debugEnabled =
sessionStorage.getItem('apm_debug') === 'true' &&
startsWith(fetchOptions.pathname, '/api/apm');
if (!debugEnabled) {
return fetchOptions;
}
return {
...fetchOptions,
query: {
...fetchOptions.query,
_debug: true
}
};
}
export async function callApi(fetchOptions, kibanaOptions) {
const combinedKibanaOptions = {
camelcase: true,
...kibanaOptions
};
const combinedFetchOptions = fetchOptionsWithDebug(fetchOptions);
const res = await kfetch(combinedFetchOptions, combinedKibanaOptions);
return combinedKibanaOptions.camelcase ? camelizeKeys(res) : res;
}
export const getAPMIndexPattern = memoize(async () => {
const res = await callApi({
pathname: chrome.addBasePath(`/api/saved_objects/_find`),
query: {
type: 'index-pattern'
}
});
if (isEmpty(res.savedObjects)) {
return {};
}
const apmIndexPattern = chrome.getInjected('apmIndexPattern');
const apmSavedObject = first(
res.savedObjects.filter(
savedObject => savedObject.attributes.title === apmIndexPattern
)
);
return getFromSavedObject(apmSavedObject);
});
import { convertKueryToEsQuery } from '../kuery';
import { callApi } from './callApi';
import { getAPMIndexPattern } from './savedObjects';
export async function loadLicense() {
return callApi({
@ -280,11 +226,3 @@ export async function loadErrorDistribution({
}
});
}
export async function createWatch(id, watch) {
return callApi({
method: 'PUT',
pathname: `/api/watcher/watch/${id}`,
body: JSON.stringify({ type: 'json', id, watch })
});
}

View file

@ -0,0 +1,39 @@
/*
* 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 'isomorphic-fetch';
import { camelizeKeys } from 'humps';
import { kfetch } from 'ui/kfetch';
import { startsWith } from 'lodash';
function fetchOptionsWithDebug(fetchOptions) {
const debugEnabled =
sessionStorage.getItem('apm_debug') === 'true' &&
startsWith(fetchOptions.pathname, '/api/apm');
if (!debugEnabled) {
return fetchOptions;
}
return {
...fetchOptions,
query: {
...fetchOptions.query,
_debug: true
}
};
}
export async function callApi(fetchOptions, kibanaOptions) {
const combinedKibanaOptions = {
camelcase: true,
...kibanaOptions
};
const combinedFetchOptions = fetchOptionsWithDebug(fetchOptions);
const res = await kfetch(combinedFetchOptions, combinedKibanaOptions);
return combinedKibanaOptions.camelcase ? camelizeKeys(res) : res;
}

View file

@ -0,0 +1,31 @@
/*
* 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 chrome from 'ui/chrome';
import { callApi } from './callApi';
import { SERVICE_NAME, TRANSACTION_TYPE } from '../../../common/constants';
export async function startMlJob({ serviceName, transactionType }) {
const apmIndexPattern = chrome.getInjected('apmIndexPattern');
return callApi({
method: 'POST',
pathname: `/api/ml/modules/setup/apm_transaction`,
body: JSON.stringify({
prefix: `${serviceName}-${transactionType}-`.toLowerCase(),
groups: ['apm', serviceName.toLowerCase(), transactionType.toLowerCase()],
indexPatternName: apmIndexPattern,
startDatafeed: true,
query: {
bool: {
filter: [
{ term: { [SERVICE_NAME]: serviceName } },
{ term: { [TRANSACTION_TYPE]: transactionType } }
]
}
}
})
});
}

View file

@ -0,0 +1,36 @@
/*
* 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 { memoize, isEmpty, first } from 'lodash';
import chrome from 'ui/chrome';
import { getFromSavedObject } from 'ui/index_patterns/static_utils';
import { callApi } from './callApi';
export const getAPMIndexPattern = memoize(async () => {
const res = await callApi({
pathname: `/api/saved_objects/_find`,
query: {
type: 'index-pattern'
}
});
if (isEmpty(res.savedObjects)) {
return;
}
const apmIndexPattern = chrome.getInjected('apmIndexPattern');
const apmSavedObject = first(
res.savedObjects.filter(
savedObject => savedObject.attributes.title === apmIndexPattern
)
);
if (!apmSavedObject) {
return;
}
return getFromSavedObject(apmSavedObject);
});

View file

@ -4,6 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
export default {
addBasePath: (path) => path
};
import { callApi } from './callApi';
export async function createWatch(id, watch) {
return callApi({
method: 'PUT',
pathname: `/api/watcher/watch/${id}`,
body: JSON.stringify({ type: 'json', id, watch })
});
}

View file

@ -0,0 +1,31 @@
/*
* 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 { createInitialDataSelector } from '../helpers';
describe('createInitialDataSelector', () => {
it('should use initialData when data is missing from state', () => {
const state = {};
const initialData = { foo: 'bar' };
const withInitialData = createInitialDataSelector(initialData);
expect(withInitialData(state)).toBe(withInitialData(state));
expect(withInitialData(state, initialData)).toEqual({
data: { foo: 'bar' }
});
});
it('should use data when available', () => {
const state = { data: 'hello' };
const initialData = { foo: 'bar' };
const withInitialData = createInitialDataSelector(initialData);
expect(withInitialData(state)).toBe(withInitialData(state));
expect(withInitialData(state, initialData)).toEqual({
data: 'hello'
});
});
});

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import * as rest from '../../../services/rest';
import * as rest from '../../../services/rest/apm';
import { getServiceList, ServiceListRequest } from '../serviceList';
import { mountWithStore } from '../../../utils/testHelpers';

View file

@ -9,8 +9,8 @@ import { createSelector } from 'reselect';
import { getCharts } from '../selectors/chartSelectors';
import { getUrlParams } from '../urlParams';
import { Request } from 'react-redux-request';
import { loadCharts } from '../../services/rest';
import { withInitialData } from './helpers';
import { loadCharts } from '../../services/rest/apm';
import { createInitialDataSelector } from './helpers';
const ID = 'detailsCharts';
const INITIAL_DATA = {
@ -18,12 +18,14 @@ const INITIAL_DATA = {
dates: [],
responseTimes: {},
tpmBuckets: [],
weightedAverage: null
overallAvgDuration: null
};
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export const getDetailsCharts = createSelector(
getUrlParams,
state => withInitialData(state.reactReduxRequest[ID], INITIAL_DATA),
state => withInitialData(state.reactReduxRequest[ID]),
(urlParams, detailCharts) => {
return {
...detailCharts,

View file

@ -6,14 +6,15 @@
import React from 'react';
import { Request } from 'react-redux-request';
import { loadErrorDistribution } from '../../services/rest';
import { withInitialData } from './helpers';
import { loadErrorDistribution } from '../../services/rest/apm';
import { createInitialDataSelector } from './helpers';
const ID = 'errorDistribution';
const INITIAL_DATA = { buckets: [], totalHits: 0 };
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getErrorDistribution(state) {
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
return withInitialData(state.reactReduxRequest[ID]);
}
export function ErrorDistributionRequest({ urlParams, render }) {

View file

@ -5,15 +5,16 @@
*/
import React from 'react';
import { withInitialData } from './helpers';
import { createInitialDataSelector } from './helpers';
import { Request } from 'react-redux-request';
import { loadErrorGroupDetails } from '../../services/rest';
import { loadErrorGroupDetails } from '../../services/rest/apm';
const ID = 'errorGroupDetails';
const INITIAL_DATA = {};
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getErrorGroupDetails(state) {
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
return withInitialData(state.reactReduxRequest[ID]);
}
export function ErrorGroupDetailsRequest({ urlParams, render }) {

View file

@ -5,15 +5,16 @@
*/
import React from 'react';
import { withInitialData } from './helpers';
import { createInitialDataSelector } from './helpers';
import { Request } from 'react-redux-request';
import { loadErrorGroupList } from '../../services/rest';
import { loadErrorGroupList } from '../../services/rest/apm';
const ID = 'errorGroupList';
const INITIAL_DATA = [];
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getErrorGroupList(state) {
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
return withInitialData(state.reactReduxRequest[ID]);
}
export function ErrorGroupDetailsRequest({ urlParams, render }) {

View file

@ -4,9 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
export function withInitialData(state = {}, initialData) {
return {
...state,
data: state.data || initialData
};
}
import { createSelector } from 'reselect';
import { get } from 'lodash';
export const createInitialDataSelector = initialData => {
return createSelector(
state => state,
state => {
return {
...state,
data: get(state, 'data') || initialData
};
}
);
};

View file

@ -4,18 +4,23 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { withInitialData } from './helpers';
import { createInitialDataSelector } from './helpers';
import { Request } from 'react-redux-request';
import { loadLicense } from '../../services/rest';
import { loadLicense } from '../../services/rest/apm';
const ID = 'license';
const INITIAL_DATA = {
features: { watcher: { isAvailable: false } },
features: {
watcher: { isAvailable: false },
ml: { isAvailable: false }
},
license: { isActive: false }
};
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getLicense(state) {
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
return withInitialData(state.reactReduxRequest[ID]);
}
export function LicenceRequest({ render }) {

View file

@ -6,11 +6,11 @@
import React from 'react';
import { createSelector } from 'reselect';
import { get, isEmpty } from 'lodash';
import { getCharts } from '../selectors/chartSelectors';
import { getUrlParams } from '../urlParams';
import { Request } from 'react-redux-request';
import { loadCharts } from '../../services/rest';
import { withInitialData } from './helpers';
import { loadCharts } from '../../services/rest/apm';
const ID = 'overviewCharts';
const INITIAL_DATA = {
@ -18,20 +18,29 @@ const INITIAL_DATA = {
dates: [],
responseTimes: {},
tpmBuckets: [],
weightedAverage: null
overallAvgDuration: null
};
export const getOverviewCharts = createSelector(
getUrlParams,
state => withInitialData(state.reactReduxRequest[ID], INITIAL_DATA),
(urlParams, overviewCharts) => {
state => state.reactReduxRequest[ID],
(urlParams, overviewCharts = {}) => {
return {
...overviewCharts,
data: getCharts(urlParams, overviewCharts.data)
data: getCharts(urlParams, overviewCharts.data || INITIAL_DATA)
};
}
);
export function hasDynamicBaseline(state) {
return !isEmpty(
get(
state,
`reactReduxRequest[${ID}].data.responseTimes.avgAnomalies.buckets`
)
);
}
export function OverviewChartsRequest({ urlParams, render }) {
const { serviceName, start, end, transactionType, kuery } = urlParams;

View file

@ -7,15 +7,16 @@
import React from 'react';
import _ from 'lodash';
import PropTypes from 'prop-types';
import { withInitialData } from './helpers';
import { createInitialDataSelector } from './helpers';
import { Request } from 'react-redux-request';
import { loadServiceDetails } from '../../services/rest';
import { loadServiceDetails } from '../../services/rest/apm';
const ID = 'serviceDetails';
const INITIAL_DATA = { types: [] };
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getServiceDetails(state) {
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
return withInitialData(state.reactReduxRequest[ID]);
}
export function getDefaultTransactionType(state) {

View file

@ -7,15 +7,16 @@
import React from 'react';
import orderBy from 'lodash.orderby';
import { createSelector } from 'reselect';
import { loadServiceList } from '../../services/rest';
import { loadServiceList } from '../../services/rest/apm';
import { Request } from 'react-redux-request';
import { withInitialData } from './helpers';
import { createInitialDataSelector } from './helpers';
const ID = 'serviceList';
const INITIAL_DATA = [];
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export const getServiceList = createSelector(
state => withInitialData(state.reactReduxRequest[ID], INITIAL_DATA),
state => withInitialData(state.reactReduxRequest[ID]),
state => state.sorting.service,
(serviceList, serviceSorting) => {
const { key: sortKey, descending } = serviceSorting;

View file

@ -5,15 +5,16 @@
*/
import React from 'react';
import { withInitialData } from './helpers';
import { createInitialDataSelector } from './helpers';
import { Request } from 'react-redux-request';
import { loadSpans } from '../../services/rest';
import { loadSpans } from '../../services/rest/apm';
const ID = 'spans';
const INITIAL_DATA = {};
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getSpans(state) {
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
return withInitialData(state.reactReduxRequest[ID]);
}
export function SpansRequest({ urlParams, render }) {

View file

@ -5,15 +5,16 @@
*/
import React from 'react';
import { withInitialData } from './helpers';
import { createInitialDataSelector } from './helpers';
import { Request } from 'react-redux-request';
import { loadTransaction } from '../../services/rest';
import { loadTransaction } from '../../services/rest/apm';
const ID = 'transactionDetails';
const INITIAL_DATA = {};
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getTransactionDetails(state) {
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
return withInitialData(state.reactReduxRequest[ID]);
}
export function TransactionDetailsRequest({ urlParams, render }) {

View file

@ -5,15 +5,16 @@
*/
import React from 'react';
import { withInitialData } from './helpers';
import { createInitialDataSelector } from './helpers';
import { Request } from 'react-redux-request';
import { loadTransactionDistribution } from '../../services/rest';
import { loadTransactionDistribution } from '../../services/rest/apm';
const ID = 'transactionDistribution';
const INITIAL_DATA = { buckets: [], totalHits: 0 };
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getTransactionDistribution(state) {
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
return withInitialData(state.reactReduxRequest[ID]);
}
export function getDefaultTransactionId(state) {

View file

@ -8,14 +8,15 @@ import React from 'react';
import orderBy from 'lodash.orderby';
import { createSelector } from 'reselect';
import { Request } from 'react-redux-request';
import { loadTransactionList } from '../../services/rest';
import { withInitialData } from './helpers';
import { loadTransactionList } from '../../services/rest/apm';
import { createInitialDataSelector } from './helpers';
const ID = 'transactionList';
const INITIAL_DATA = [];
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export const getTransactionList = createSelector(
state => withInitialData(state.reactReduxRequest[ID], INITIAL_DATA),
state => withInitialData(state.reactReduxRequest[ID]),
state => state.sorting.transaction,
(transactionList = {}, transactionSorting) => {
const { key: sortKey, descending } = transactionSorting;

View file

@ -0,0 +1,203 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`chartSelectors getResponseTimeSeries should match snapshot 1`] = `
Array [
Object {
"color": "#3185fc",
"data": Array [
Object {
"x": 0,
"y": 100,
},
Object {
"x": 1000,
"y": 200,
},
Object {
"x": 2000,
"y": 150,
},
Object {
"x": 3000,
"y": 250,
},
Object {
"x": 4000,
"y": 100,
},
Object {
"x": 5000,
"y": 50,
},
],
"legendValue": "0 ms",
"title": "Avg.",
"type": "line",
},
Object {
"color": "#ecae23",
"data": Array [
Object {
"x": 0,
"y": 200,
},
Object {
"x": 1000,
"y": 300,
},
Object {
"x": 2000,
"y": 250,
},
Object {
"x": 3000,
"y": 350,
},
Object {
"x": 4000,
"y": 200,
},
Object {
"x": 5000,
"y": 150,
},
],
"title": "95th percentile",
"titleShort": "95th",
"type": "line",
},
Object {
"color": "#f98510",
"data": Array [
Object {
"x": 0,
"y": 300,
},
Object {
"x": 1000,
"y": 400,
},
Object {
"x": 2000,
"y": 350,
},
Object {
"x": 3000,
"y": 450,
},
Object {
"x": 4000,
"y": 100,
},
Object {
"x": 5000,
"y": 50,
},
],
"title": "99th percentile",
"titleShort": "99th",
"type": "line",
},
]
`;
exports[`chartSelectors getTpmSeries should match snapshot 1`] = `
Array [
Object {
"color": "#00b3a4",
"data": Array [
Object {
"x": 0,
"y": 5,
},
Object {
"x": 1000,
"y": 10,
},
Object {
"x": 2000,
"y": 3,
},
Object {
"x": 3000,
"y": 8,
},
Object {
"x": 4000,
"y": 4,
},
Object {
"x": 5000,
"y": 9,
},
],
"legendValue": "10.0 tpm",
"title": "HTTP 2xx",
"type": "line",
},
Object {
"color": "#f98510",
"data": Array [
Object {
"x": 0,
"y": 1,
},
Object {
"x": 1000,
"y": 2,
},
Object {
"x": 2000,
"y": 3,
},
Object {
"x": 3000,
"y": 2,
},
Object {
"x": 4000,
"y": 3,
},
Object {
"x": 5000,
"y": 1,
},
],
"legendValue": "2.0 tpm",
"title": "HTTP 4xx",
"type": "line",
},
Object {
"color": "#db1374",
"data": Array [
Object {
"x": 0,
"y": 0,
},
Object {
"x": 1000,
"y": 1,
},
Object {
"x": 2000,
"y": 2,
},
Object {
"x": 3000,
"y": 1,
},
Object {
"x": 4000,
"y": 0,
},
Object {
"x": 5000,
"y": 2,
},
],
"legendValue": "1.0 tpm",
"title": "HTTP 5xx",
"type": "line",
},
]
`;

View file

@ -0,0 +1,137 @@
/*
* 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 {
getAnomalyScoreValues,
getResponseTimeSeries,
getTpmSeries,
getAnomalyBoundaryValues
} from '../chartSelectors';
import anomalyData from './mockData/anomalyData.json';
describe('chartSelectors', () => {
describe('getAnomalyScoreValues', () => {
it('should return anomaly score series', () => {
const dates = [0, 1000, 2000, 3000, 4000, 5000, 6000];
const buckets = [
{
anomalyScore: null
},
{
anomalyScore: 80
},
{
anomalyScore: 0
},
{
anomalyScore: 0
},
{
anomalyScore: 70
},
{
anomalyScore: 80
},
{
anomalyScore: 0
}
];
expect(getAnomalyScoreValues(dates, buckets, 1000)).toEqual([
{ x: 1000, y: 1 },
{ x: 2000, y: 1 },
{ x: 3000 },
{ x: 5000, y: 1 },
{ x: 6000, y: 1 },
{ x: 7000 }
]);
});
});
describe('getResponseTimeSeries', () => {
const chartsData = {
dates: [0, 1000, 2000, 3000, 4000, 5000],
responseTimes: {
avg: [100, 200, 150, 250, 100, 50],
p95: [200, 300, 250, 350, 200, 150],
p99: [300, 400, 350, 450, 100, 50],
avgAnomalies: {}
},
overallAvgDuration: 200
};
it('should match snapshot', () => {
expect(getResponseTimeSeries(chartsData)).toMatchSnapshot();
});
it('should return 3 series', () => {
expect(getResponseTimeSeries(chartsData).length).toBe(3);
});
});
describe('getTpmSeries', () => {
const chartsData = {
dates: [0, 1000, 2000, 3000, 4000, 5000],
tpmBuckets: [
{
key: 'HTTP 2xx',
avg: 10,
values: [5, 10, 3, 8, 4, 9]
},
{
key: 'HTTP 4xx',
avg: 2,
values: [1, 2, 3, 2, 3, 1]
},
{
key: 'HTTP 5xx',
avg: 1,
values: [0, 1, 2, 1, 0, 2]
}
]
};
const transactionType = 'MyTransactionType';
it('should match snapshot', () => {
expect(getTpmSeries(chartsData, transactionType)).toMatchSnapshot();
});
});
describe('getAnomalyBoundaryValues', () => {
const { dates, buckets } = anomalyData;
const bucketSpan = 240000;
it('should return correct buckets', () => {
expect(getAnomalyBoundaryValues(dates, buckets, bucketSpan)).toEqual([
{ x: 1530614880000, y: 54799, y0: 15669 },
{ x: 1530615060000, y: 49874, y0: 17808 },
{ x: 1530615300000, y: 49421, y0: 18012 },
{ x: 1530615540000, y: 49654, y0: 17889 },
{ x: 1530615780000, y: 50026, y0: 17713 },
{ x: 1530616020000, y: 49371, y0: 18044 },
{ x: 1530616260000, y: 50110, y0: 17713 },
{ x: 1530616500000, y: 50419, y0: 17582 },
{ x: 1530616620000, y: 50419, y0: 17582 }
]);
});
it('should extend the last bucket with a size of bucketSpan', () => {
const [lastBucket, secondLastBuckets] = getAnomalyBoundaryValues(
dates,
buckets,
bucketSpan
).reverse();
expect(secondLastBuckets.y).toBe(lastBucket.y);
expect(secondLastBuckets.y0).toBe(lastBucket.y0);
expect(lastBucket.x - secondLastBuckets.x).toBeLessThanOrEqual(
bucketSpan
);
});
});
});

View file

@ -0,0 +1,186 @@
{
"dates": [
1530614880000,
1530614940000,
1530615000000,
1530615060000,
1530615120000,
1530615180000,
1530615240000,
1530615300000,
1530615360000,
1530615420000,
1530615480000,
1530615540000,
1530615600000,
1530615660000,
1530615720000,
1530615780000,
1530615840000,
1530615900000,
1530615960000,
1530616020000,
1530616080000,
1530616140000,
1530616200000,
1530616260000,
1530616320000,
1530616380000,
1530616440000,
1530616500000,
1530616560000,
1530616620000
],
"buckets": [
{
"anomalyScore": null,
"lower": 15669,
"upper": 54799
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": 0,
"lower": 17808,
"upper": 49874
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": 0,
"lower": 18012,
"upper": 49421
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": 0,
"lower": 17889,
"upper": 49654
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": 0,
"lower": 17713,
"upper": 50026
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": 0,
"lower": 18044,
"upper": 49371
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": 0,
"lower": 17713,
"upper": 50110
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": 0,
"lower": 17582,
"upper": 50419
},
{
"anomalyScore": null,
"lower": null,
"upper": null
},
{
"anomalyScore": null,
"lower": null,
"upper": null
}
]
}

View file

@ -5,7 +5,7 @@
*/
import d3 from 'd3';
import { zipObject, difference, memoize } from 'lodash';
import { last, zipObject, difference, memoize, get, isEmpty } from 'lodash';
import { colors } from '../../style/variables';
import {
asMillisWithDefault,
@ -51,49 +51,78 @@ export function getCharts(urlParams, charts) {
}
export function getResponseTimeSeries(chartsData) {
const { dates, weightedAverage } = chartsData;
const { avg, p95, p99 } = chartsData.responseTimes;
const { dates, overallAvgDuration } = chartsData;
const { avg, p95, p99, avgAnomalies } = chartsData.responseTimes;
return [
const series = [
{
title: 'Avg.',
data: getChartValues(dates, avg),
legendValue: `${asMillisWithDefault(weightedAverage)}`,
type: 'area',
color: colors.apmBlue,
areaColor: 'rgba(49, 133, 252, 0.1)' // apmBlue
legendValue: `${asMillisWithDefault(overallAvgDuration)}`,
type: 'line',
color: colors.apmBlue
},
{
title: '95th percentile',
titleShort: '95th',
data: getChartValues(dates, p95),
type: 'area',
color: colors.apmYellow,
areaColor: 'rgba(236, 174, 35, 0.1)' // apmYellow
type: 'line',
color: colors.apmYellow
},
{
title: '99th percentile',
titleShort: '99th',
data: getChartValues(dates, p99),
type: 'area',
color: colors.apmOrange,
areaColor: 'rgba(249, 133, 16, 0.1)' // apmOrange
type: 'line',
color: colors.apmOrange
}
];
}
function getTpmLegendTitle(bucketKey) {
// hide legend text for transactions without "result"
if (bucketKey === 'transaction_result_missing') {
return '';
if (!isEmpty(avgAnomalies.buckets)) {
// insert after Avg. serie
series.splice(1, 0, {
title: 'Anomaly Boundaries',
hideLegend: true,
hideTooltipValue: true,
data: getAnomalyBoundaryValues(
dates,
avgAnomalies.buckets,
avgAnomalies.bucketSpanAsMillis
),
type: 'area',
color: 'none',
areaColor: 'rgba(49, 133, 252, 0.1)' // apmBlue
});
series.splice(1, 0, {
title: 'Anomaly score',
hideLegend: true,
hideTooltipValue: true,
data: getAnomalyScoreValues(
dates,
avgAnomalies.buckets,
avgAnomalies.bucketSpanAsMillis
),
type: 'areaMaxHeight',
color: 'none',
areaColor: 'rgba(146, 0, 0, 0.1)' // apmRed
});
}
return bucketKey;
return series;
}
function getTpmSeries(chartsData, transactionType) {
export function getTpmSeries(chartsData, transactionType) {
const { dates, tpmBuckets } = chartsData;
const getColor = getColorByKey(tpmBuckets.map(({ key }) => key));
const getTpmLegendTitle = bucketKey => {
// hide legend text for transactions without "result"
if (bucketKey === 'transaction_result_missing') {
return '';
}
return bucketKey;
};
return tpmBuckets.map(bucket => {
return {
@ -127,9 +156,73 @@ function getColorByKey(keys) {
return key => assignedColors[key] || unassignedColors[key];
}
function getChartValues(dates = [], yValues = []) {
function getChartValues(dates = [], buckets = []) {
return dates.map((x, i) => ({
x,
y: yValues[i]
y: buckets[i]
}));
}
export function getAnomalyScoreValues(
dates = [],
buckets = [],
bucketSpanAsMillis
) {
const ANOMALY_THRESHOLD = 75;
const getX = (currentX, i) => currentX + bucketSpanAsMillis * i;
return dates
.map((x, i) => {
const { anomalyScore } = buckets[i] || {};
return {
x,
anomalyScore
};
})
.filter(p => p.anomalyScore > ANOMALY_THRESHOLD)
.reduce((acc, p, i, points) => {
acc.push({ x: p.x, y: 1 });
const { x: nextX } = points[i + 1] || {};
const endX = getX(p.x, 1);
if (nextX == null || nextX > endX) {
acc.push(
{
x: endX,
y: 1
},
{
x: getX(p.x, 2)
}
);
}
return acc;
}, []);
}
export function getAnomalyBoundaryValues(
dates = [],
buckets = [],
bucketSpanAsMillis
) {
const lastX = last(dates);
return dates
.map((x, i) => ({
x,
y0: get(buckets[i], 'lower'),
y: get(buckets[i], 'upper')
}))
.filter(point => point.y != null)
.reduce((acc, p, i, points) => {
const isLast = last(points) === p;
acc.push(p);
if (isLast) {
acc.push({
...p,
x: Math.min(p.x + bucketSpanAsMillis, lastX) // avoid going beyond the last date
});
}
return acc;
}, []);
}

View file

@ -13,10 +13,85 @@ import {
toQuery,
fromQuery,
KibanaLinkComponent,
RelativeLinkComponent
RelativeLinkComponent,
encodeKibanaSearchParams,
decodeKibanaSearchParams
} from '../url';
import { toJson } from '../testHelpers';
describe('encodeKibanaSearchParams and decodeKibanaSearchParams should return the original string', () => {
it('should convert string to object', () => {
const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))`;
const nextSearch = encodeKibanaSearchParams(
decodeKibanaSearchParams(search)
);
expect(search).toBe(`?${nextSearch}`);
});
});
describe('decodeKibanaSearchParams', () => {
it('when both _a and _g are defined', () => {
const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))`;
const query = decodeKibanaSearchParams(search);
expect(query).toEqual({
_a: {
filters: [],
mlSelectInterval: { interval: { display: 'Auto', val: 'auto' } },
mlSelectSeverity: { threshold: { display: 'warning', val: 0 } },
mlTimeSeriesExplorer: {},
query: { query_string: { analyze_wildcard: true, query: '*' } }
},
_g: {
ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] },
refreshInterval: { display: 'Off', pause: false, value: 0 },
time: {
from: '2018-06-06T08:20:45.437Z',
mode: 'absolute',
to: '2018-06-14T21:56:58.505Z'
}
}
});
});
it('when only _g is defined', () => {
const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)))`;
const query = decodeKibanaSearchParams(search);
expect(query).toEqual({
_a: null,
_g: {
ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] }
}
});
});
});
describe('encodeKibanaSearchParams', () => {
it('should convert object to string', () => {
const query = {
_a: {
filters: [],
mlSelectInterval: { interval: { display: 'Auto', val: 'auto' } },
mlSelectSeverity: { threshold: { display: 'warning', val: 0 } },
mlTimeSeriesExplorer: {},
query: { query_string: { analyze_wildcard: true, query: '*' } }
},
_g: {
ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] },
refreshInterval: { display: 'Off', pause: false, value: 0 },
time: {
from: '2018-06-06T08:20:45.437Z',
mode: 'absolute',
to: '2018-06-14T21:56:58.505Z'
}
}
};
const search = encodeKibanaSearchParams(query);
expect(search).toBe(
`_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))`
);
});
});
describe('toQuery', () => {
it('should parse string to object', () => {
expect(toQuery('?foo=bar&name=john%20doe')).toEqual({

View file

@ -23,47 +23,49 @@ const waitForAngularReady = new Promise(resolve => {
}, 10);
});
export function initTimepicker(history, dispatch, callback) {
// default the timepicker to the last 24 hours
chrome.getUiSettingsClient().overrideLocalDefault(
'timepicker:timeDefaults',
JSON.stringify({
from: 'now-24h',
to: 'now',
mode: 'quick'
})
);
export function initTimepicker(history, dispatch) {
return new Promise(resolve => {
// default the timepicker to the last 24 hours
chrome.getUiSettingsClient().overrideLocalDefault(
'timepicker:timeDefaults',
JSON.stringify({
from: 'now-24h',
to: 'now',
mode: 'quick'
})
);
uiModules
.get('app/apm', [])
.controller('TimePickerController', ($scope, globalState) => {
// Add APM feedback menu
// TODO: move this somewhere else
$scope.topNavMenu = [];
$scope.topNavMenu.push({
key: 'APM feedback',
description: 'APM feedback',
tooltip: 'Provide feedback on APM',
template: require('../../templates/feedback_menu.html')
});
uiModules
.get('app/apm', [])
.controller('TimePickerController', ($scope, globalState) => {
// Add APM feedback menu
// TODO: move this somewhere else
$scope.topNavMenu = [];
$scope.topNavMenu.push({
key: 'APM feedback',
description: 'APM feedback',
tooltip: 'Provide feedback on APM',
template: require('../../templates/feedback_menu.html')
});
history.listen(() => {
updateRefreshRate(dispatch);
globalState.fetch();
});
timefilter.enableTimeRangeSelector();
timefilter.enableAutoRefreshSelector();
history.listen(() => {
updateRefreshRate(dispatch);
globalState.fetch();
$scope.$listen(timefilter, 'timeUpdate', () =>
dispatch(updateTimePickerAction())
);
registerTimefilterWithGlobalState(globalState);
Promise.all([waitForAngularReady]).then(resolve);
});
timefilter.enableTimeRangeSelector();
timefilter.enableAutoRefreshSelector();
updateRefreshRate(dispatch);
$scope.$listen(timefilter, 'timeUpdate', () =>
dispatch(updateTimePickerAction())
);
registerTimefilterWithGlobalState(globalState);
Promise.all([waitForAngularReady]).then(callback);
});
});
}
function updateTimePickerAction() {

View file

@ -16,6 +16,21 @@ import { EuiLink } from '@elastic/eui';
import createHistory from 'history/createHashHistory';
import chrome from 'ui/chrome';
export function getMlJobUrl(serviceName, transactionType, location) {
const { _g, _a } = decodeKibanaSearchParams(location.search);
const nextSearch = encodeKibanaSearchParams({
_g: {
..._g,
ml: {
jobIds: [`${serviceName}-${transactionType}-high_mean_response_time`]
}
},
_a
});
return `/app/ml#/timeseriesexplorer/?${nextSearch}`;
}
export function toQuery(search) {
return qs.parse(search.slice(1));
}
@ -40,6 +55,21 @@ function stringifyWithoutEncoding(query) {
});
}
export function decodeKibanaSearchParams(search) {
const query = toQuery(search);
return {
_g: query._g ? rison.decode(query._g) : null,
_a: query._a ? rison.decode(query._a) : null
};
}
export function encodeKibanaSearchParams(query) {
return stringifyWithoutEncoding({
_g: rison.encode(query._g),
_a: rison.encode(query._a)
});
}
export function RelativeLinkComponent({
location,
path,
@ -77,7 +107,7 @@ export function KibanaLinkComponent({
location,
pathname,
hash,
query,
query = {},
...props
}) {
const currentQuery = toQuery(location.search);
@ -85,6 +115,7 @@ export function KibanaLinkComponent({
_g: query._g ? rison.encode(query._g) : currentQuery._g,
_a: query._a ? rison.encode(query._a) : ''
};
const search = stringifyWithoutEncoding(nextQuery);
const href = url.format({
pathname: chrome.addBasePath(pathname),

View file

@ -26,6 +26,7 @@ export function setupRequest(req, reply) {
req.query
)}`
);
console.log(`GET ${params.index}/_search`);
console.log(JSON.stringify(params.body, null, 4));
}
return cluster.callWithRequest(req, type, params);

View file

@ -6,20 +6,25 @@
import _ from 'lodash';
import { getTimeseriesData } from '../get_timeseries_data';
import elasticSearchResponse from './es_response.json';
import timeseriesResponse from './timeseries_response.json';
import responseTimeAnomalyResponse from './response_time_anomaly_response.json';
describe('get_timeseries_data', () => {
let res;
let clientSpy;
beforeEach(async () => {
clientSpy = jest.fn(() => Promise.resolve(elasticSearchResponse));
clientSpy = jest
.fn()
.mockResolvedValueOnce(timeseriesResponse)
.mockResolvedValueOnce(responseTimeAnomalyResponse);
res = await getTimeseriesData({
serviceName: 'myServiceName',
transactionType: 'myTransactionType',
transactionName: 'myTransactionName',
transactionName: null,
setup: {
start: 1523307608624,
end: 1523350808624,
start: 1528113600000,
end: 1528977600000,
client: clientSpy,
config: {
get: () => 'myIndex'
@ -33,18 +38,19 @@ describe('get_timeseries_data', () => {
});
it('should not contain first and last bucket', () => {
const esResponseDates = elasticSearchResponse.aggregations.transaction_results.buckets[0].timeseries.buckets.map(
const mockDates = timeseriesResponse.aggregations.transaction_results.buckets[0].timeseries.buckets.map(
bucket => bucket.key
);
expect(res.dates).not.toContain(_.first(esResponseDates));
expect(res.dates).not.toContain(_.last(esResponseDates));
expect(res.dates).not.toContain(_.first(mockDates));
expect(res.dates).not.toContain(_.last(mockDates));
expect(res.tpm_buckets[0].values).toHaveLength(res.dates.length);
});
it('should have correct order', () => {
expect(res.tpm_buckets.map(bucket => bucket.key)).toEqual([
'HTTP 2xx',
'HTTP 3xx',
'HTTP 4xx',
'HTTP 5xx',
'A Custom Bucket (that should be last)'

View file

@ -0,0 +1,118 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`get_buckets_with_initial_anomaly_bounds should return correct buckets 1`] = `
Array [
Object {
"anomaly_score": Object {
"value": null,
},
"doc_count": 0,
"key": 1530523000000,
"key_as_string": "2018-07-02T09:16:40.000Z",
"lower": Object {
"value": 17688.182675688193,
},
"upper": Object {
"value": 50381.01051622894,
},
},
Object {
"anomaly_score": Object {
"value": null,
},
"doc_count": 4,
"key": 1530523500000,
"key_as_string": "2018-07-02T09:25:00.000Z",
"lower": Object {
"value": null,
},
"upper": Object {
"value": null,
},
},
Object {
"anomaly_score": Object {
"value": null,
},
"doc_count": 0,
"key": 1530524000000,
"key_as_string": "2018-07-02T09:33:20.000Z",
"lower": Object {
"value": null,
},
"upper": Object {
"value": null,
},
},
Object {
"anomaly_score": Object {
"value": 0,
},
"doc_count": 2,
"key": 1530524500000,
"key_as_string": "2018-07-02T09:41:40.000Z",
"lower": Object {
"value": 16034.081569306454,
},
"upper": Object {
"value": 54158.77731018045,
},
},
Object {
"anomaly_score": Object {
"value": null,
},
"doc_count": 0,
"key": 1530525000000,
"key_as_string": "2018-07-02T09:50:00.000Z",
"lower": Object {
"value": null,
},
"upper": Object {
"value": null,
},
},
Object {
"anomaly_score": Object {
"value": 0,
},
"doc_count": 2,
"key": 1530525500000,
"key_as_string": "2018-07-02T09:58:20.000Z",
"lower": Object {
"value": 16034.081569306454,
},
"upper": Object {
"value": 54158.77731018045,
},
},
Object {
"anomaly_score": Object {
"value": null,
},
"doc_count": 0,
"key": 1530526000000,
"key_as_string": "2018-07-02T10:06:40.000Z",
"lower": Object {
"value": null,
},
"upper": Object {
"value": null,
},
},
Object {
"anomaly_score": Object {
"value": 0,
},
"doc_count": 2,
"key": 1530526500000,
"key_as_string": "2018-07-02T10:15:00.000Z",
"lower": Object {
"value": 16034.081569306454,
},
"upper": Object {
"value": 54158.77731018045,
},
},
]
`;

View file

@ -0,0 +1,55 @@
/*
* 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 { getAvgResponseTimeAnomalies } from '../get_avg_response_time_anomalies';
import mainBucketsResponse from './mockData/mainBucketsResponse';
import firstBucketsResponse from './mockData/firstBucketsResponse';
describe('get_avg_response_time_anomalies', () => {
it('', async () => {
const clientSpy = jest
.fn()
.mockResolvedValueOnce(mainBucketsResponse)
.mockResolvedValueOnce(firstBucketsResponse);
const avgAnomalies = await getAvgResponseTimeAnomalies({
serviceName: 'myServiceName',
transactionType: 'myTransactionType',
setup: {
start: 1528113600000,
end: 1528977600000,
client: clientSpy,
config: {
get: () => 'myIndex'
}
}
});
expect(avgAnomalies).toEqual({
bucketSpanAsMillis: 10800000,
buckets: [
{
anomaly_score: null,
lower: 17688.182675688193,
upper: 50381.01051622894
},
{ anomaly_score: null, lower: null, upper: null },
{
anomaly_score: 0,
lower: 16034.081569306454,
upper: 54158.77731018045
},
{ anomaly_score: null, lower: null, upper: null },
{
anomaly_score: 0,
lower: 16034.081569306454,
upper: 54158.77731018045
},
{ anomaly_score: null, lower: null, upper: null }
]
});
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 { getBucketWithInitialAnomalyBounds } from '../get_buckets_with_initial_anomaly_bounds';
import mainBucketsResponse from './mockData/mainBucketsResponse';
import firstBucketsResponse from './mockData/firstBucketsResponse';
describe('get_buckets_with_initial_anomaly_bounds', () => {
const mainBuckets =
mainBucketsResponse.aggregations.ml_avg_response_times.buckets;
let buckets;
beforeEach(async () => {
buckets = await getBucketWithInitialAnomalyBounds({
serviceName: 'myServiceName',
transactionType: 'myTransactionType',
intervalString: '60s',
start: 1530523322742,
client: () => firstBucketsResponse,
mainBuckets,
anomalyBucketSpan: 900
});
});
it('should return correct buckets', () => {
expect(buckets).toMatchSnapshot();
});
it('should not change the number of buckets', () => {
expect(mainBuckets.length).toEqual(buckets.length);
});
it('should replace the first bucket but leave all other buckets the same', () => {
buckets.forEach((bucket, i) => {
if (i === 0) {
expect(mainBuckets[0]).not.toEqual(bucket);
} else {
expect(mainBuckets[i]).toBe(bucket);
}
});
});
});

View file

@ -0,0 +1,83 @@
{
"took": 22,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 2,
"max_score": 0,
"hits": []
},
"aggregations": {
"ml_avg_response_times": {
"buckets": [
{
"key_as_string": "2018-07-02T09:00:00.000Z",
"key": 1530522000000,
"doc_count": 0,
"anomaly_score": {
"value": null
},
"upper": {
"value": null
},
"lower": {
"value": null
}
},
{
"key_as_string": "2018-07-02T09:08:20.000Z",
"key": 1530522500000,
"doc_count": 2,
"anomaly_score": {
"value": 0
},
"upper": {
"value": 50381.01051622894
},
"lower": {
"value": 17688.182675688193
}
},
{
"key_as_string": "2018-07-02T09:16:40.000Z",
"key": 1530523000000,
"doc_count": 0,
"anomaly_score": {
"value": null
},
"upper": {
"value": null
},
"lower": {
"value": null
}
}
]
},
"top_hits": {
"hits": {
"total": 2,
"max_score": null,
"hits": [
{
"_index": ".ml-anomalies-shared",
"_type": "doc",
"_id": "opbeans-node-request-high_mean_response_time_model_plot_1530522900000_900_0_29791_0",
"_score": null,
"_source": {
"bucket_span": 900
},
"sort": [
900
]
}
]
}
}
}
}

View file

@ -0,0 +1,152 @@
{
"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": 1530523000000,
"doc_count": 0,
"anomaly_score": {
"value": null
},
"upper": {
"value": null
},
"lower": {
"value": null
}
},
{
"key_as_string": "2018-07-02T09:25:00.000Z",
"key": 1530523500000,
"doc_count": 4,
"anomaly_score": {
"value": null
},
"upper": {
"value": null
},
"lower": {
"value": null
}
},
{
"key_as_string": "2018-07-02T09:33:20.000Z",
"key": 1530524000000,
"doc_count": 0,
"anomaly_score": {
"value": null
},
"upper": {
"value": null
},
"lower": {
"value": null
}
},
{
"key_as_string": "2018-07-02T09:41:40.000Z",
"key": 1530524500000,
"doc_count": 2,
"anomaly_score": {
"value": 0
},
"upper": {
"value": 54158.77731018045
},
"lower": {
"value": 16034.081569306454
}
},
{
"key_as_string": "2018-07-02T09:50:00.000Z",
"key": 1530525000000,
"doc_count": 0,
"anomaly_score": {
"value": null
},
"upper": {
"value": null
},
"lower": {
"value": null
}
},
{
"key_as_string": "2018-07-02T09:58:20.000Z",
"key": 1530525500000,
"doc_count": 2,
"anomaly_score": {
"value": 0
},
"upper": {
"value": 54158.77731018045
},
"lower": {
"value": 16034.081569306454
}
},
{
"key_as_string": "2018-07-02T10:06:40.000Z",
"key": 1530526000000,
"doc_count": 0,
"anomaly_score": {
"value": null
},
"upper": {
"value": null
},
"lower": {
"value": null
}
},
{
"key_as_string": "2018-07-02T10:15:00.000Z",
"key": 1530526500000,
"doc_count": 2,
"anomaly_score": {
"value": 0
},
"upper": {
"value": 54158.77731018045
},
"lower": {
"value": 16034.081569306454
}
}
]
},
"top_hits": {
"hits": {
"total": 2,
"max_score": null,
"hits": [
{
"_index": ".ml-anomalies-shared",
"_type": "doc",
"_id":
"opbeans-node-request-high_mean_response_time_model_plot_1530522900000_900_0_29791_0",
"_score": null,
"_source": {
"bucket_span": 900
},
"sort": [900]
}
]
}
}
}
}

View file

@ -0,0 +1,71 @@
/*
* 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 async function getAnomalyAggs({
serviceName,
transactionType,
intervalString,
client,
start,
end
}) {
const params = {
index: `.ml-anomalies-${serviceName}-${transactionType}-high_mean_response_time`.toLowerCase(),
body: {
size: 0,
query: {
bool: {
filter: [
{
range: {
timestamp: {
gte: start,
lte: end,
format: 'epoch_millis'
}
}
}
]
}
},
aggs: {
top_hits: {
top_hits: {
sort: ['bucket_span'],
_source: { includes: ['bucket_span'] },
size: 1
}
},
ml_avg_response_times: {
date_histogram: {
field: 'timestamp',
interval: intervalString,
min_doc_count: 0,
extended_bounds: {
min: start,
max: end
}
},
aggs: {
anomaly_score: { max: { field: 'anomaly_score' } },
lower: { min: { field: 'model_lower' } },
upper: { max: { field: 'model_upper' } }
}
}
}
}
};
try {
const resp = await client('search', params);
return resp.aggregations;
} catch (e) {
if (e.statusCode === 404) {
return null;
}
throw e;
}
}

View file

@ -0,0 +1,74 @@
/*
* 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 { get } from 'lodash';
import { getBucketSize } from '../../../helpers/get_bucket_size';
import { getAnomalyAggs } from './get_anomaly_aggs';
import { getBucketWithInitialAnomalyBounds } from './get_buckets_with_initial_anomaly_bounds';
export async function getAvgResponseTimeAnomalies({
serviceName,
transactionType,
transactionName,
setup
}) {
const { start, end, client } = setup;
const { intervalString, bucketSize } = getBucketSize(start, end, 'auto');
// don't fetch anomalies for transaction details page
if (transactionName) {
return [];
}
const aggs = await getAnomalyAggs({
serviceName,
transactionType,
intervalString,
client,
start,
end
});
if (!aggs) {
return {
message: 'ml index does not exist'
};
}
const anomalyBucketSpan = get(
aggs,
'top_hits.hits.hits[0]._source.bucket_span'
);
const mainBuckets = get(aggs, 'ml_avg_response_times.buckets', []).slice(
1,
-1
);
const bucketsWithInitialAnomalyBounds = await getBucketWithInitialAnomalyBounds(
{
serviceName,
transactionType,
client,
start,
mainBuckets,
anomalyBucketSpan
}
);
const buckets = bucketsWithInitialAnomalyBounds.map(bucket => {
return {
anomaly_score: bucket.anomaly_score.value,
lower: bucket.lower.value,
upper: bucket.upper.value
};
});
return {
bucketSpanAsMillis: Math.max(bucketSize, anomalyBucketSpan) * 1000,
buckets
};
}

View file

@ -0,0 +1,52 @@
/*
* 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 { last, get } from 'lodash';
import { getAnomalyAggs } from './get_anomaly_aggs';
export async function getBucketWithInitialAnomalyBounds({
serviceName,
transactionType,
client,
start,
mainBuckets,
anomalyBucketSpan
}) {
// abort if first bucket already has values for initial anomaly bounds
if (mainBuckets[0].lower.value || !anomalyBucketSpan) {
return mainBuckets;
}
const newStart = start - anomalyBucketSpan * 1000;
const newEnd = start;
const aggs = await getAnomalyAggs({
serviceName,
transactionType,
intervalString: `${anomalyBucketSpan}s`,
client,
start: newStart,
end: newEnd
});
const firstBucketWithBounds = last(
get(aggs, 'ml_avg_response_times.buckets', []).filter(
bucket => bucket.lower.value
)
);
return mainBuckets.map((bucket, i) => {
// replace first item
if (i === 0 && firstBucketWithBounds) {
return {
...bucket,
upper: { value: firstBucketWithBounds.upper.value },
lower: { value: firstBucketWithBounds.lower.value }
};
}
return bucket;
});
}

View file

@ -14,6 +14,7 @@ import {
import { get, sortBy, round } from 'lodash';
import mean from 'lodash.mean';
import { getBucketSize } from '../../helpers/get_bucket_size';
import { getAvgResponseTimeAnomalies } from './get_avg_response_time_anomalies/get_avg_response_time_anomalies';
export async function getTimeseriesData({
serviceName,
@ -150,15 +151,23 @@ export async function getTimeseriesData({
bucket => bucket.key.replace(/^HTTP (\d)xx$/, '00$1') // ensure that HTTP 3xx are sorted at the top
);
const avgResponseTimesAnomalies = await getAvgResponseTimeAnomalies({
serviceName,
transactionType,
transactionName,
setup
});
return {
total_hits: resp.hits.total,
dates: dates,
response_times: {
avg: responseTime.avg,
p95: responseTime.p95,
p99: responseTime.p99
p99: responseTime.p99,
avg_anomalies: avgResponseTimesAnomalies
},
tpm_buckets: tpmBuckets,
weighted_average: overallAvgDuration || 0
overall_avg_duration: overallAvgDuration || 0
};
}