[8.0] [APM] Display relevant anomalies (#119709) (#120112)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dario Gieselaar 2021-12-02 08:32:40 +01:00 committed by GitHub
parent b9ebc5f617
commit 563d7935d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1537 additions and 572 deletions

View file

@ -60,6 +60,11 @@ export class BaseSpan extends Serializable<ApmFields> {
return this;
}
outcome(outcome: 'success' | 'failure' | 'unknown') {
this.fields['event.outcome'] = outcome;
return this;
}
serialize(): ApmFields[] {
return [this.fields, ...this._children.flatMap((child) => child.serialize())];
}

View file

@ -66,6 +66,7 @@ export function getTransactionMetrics(events: ApmFields[]) {
return {
...metricset.key,
'metricset.name': 'transaction',
'transaction.duration.histogram': sortAndCompressHistogram(histogram),
_doc_count: metricset.events.length,
};

View file

@ -1,27 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ApmMlDetectorIndex } from './apm_ml_detectors';
export function apmMlAnomalyQuery(detectorIndex: ApmMlDetectorIndex) {
return [
{
bool: {
filter: [
{
terms: {
result_type: ['model_plot', 'record'],
},
},
{
term: { detector_index: detectorIndex },
},
],
},
},
];
}

View file

@ -5,8 +5,28 @@
* 2.0.
*/
export const enum ApmMlDetectorIndex {
txLatency = 0,
txThroughput = 1,
txFailureRate = 2,
export const enum ApmMlDetectorType {
txLatency = 'txLatency',
txThroughput = 'txThroughput',
txFailureRate = 'txFailureRate',
}
const detectorIndices = {
[ApmMlDetectorType.txLatency]: 0,
[ApmMlDetectorType.txThroughput]: 1,
[ApmMlDetectorType.txFailureRate]: 2,
};
export function getApmMlDetectorIndex(type: ApmMlDetectorType) {
return detectorIndices[type];
}
export function getApmMlDetectorType(detectorIndex: number) {
let type: ApmMlDetectorType;
for (type in detectorIndices) {
if (detectorIndices[type] === detectorIndex) {
return type;
}
}
throw new Error('Could not map detector index to type');
}

View file

@ -0,0 +1,173 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import uuid from 'uuid';
import { ENVIRONMENT_ALL } from '../environment_filter_values';
import { Environment } from '../environment_rt';
import { ApmMlDetectorType } from './apm_ml_detectors';
import { getPreferredServiceAnomalyTimeseries } from './get_preferred_service_anomaly_timeseries';
import { ServiceAnomalyTimeseries } from './service_anomaly_timeseries';
const PROD = 'production' as Environment;
const DEV = 'development' as Environment;
function createMockAnomalyTimeseries({
type,
environment = PROD,
version = 3,
}: {
type: ApmMlDetectorType;
environment?: Environment;
version?: number;
}): ServiceAnomalyTimeseries {
return {
anomalies: [],
bounds: [],
environment,
jobId: uuid(),
type,
serviceName: 'opbeans-java',
transactionType: 'request',
version,
};
}
describe('getPreferredServiceAnomalyTimeseries', () => {
describe('with a wide set of series', () => {
const allAnomalyTimeseries = [
createMockAnomalyTimeseries({
type: ApmMlDetectorType.txLatency,
environment: PROD,
}),
createMockAnomalyTimeseries({
type: ApmMlDetectorType.txLatency,
environment: DEV,
}),
createMockAnomalyTimeseries({
type: ApmMlDetectorType.txThroughput,
environment: PROD,
}),
createMockAnomalyTimeseries({
type: ApmMlDetectorType.txFailureRate,
environment: PROD,
}),
createMockAnomalyTimeseries({
type: ApmMlDetectorType.txFailureRate,
environment: PROD,
version: 2,
}),
];
describe('with one environment', () => {
const environments = [PROD];
describe('and all being selected', () => {
const environment = ENVIRONMENT_ALL.value;
it('returns the series for prod', () => {
expect(
getPreferredServiceAnomalyTimeseries({
allAnomalyTimeseries,
detectorType: ApmMlDetectorType.txLatency,
environment,
environments,
fallbackToTransactions: false,
})?.environment
).toBe(PROD);
});
});
});
describe('with multiple environments', () => {
const environments = [PROD, DEV];
describe('and all being selected', () => {
const environment = ENVIRONMENT_ALL.value;
it('returns no series', () => {
expect(
getPreferredServiceAnomalyTimeseries({
allAnomalyTimeseries,
detectorType: ApmMlDetectorType.txLatency,
environment,
environments,
fallbackToTransactions: false,
})
).toBeUndefined();
expect(
getPreferredServiceAnomalyTimeseries({
allAnomalyTimeseries,
detectorType: ApmMlDetectorType.txLatency,
environment,
environments,
fallbackToTransactions: true,
})
).toBeUndefined();
});
});
describe('and production being selected', () => {
const environment = PROD;
it('returns the series for production', () => {
const series = getPreferredServiceAnomalyTimeseries({
allAnomalyTimeseries,
detectorType: ApmMlDetectorType.txFailureRate,
environment,
environments,
fallbackToTransactions: false,
});
expect(series).toBeDefined();
expect(series?.environment).toBe(PROD);
});
});
});
});
describe('with multiple versions', () => {
const allAnomalyTimeseries = [
createMockAnomalyTimeseries({
type: ApmMlDetectorType.txLatency,
environment: PROD,
version: 3,
}),
createMockAnomalyTimeseries({
type: ApmMlDetectorType.txLatency,
environment: PROD,
version: 2,
}),
];
const environments = [PROD];
const environment = ENVIRONMENT_ALL.value;
it('selects the most recent version when transaction metrics are being used', () => {
const series = getPreferredServiceAnomalyTimeseries({
allAnomalyTimeseries,
detectorType: ApmMlDetectorType.txLatency,
environment,
environments,
fallbackToTransactions: false,
});
expect(series?.version).toBe(3);
});
it('selects the legacy version when transaction metrics are being used', () => {
const series = getPreferredServiceAnomalyTimeseries({
allAnomalyTimeseries,
detectorType: ApmMlDetectorType.txLatency,
environment,
environments,
fallbackToTransactions: true,
});
expect(series?.version).toBe(2);
});
});
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ENVIRONMENT_ALL } from '../environment_filter_values';
import { Environment } from '../environment_rt';
import { ApmMlDetectorType } from './apm_ml_detectors';
import { ServiceAnomalyTimeseries } from './service_anomaly_timeseries';
export function getPreferredServiceAnomalyTimeseries({
environment,
environments,
detectorType,
allAnomalyTimeseries,
fallbackToTransactions,
}: {
environment: Environment;
environments: Environment[];
detectorType: ApmMlDetectorType;
allAnomalyTimeseries: ServiceAnomalyTimeseries[];
fallbackToTransactions: boolean;
}) {
const seriesForType = allAnomalyTimeseries.filter(
(serie) => serie.type === detectorType
);
const preferredEnvironment =
environment === ENVIRONMENT_ALL.value && environments.length === 1
? environments[0]
: environment;
return seriesForType.find(
(serie) =>
serie.environment === preferredEnvironment &&
(fallbackToTransactions ? serie.version <= 2 : serie.version >= 3)
);
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Coordinate } from '../../typings/timeseries';
import { ApmMlDetectorType } from './apm_ml_detectors';
export interface ServiceAnomalyTimeseries {
jobId: string;
type: ApmMlDetectorType;
environment: string;
serviceName: string;
version: number;
transactionType: string;
anomalies: Array<Coordinate & { actual: number | null }>;
bounds: Array<{ x: number; y0: number | null; y1: number | null }>;
}

View file

@ -184,10 +184,7 @@ export function ErrorGroupOverview() {
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem>
<FailedTransactionRateChart
kuery={kuery}
environment={environment}
/>
<FailedTransactionRateChart kuery={kuery} />
</EuiFlexItem>
</ChartPointerEventContextProvider>
</EuiFlexGroup>

View file

@ -23,7 +23,6 @@ import { ServiceOverviewInstancesChartAndTable } from './service_overview_instan
import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart';
import { TransactionsTable } from '../../shared/transactions_table';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useTimeRange } from '../../../hooks/use_time_range';
@ -36,7 +35,8 @@ import { replace } from '../../shared/Links/url_helpers';
export const chartHeight = 288;
export function ServiceOverview() {
const { agentName, serviceName, transactionType } = useApmServiceContext();
const { agentName, serviceName, transactionType, fallbackToTransactions } =
useApmServiceContext();
const {
query,
query: {
@ -47,9 +47,7 @@ export function ServiceOverview() {
transactionType: transactionTypeFromUrl,
},
} = useApmParams('/services/{serviceName}/overview');
const { fallbackToTransactions } = useFallbackToTransactionsFetcher({
kuery,
});
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const history = useHistory();
@ -96,11 +94,7 @@ export function ServiceOverview() {
)}
<EuiFlexItem>
<EuiPanel hasBorder={true}>
<LatencyChart
height={latencyChartHeight}
environment={environment}
kuery={kuery}
/>
<LatencyChart height={latencyChartHeight} kuery={kuery} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem>
@ -112,7 +106,6 @@ export function ServiceOverview() {
<EuiFlexItem grow={3}>
<ServiceOverviewThroughputChart
height={nonLatencyChartHeight}
environment={environment}
kuery={kuery}
/>
</EuiFlexItem>
@ -142,7 +135,6 @@ export function ServiceOverview() {
height={nonLatencyChartHeight}
showAnnotations={false}
kuery={kuery}
environment={environment}
/>
</EuiFlexItem>
)}

View file

@ -14,11 +14,14 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ApmMlDetectorType } from '../../../../common/anomaly_detection/apm_ml_detectors';
import { asExactTransactionRate } from '../../../../common/utils/formatters';
import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
import { useEnvironmentsContext } from '../../../context/environments_context/use_environments_context';
import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useFetcher } from '../../../hooks/use_fetcher';
import { usePreferredServiceAnomalyTimeseries } from '../../../hooks/use_preferred_service_anomaly_timeseries';
import { useTheme } from '../../../hooks/use_theme';
import { useTimeRange } from '../../../hooks/use_time_range';
import { TimeseriesChart } from '../../shared/charts/timeseries_chart';
@ -34,12 +37,10 @@ const INITIAL_STATE = {
export function ServiceOverviewThroughputChart({
height,
environment,
kuery,
transactionName,
}: {
height?: number;
environment: string;
kuery: string;
transactionName?: string;
}) {
@ -53,6 +54,12 @@ export function ServiceOverviewThroughputChart({
query: { rangeFrom, rangeTo },
} = useApmParams('/services/{serviceName}');
const { environment } = useEnvironmentsContext();
const preferredAnomalyTimeseries = usePreferredServiceAnomalyTimeseries(
ApmMlDetectorType.txThroughput
);
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { transactionType, serviceName } = useApmServiceContext();
@ -157,6 +164,7 @@ export function ServiceOverviewThroughputChart({
timeseries={timeseries}
yLabelFormat={asExactTransactionRate}
customTheme={comparisonChartTheme}
anomalyTimeseries={preferredAnomalyTimeseries}
/>
</EuiPanel>
);

View file

@ -14,7 +14,6 @@ import { ChartPointerEventContextProvider } from '../../../context/chart_pointer
import { useApmParams } from '../../../hooks/use_apm_params';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useTimeRange } from '../../../hooks/use_time_range';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge';
import { TransactionCharts } from '../../shared/charts/transaction_charts';
import { replace } from '../../shared/Links/url_helpers';
@ -32,7 +31,7 @@ export function TransactionDetails() {
} = query;
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const apmRouter = useApmRouter();
const { transactionType } = useApmServiceContext();
const { transactionType, fallbackToTransactions } = useApmServiceContext();
const history = useHistory();
@ -49,11 +48,6 @@ export function TransactionDetails() {
}),
});
const { kuery } = query;
const { fallbackToTransactions } = useFallbackToTransactionsFetcher({
kuery,
});
return (
<>
{fallbackToTransactions && <AggregatedTransactionsBadge />}

View file

@ -10,7 +10,6 @@ import React from 'react';
import { useHistory } from 'react-router-dom';
import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useFallbackToTransactionsFetcher } from '../../../hooks/use_fallback_to_transactions_fetcher';
import { useTimeRange } from '../../../hooks/use_time_range';
import { AggregatedTransactionsBadge } from '../../shared/aggregated_transactions_badge';
import { TransactionCharts } from '../../shared/charts/transaction_charts';
@ -30,10 +29,8 @@ export function TransactionOverview() {
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { fallbackToTransactions } = useFallbackToTransactionsFetcher({
kuery,
});
const { transactionType, serviceName } = useApmServiceContext();
const { transactionType, serviceName, fallbackToTransactions } =
useApmServiceContext();
const history = useHistory();

View file

@ -95,8 +95,8 @@ export const settings = {
}),
params: t.partial({
query: t.partial({
name: t.string,
environment: t.string,
name: t.string,
pageStep: agentConfigurationPageStepRt,
}),
}),

View file

@ -12,6 +12,7 @@ import {
useKibana,
KibanaPageTemplateProps,
} from '../../../../../../../src/plugins/kibana_react/public';
import { EnvironmentsContextProvider } from '../../../context/environments_context/environments_context';
import { useFetcher } from '../../../hooks/use_fetcher';
import { ApmPluginStartDeps } from '../../../plugin';
import { ApmEnvironmentFilter } from '../../shared/EnvironmentFilter';
@ -33,11 +34,13 @@ export function ApmMainTemplate({
pageTitle,
pageHeader,
children,
environmentFilter = true,
...pageTemplateProps
}: {
pageTitle?: React.ReactNode;
pageHeader?: EuiPageHeaderProps;
children: React.ReactNode;
environmentFilter?: boolean;
} & KibanaPageTemplateProps) {
const location = useLocation();
@ -62,12 +65,14 @@ export function ApmMainTemplate({
location.pathname.includes(path)
);
return (
const rightSideItems = environmentFilter ? [<ApmEnvironmentFilter />] : [];
const pageTemplate = (
<ObservabilityPageTemplate
noDataConfig={shouldBypassNoDataScreen ? undefined : noDataConfig}
pageHeader={{
pageTitle,
rightSideItems: [<ApmEnvironmentFilter />],
rightSideItems,
...pageHeader,
}}
{...pageTemplateProps}
@ -75,4 +80,12 @@ export function ApmMainTemplate({
{children}
</ObservabilityPageTemplate>
);
if (environmentFilter) {
return (
<EnvironmentsContextProvider>{pageTemplate}</EnvironmentsContextProvider>
);
}
return pageTemplate;
}

View file

@ -51,6 +51,7 @@ export default {
alerts: [],
transactionTypes: [],
serviceName,
fallbackToTransactions: false,
}}
>
<KibanaContext.Provider>

View file

@ -25,6 +25,7 @@ import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plug
import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useBreadcrumb } from '../../../../context/breadcrumbs/use_breadcrumb';
import { ServiceAnomalyTimeseriesContextProvider } from '../../../../context/service_anomaly_timeseries/service_anomaly_timeseries_context';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useApmRouter } from '../../../../hooks/use_apm_router';
import { useTimeRange } from '../../../../hooks/use_time_range';
@ -57,7 +58,9 @@ interface Props {
export function ApmServiceTemplate(props: Props) {
return (
<ApmServiceContextProvider>
<TemplateWithContext {...props} />
<ServiceAnomalyTimeseriesContextProvider>
<TemplateWithContext {...props} />
</ServiceAnomalyTimeseriesContextProvider>
</ApmServiceContextProvider>
);
}

View file

@ -38,12 +38,12 @@ export function SettingsTemplate({ children, selectedTab }: Props) {
return (
<ApmMainTemplate
environmentFilter={false}
pageHeader={{
tabs,
pageTitle: i18n.translate('xpack.apm.settings.title', {
defaultMessage: 'Settings',
}),
rightSideItems: [], // hide EnvironmentFilter
}}
>
{children}

View file

@ -16,9 +16,10 @@ import {
} from '../../../../common/environment_filter_values';
import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher';
import { fromQuery, toQuery } from '../Links/url_helpers';
import { useTimeRange } from '../../../hooks/use_time_range';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useUxUrlParams } from '../../../context/url_params_context/use_ux_url_params';
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { Environment } from '../../../../common/environment_rt';
import { useEnvironmentsContext } from '../../../context/environments_context/use_environments_context';
function updateEnvironmentUrl(
history: History,
@ -62,23 +63,13 @@ function getOptions(environments: string[]) {
}
export function ApmEnvironmentFilter() {
const { path, query } = useApmParams('/*');
const serviceName = 'serviceName' in path ? path.serviceName : undefined;
const environment =
('environment' in query && query.environment) || ENVIRONMENT_ALL.value;
const rangeFrom = 'rangeFrom' in query ? query.rangeFrom : undefined;
const rangeTo = 'rangeTo' in query ? query.rangeTo : undefined;
const { start, end } = useTimeRange({ rangeFrom, rangeTo, optional: true });
const { status, environments, environment } = useEnvironmentsContext();
return (
<EnvironmentFilter
start={start}
end={end}
serviceName={serviceName}
status={status}
environment={environment}
environments={environments}
/>
);
}
@ -88,34 +79,32 @@ export function UxEnvironmentFilter() {
urlParams: { start, end, environment, serviceName },
} = useUxUrlParams();
const { environments, status } = useEnvironmentsFetcher({
serviceName,
start,
end,
});
return (
<EnvironmentFilter
start={start}
end={end}
environment={environment}
serviceName={serviceName}
environment={(environment || ENVIRONMENT_ALL.value) as Environment}
status={status}
environments={environments as Environment[]}
/>
);
}
export function EnvironmentFilter({
start,
end,
environment,
serviceName,
environments,
status,
}: {
start?: string;
end?: string;
environment?: string;
serviceName?: string;
environment: Environment;
environments: Environment[];
status: FETCH_STATUS;
}) {
const history = useHistory();
const location = useLocation();
const { environments, status = 'loading' } = useEnvironmentsFetcher({
serviceName,
start,
end,
});
// Set the min-width so we don't see as much collapsing of the select during
// the loading state. 200px is what is looks like if "production" is
@ -135,7 +124,7 @@ export function EnvironmentFilter({
onChange={(event) => {
updateEnvironmentUrl(history, location, event.target.value);
}}
isLoading={status === 'loading'}
isLoading={status === FETCH_STATUS.LOADING}
style={{ minWidth }}
data-test-subj="environmentFilter"
/>

View file

@ -23,6 +23,9 @@ import {
} from '../../time_comparison/get_time_range_comparison';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { useEnvironmentsContext } from '../../../../context/environments_context/use_environments_context';
import { ApmMlDetectorType } from '../../../../../common/anomaly_detection/apm_ml_detectors';
import { usePreferredServiceAnomalyTimeseries } from '../../../../hooks/use_preferred_service_anomaly_timeseries';
function yLabelFormat(y?: number | null) {
return asPercent(y || 0, 1);
@ -32,7 +35,6 @@ interface Props {
height?: number;
showAnnotations?: boolean;
kuery: string;
environment: string;
}
type ErrorRate =
@ -52,7 +54,6 @@ const INITIAL_STATE: ErrorRate = {
export function FailedTransactionRateChart({
height,
showAnnotations = true,
environment,
kuery,
}: Props) {
const theme = useTheme();
@ -66,6 +67,12 @@ export function FailedTransactionRateChart({
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { environment } = useEnvironmentsContext();
const preferredAnomalyTimeseries = usePreferredServiceAnomalyTimeseries(
ApmMlDetectorType.txFailureRate
);
const { serviceName, transactionType, alerts } = useApmServiceContext();
const comparisonChartThem = getComparisonChartTheme(theme);
const { comparisonStart, comparisonEnd } = getTimeRangeComparison({
@ -154,6 +161,7 @@ export function FailedTransactionRateChart({
yLabelFormat={yLabelFormat}
yDomain={{ min: 0, max: 1 }}
customTheme={comparisonChartThem}
anomalyTimeseries={preferredAnomalyTimeseries}
alerts={alerts.filter(
(alert) =>
alert[ALERT_RULE_TYPE_ID]?.[0] === AlertType.TransactionErrorRate

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Fit } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { rgba } from 'polished';
import { EuiTheme } from '../../../../../../../../src/plugins/kibana_react/common';
import { getSeverityColor } from '../../../../../common/anomaly_detection';
import {
ANOMALY_SEVERITY,
ANOMALY_THRESHOLD,
} from '../../../../../common/ml_constants';
import { ServiceAnomalyTimeseries } from '../../../../../common/anomaly_detection/service_anomaly_timeseries';
import { APMChartSpec } from '../../../../../typings/timeseries';
import { getSeverity } from '../../../../../../ml/public';
export function getChartAnomalyTimeseries({
anomalyTimeseries,
theme,
}: {
anomalyTimeseries?: ServiceAnomalyTimeseries;
theme: EuiTheme;
}):
| {
boundaries: APMChartSpec[];
scores: APMChartSpec[];
}
| undefined {
if (!anomalyTimeseries) {
return undefined;
}
const boundaries = [
{
title: 'model plot',
type: 'area',
fit: Fit.Lookahead,
hideLegend: true,
hideTooltipValue: true,
areaSeriesStyle: {
point: {
opacity: 0,
},
},
color: rgba(theme.eui.euiColorVis1, 0.5),
stackAccessors: ['x'],
yAccessors: ['y0'],
y0Accessors: ['y1'],
data: anomalyTimeseries.bounds,
},
];
const severities = [
{ severity: ANOMALY_SEVERITY.MAJOR, threshold: ANOMALY_THRESHOLD.MAJOR },
{
severity: ANOMALY_SEVERITY.CRITICAL,
threshold: ANOMALY_THRESHOLD.CRITICAL,
},
];
const scores: APMChartSpec[] = severities.map(({ severity, threshold }) => {
const color = getSeverityColor(threshold);
const style = {
line: {
opacity: 0,
},
area: {
fill: color,
},
point: {
visible: true,
opacity: 0.75,
radius: 3,
strokeWidth: 1,
fill: color,
stroke: rgba(0, 0, 0, 0.1),
},
};
const data = anomalyTimeseries.anomalies.map((anomaly) => ({
...anomaly,
y: getSeverity(anomaly.y ?? 0).id === severity ? anomaly.actual : null,
}));
return {
title: i18n.translate('xpack.apm.anomalyScore', {
defaultMessage:
'{severity, select, minor {Minor} major {Major} critical {Critical}} anomaly',
values: {
severity,
},
}),
type: 'line',
hideLegend: true,
lineSeriesStyle: style,
data,
color,
};
});
return { boundaries, scores };
}

View file

@ -26,11 +26,13 @@ import {
import { MLHeader } from '../../../shared/charts/transaction_charts/ml_header';
import * as urlHelpers from '../../../shared/Links/url_helpers';
import { getComparisonChartTheme } from '../../time_comparison/get_time_range_comparison';
import { useEnvironmentsContext } from '../../../../context/environments_context/use_environments_context';
import { ApmMlDetectorType } from '../../../../../common/anomaly_detection/apm_ml_detectors';
import { usePreferredServiceAnomalyTimeseries } from '../../../../hooks/use_preferred_service_anomaly_timeseries';
interface Props {
height?: number;
kuery: string;
environment: string;
}
const options: Array<{ value: LatencyAggregationType; text: string }> = [
@ -43,7 +45,7 @@ function filterNil<T>(value: T | null | undefined): value is T {
return value != null;
}
export function LatencyChart({ height, kuery, environment }: Props) {
export function LatencyChart({ height, kuery }: Props) {
const history = useHistory();
const theme = useTheme();
const comparisonChartTheme = getComparisonChartTheme(theme);
@ -51,17 +53,22 @@ export function LatencyChart({ height, kuery, environment }: Props) {
const { latencyAggregationType, comparisonEnabled } = urlParams;
const license = useLicenseContext();
const { environment } = useEnvironmentsContext();
const { latencyChartsData, latencyChartsStatus } =
useTransactionLatencyChartsFetcher({
kuery,
environment,
});
const { currentPeriod, previousPeriod, anomalyTimeseries, mlJobId } =
latencyChartsData;
const { currentPeriod, previousPeriod } = latencyChartsData;
const { alerts } = useApmServiceContext();
const preferredAnomalyTimeseries = usePreferredServiceAnomalyTimeseries(
ApmMlDetectorType.txLatency
);
const timeseries = [
currentPeriod,
comparisonEnabled ? previousPeriod : undefined,
@ -111,7 +118,7 @@ export function LatencyChart({ height, kuery, environment }: Props) {
<EuiFlexItem grow={false}>
<MLHeader
hasValidMlLicense={license?.getFeature('ml').isAvailable}
mlJobId={mlJobId}
mlJobId={preferredAnomalyTimeseries?.jobId}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -124,7 +131,7 @@ export function LatencyChart({ height, kuery, environment }: Props) {
customTheme={comparisonChartTheme}
timeseries={timeseries}
yLabelFormat={getResponseTimeTickFormatter(latencyFormatter)}
anomalyTimeseries={anomalyTimeseries}
anomalyTimeseries={preferredAnomalyTimeseries}
alerts={alerts.filter(
(alert) =>
alert[ALERT_RULE_TYPE_ID]?.[0] ===

View file

@ -28,7 +28,6 @@ import { Meta, Story } from '@storybook/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public';
import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values';
import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types';
import type { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context';
import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context';
@ -106,6 +105,7 @@ const stories: Meta<Args> = {
serviceName,
transactionType,
transactionTypes: [],
fallbackToTransactions: false,
}}
>
<ChartPointerEventContextProvider>
@ -124,9 +124,7 @@ const stories: Meta<Args> = {
export default stories;
export const Example: Story<Args> = () => {
return (
<LatencyChart height={300} environment={ENVIRONMENT_ALL.value} kuery="" />
);
return <LatencyChart height={300} kuery="" />;
};
Example.args = {
alertsResponse: {
@ -207,17 +205,6 @@ Example.args = {
],
},
latencyChartResponse: {
anomalyTimeseries: {
jobId: 'apm-production-00aa-high_mean_transaction_duration',
anomalyScore: [
{
x0: 1622613600000,
x: 1622616000000,
y: 90.7449171687341,
},
],
anomalyBoundaries: [],
},
currentPeriod: {
overallAvgDuration: 3912.628446632232,
latencyTimeseries: [
@ -816,18 +803,12 @@ Example.args = {
};
export const NoData: Story<Args> = () => {
return (
<LatencyChart height={300} environment={ENVIRONMENT_ALL.value} kuery="" />
);
return <LatencyChart height={300} kuery="" />;
};
NoData.args = {
alertsResponse: { alerts: [] },
latencyChartResponse: {
anomalyTimeseries: {
jobId: 'apm-production-00aa-high_mean_transaction_duration',
anomalyScore: [],
anomalyBoundaries: [],
},
currentPeriod: { latencyTimeseries: [], overallAvgDuration: null },
previousPeriod: { latencyTimeseries: [], overallAvgDuration: null },
},

View file

@ -16,7 +16,6 @@ import {
LineSeries,
niceTimeFormatter,
Position,
RectAnnotation,
ScaleType,
Settings,
XYBrushEvent,
@ -31,23 +30,20 @@ import {
useChartTheme,
} from '../../../../../observability/public';
import { asAbsoluteDateTime } from '../../../../common/utils/formatters';
import {
Coordinate,
RectCoordinate,
TimeSeries,
} from '../../../../typings/timeseries';
import { Coordinate, TimeSeries } from '../../../../typings/timeseries';
import { useAnnotationsContext } from '../../../context/annotations/use_annotations_context';
import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context';
import { APMServiceAlert } from '../../../context/apm_service/apm_service_context';
import { useChartPointerEventContext } from '../../../context/chart_pointer_event/use_chart_pointer_event_context';
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { useTheme } from '../../../hooks/use_theme';
import { getLatencyChartSelector } from '../../../selectors/latency_chart_selectors';
import { unit } from '../../../utils/style';
import { ChartContainer } from './chart_container';
import { getAlertAnnotations } from './helper/get_alert_annotations';
import { getTimeZone } from './helper/timezone';
import { isTimeseriesEmpty, onBrushEnd } from './helper/helper';
import { ServiceAnomalyTimeseries } from '../../../../common/anomaly_detection/service_anomaly_timeseries';
import { getChartAnomalyTimeseries } from './helper/get_chart_anomaly_timeseries';
interface Props {
id: string;
@ -65,9 +61,7 @@ interface Props {
yTickFormat?: (y: number) => string;
showAnnotations?: boolean;
yDomain?: YDomainRange;
anomalyTimeseries?: ReturnType<
typeof getLatencyChartSelector
>['anomalyTimeseries'];
anomalyTimeseries?: ServiceAnomalyTimeseries;
customTheme?: Record<string, unknown>;
alerts?: APMServiceAlert[];
}
@ -103,10 +97,20 @@ export function TimeseriesChart({
const min = Math.min(...xValues);
const max = Math.max(...xValues);
const anomalyChartTimeseries = getChartAnomalyTimeseries({
anomalyTimeseries,
theme,
});
const xFormatter = niceTimeFormatter([min, max]);
const isEmpty = isTimeseriesEmpty(timeseries);
const annotationColor = theme.eui.euiColorSecondary;
const allSeries = [...timeseries, ...(anomalyTimeseries?.boundaries ?? [])];
const annotationColor = theme.eui.euiColorSuccess;
const allSeries = [
...timeseries,
// TODO: re-enable anomaly boundaries when we have a fix for https://github.com/elastic/kibana/issues/100660
// ...(anomalyChartTimeseries?.boundaries ?? []),
...(anomalyChartTimeseries?.scores ?? []),
];
const xDomain = isEmpty ? { min: 0, max: 1 } : { min, max };
return (
@ -185,11 +189,15 @@ export function TimeseriesChart({
<Series
timeZone={timeZone}
key={serie.title}
id={serie.title}
id={serie.id || serie.title}
groupId={serie.groupId}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
yAccessors={serie.yAccessors ?? ['y']}
y0Accessors={serie.y0Accessors}
stackAccessors={serie.stackAccessors ?? undefined}
markSizeAccessor={serie.markSizeAccessor}
data={isEmpty ? [] : serie.data}
color={serie.color}
curve={CurveType.CURVE_MONOTONE_X}
@ -198,25 +206,12 @@ export function TimeseriesChart({
filterSeriesInTooltip={
serie.hideTooltipValue ? () => false : undefined
}
stackAccessors={serie.stackAccessors ?? undefined}
areaSeriesStyle={serie.areaSeriesStyle}
lineSeriesStyle={serie.lineSeriesStyle}
/>
);
})}
{anomalyTimeseries?.scores && (
<RectAnnotation
key={anomalyTimeseries.scores.title}
id="score_anomalies"
dataValues={(anomalyTimeseries.scores.data as RectCoordinate[]).map(
({ x0, x: x1 }) => ({
coordinates: { x0, x1 },
})
)}
style={{ fill: anomalyTimeseries.scores.color }}
/>
)}
{getAlertAnnotations({
alerts,
chartStartTime: xValues[0],

View file

@ -38,13 +38,12 @@ export function TransactionCharts({
<EuiFlexGrid columns={2} gutterSize="s">
<EuiFlexItem data-cy={`transaction-duration-charts`}>
<EuiPanel hasBorder={true}>
<LatencyChart kuery={kuery} environment={environment} />
<LatencyChart kuery={kuery} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem style={{ flexShrink: 1 }}>
<ServiceOverviewThroughputChart
environment={environment}
kuery={kuery}
transactionName={transactionName}
/>
@ -55,10 +54,7 @@ export function TransactionCharts({
<EuiFlexGrid columns={2} gutterSize="s">
<EuiFlexItem>
<FailedTransactionRateChart
kuery={kuery}
environment={environment}
/>
<FailedTransactionRateChart kuery={kuery} />
</EuiFlexItem>
<EuiFlexItem>
<TransactionBreakdownChart

View file

@ -18,6 +18,7 @@ import { APIReturnType } from '../../services/rest/createCallApmApi';
import { useServiceAlertsFetcher } from './use_service_alerts_fetcher';
import { useApmParams } from '../../hooks/use_apm_params';
import { useTimeRange } from '../../hooks/use_time_range';
import { useFallbackToTransactionsFetcher } from '../../hooks/use_fallback_to_transactions_fetcher';
export type APMServiceAlert = ValuesType<
APIReturnType<'GET /internal/apm/services/{serviceName}/alerts'>['alerts']
@ -30,12 +31,14 @@ export interface APMServiceContextValue {
transactionTypes: string[];
alerts: APMServiceAlert[];
runtimeName?: string;
fallbackToTransactions: boolean;
}
export const APMServiceContext = createContext<APMServiceContextValue>({
serviceName: '',
transactionTypes: [],
alerts: [],
fallbackToTransactions: false,
});
export function ApmServiceContextProvider({
@ -46,7 +49,7 @@ export function ApmServiceContextProvider({
const {
path: { serviceName },
query,
query: { rangeFrom, rangeTo },
query: { kuery, rangeFrom, rangeTo },
} = useApmParams('/services/{serviceName}');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
@ -77,6 +80,10 @@ export function ApmServiceContextProvider({
end,
});
const { fallbackToTransactions } = useFallbackToTransactionsFetcher({
kuery,
});
return (
<APMServiceContext.Provider
value={{
@ -86,6 +93,7 @@ export function ApmServiceContextProvider({
transactionTypes,
alerts,
runtimeName,
fallbackToTransactions,
}}
children={children}
/>

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
import { Environment } from '../../../common/environment_rt';
import { useApmParams } from '../../hooks/use_apm_params';
import { useEnvironmentsFetcher } from '../../hooks/use_environments_fetcher';
import { FETCH_STATUS } from '../../hooks/use_fetcher';
import { useTimeRange } from '../../hooks/use_time_range';
export const EnvironmentsContext = React.createContext<{
environment: Environment;
environments: Environment[];
status: FETCH_STATUS;
}>({
environment: ENVIRONMENT_ALL.value,
environments: [],
status: FETCH_STATUS.NOT_INITIATED,
});
export function EnvironmentsContextProvider({
children,
}: {
children: React.ReactElement;
}) {
const { path, query } = useApmParams('/*');
const serviceName = 'serviceName' in path ? path.serviceName : undefined;
const environment =
('environment' in query && (query.environment as Environment)) ||
ENVIRONMENT_ALL.value;
const rangeFrom = 'rangeFrom' in query ? query.rangeFrom : undefined;
const rangeTo = 'rangeTo' in query ? query.rangeTo : undefined;
const { start, end } = useTimeRange({ rangeFrom, rangeTo, optional: true });
const { environments, status } = useEnvironmentsFetcher({
serviceName,
start,
end,
});
return (
<EnvironmentsContext.Provider
value={{
environments,
status,
environment,
}}
>
{children}
</EnvironmentsContext.Provider>
);
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useContext } from 'react';
import { EnvironmentsContext } from './environments_context';
export function useEnvironmentsContext() {
return useContext(EnvironmentsContext);
}

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ServiceAnomalyTimeseries } from '../../../common/anomaly_detection/service_anomaly_timeseries';
import { useApmParams } from '../../hooks/use_apm_params';
import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher';
import { useTimeRange } from '../../hooks/use_time_range';
import { useApmPluginContext } from '../apm_plugin/use_apm_plugin_context';
import { useApmServiceContext } from '../apm_service/use_apm_service_context';
import { isActivePlatinumLicense } from '../../../common/license_check';
import { useLicenseContext } from '../license/use_license_context';
export const ServiceAnomalyTimeseriesContext = React.createContext<{
status: FETCH_STATUS;
allAnomalyTimeseries: ServiceAnomalyTimeseries[];
}>({
status: FETCH_STATUS.NOT_INITIATED,
allAnomalyTimeseries: [],
});
export function ServiceAnomalyTimeseriesContextProvider({
children,
}: {
children: React.ReactChild;
}) {
const { serviceName, transactionType } = useApmServiceContext();
const { core } = useApmPluginContext();
const license = useLicenseContext();
const mlCapabilities = core.application.capabilities.ml as
| { canGetJobs: boolean }
| undefined;
const canGetAnomalies =
mlCapabilities?.canGetJobs && isActivePlatinumLicense(license);
const {
query: { rangeFrom, rangeTo },
} = useApmParams('/services/{serviceName}');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const { status, data } = useFetcher(
(callApmApi) => {
if (!transactionType || !canGetAnomalies) {
return;
}
return callApmApi({
endpoint: 'GET /internal/apm/services/{serviceName}/anomaly_charts',
params: {
path: {
serviceName,
},
query: {
start,
end,
transactionType,
},
},
});
},
[serviceName, canGetAnomalies, transactionType, start, end]
);
return (
<ServiceAnomalyTimeseriesContext.Provider
value={{
status,
allAnomalyTimeseries: data?.allAnomalyTimeseries ?? [],
}}
>
{children}
</ServiceAnomalyTimeseriesContext.Provider>
);
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useContext } from 'react';
import { ServiceAnomalyTimeseriesContext } from './service_anomaly_timeseries_context';
export function useServiceAnomalyTimeseriesContext() {
return useContext(ServiceAnomalyTimeseriesContext);
}

View file

@ -34,7 +34,7 @@ export function useEnvironmentsFetcher({
start?: string;
end?: string;
}) {
const { data = INITIAL_DATA, status = 'loading' } = useFetcher(
const { data = INITIAL_DATA, status } = useFetcher(
(callApmApi) => {
if (start && end) {
return callApmApi({

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ApmMlDetectorType } from '../../common/anomaly_detection/apm_ml_detectors';
import { getPreferredServiceAnomalyTimeseries } from '../../common/anomaly_detection/get_preferred_service_anomaly_timeseries';
import { useApmServiceContext } from '../context/apm_service/use_apm_service_context';
import { useEnvironmentsContext } from '../context/environments_context/use_environments_context';
import { useServiceAnomalyTimeseriesContext } from '../context/service_anomaly_timeseries/use_service_anomaly_timeseries_context';
export function usePreferredServiceAnomalyTimeseries(
detectorType: ApmMlDetectorType
) {
const { allAnomalyTimeseries } = useServiceAnomalyTimeseriesContext();
const { environment, environments } = useEnvironmentsContext();
const { fallbackToTransactions } = useApmServiceContext();
return getPreferredServiceAnomalyTimeseries({
environment,
environments,
fallbackToTransactions,
detectorType,
allAnomalyTimeseries,
});
}

View file

@ -31,11 +31,6 @@ const latencyChartData = {
overallAvgDuration: 1,
latencyTimeseries: [{ x: 1, y: 10 }],
},
anomalyTimeseries: {
jobId: '1',
anomalyBoundaries: [{ x: 1, y: 2, y0: 1 }],
anomalyScore: [{ x: 1, x0: 2 }],
},
} as LatencyChartsResponse;
describe('getLatencyChartSelector', () => {
@ -45,15 +40,12 @@ describe('getLatencyChartSelector', () => {
expect(latencyChart).toEqual({
currentPeriod: undefined,
previousPeriod: undefined,
mlJobId: undefined,
anomalyTimeseries: undefined,
});
});
it('returns average timeseries', () => {
const { anomalyTimeseries, ...latencyWithoutAnomaly } = latencyChartData;
const latencyTimeseries = getLatencyChartSelector({
latencyChart: latencyWithoutAnomaly as LatencyChartsResponse,
latencyChart: latencyChartData,
theme,
latencyAggregationType: LatencyAggregationType.avg,
});
@ -76,9 +68,8 @@ describe('getLatencyChartSelector', () => {
});
it('returns 95th percentile timeseries', () => {
const { anomalyTimeseries, ...latencyWithoutAnomaly } = latencyChartData;
const latencyTimeseries = getLatencyChartSelector({
latencyChart: latencyWithoutAnomaly as LatencyChartsResponse,
latencyChart: latencyChartData,
theme,
latencyAggregationType: LatencyAggregationType.p95,
});
@ -100,9 +91,8 @@ describe('getLatencyChartSelector', () => {
});
it('returns 99th percentile timeseries', () => {
const { anomalyTimeseries, ...latencyWithoutAnomaly } = latencyChartData;
const latencyTimeseries = getLatencyChartSelector({
latencyChart: latencyWithoutAnomaly as LatencyChartsResponse,
latencyChart: latencyChartData,
theme,
latencyAggregationType: LatencyAggregationType.p99,
});
@ -146,39 +136,6 @@ describe('getLatencyChartSelector', () => {
color: 'green',
title: 'Previous period',
},
mlJobId: '1',
anomalyTimeseries: {
boundaries: [
{
type: 'area',
fit: 'lookahead',
hideLegend: true,
hideTooltipValue: true,
stackAccessors: ['y'],
areaSeriesStyle: { point: { opacity: 0 } },
title: 'anomalyBoundariesLower',
data: [{ x: 1, y: 1 }],
color: 'rgba(0,0,0,0)',
},
{
type: 'area',
fit: 'lookahead',
hideLegend: true,
hideTooltipValue: true,
stackAccessors: ['y'],
areaSeriesStyle: { point: { opacity: 0 } },
title: 'anomalyBoundariesUpper',
data: [{ x: 1, y: 1 }],
color: 'rgba(0,0,255,0.5)',
},
],
scores: {
title: 'anomalyScores',
type: 'rectAnnotation',
data: [{ x: 1, x0: 2 }],
color: 'yellow',
},
},
});
});
});

View file

@ -5,9 +5,7 @@
* 2.0.
*/
import { Fit } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { rgba } from 'polished';
import { EuiTheme } from '../../../../../src/plugins/kibana_react/common';
import { asDuration } from '../../common/utils/formatters';
import { APMChartSpec, Coordinate } from '../../typings/timeseries';
@ -19,8 +17,6 @@ export type LatencyChartsResponse =
export interface LatencyChartData {
currentPeriod?: APMChartSpec<Coordinate>;
previousPeriod?: APMChartSpec<Coordinate>;
mlJobId?: string;
anomalyTimeseries?: { boundaries: APMChartSpec[]; scores: APMChartSpec };
}
export function getLatencyChartSelector({
@ -48,11 +44,6 @@ export function getLatencyChartSelector({
previousPeriod: latencyChart.previousPeriod,
theme,
}),
mlJobId: latencyChart.anomalyTimeseries?.jobId,
anomalyTimeseries: getAnomalyTimeseries({
anomalyTimeseries: latencyChart.anomalyTimeseries,
theme,
}),
};
}
@ -125,58 +116,3 @@ function getLatencyTimeseries({
}
}
}
function getAnomalyTimeseries({
anomalyTimeseries,
theme,
}: {
anomalyTimeseries: LatencyChartsResponse['anomalyTimeseries'];
theme: EuiTheme;
}): { boundaries: APMChartSpec[]; scores: APMChartSpec } | undefined {
if (!anomalyTimeseries) {
return undefined;
}
const boundariesConfigBase = {
type: 'area',
fit: Fit.Lookahead,
hideLegend: true,
hideTooltipValue: true,
stackAccessors: ['y'],
areaSeriesStyle: {
point: {
opacity: 0,
},
},
};
const boundaries = [
{
...boundariesConfigBase,
title: 'anomalyBoundariesLower',
data: anomalyTimeseries.anomalyBoundaries.map((coord) => ({
x: coord.x,
y: coord.y0,
})),
color: rgba(0, 0, 0, 0),
},
{
...boundariesConfigBase,
title: 'anomalyBoundariesUpper',
data: anomalyTimeseries.anomalyBoundaries.map((coord) => ({
x: coord.x,
y: coord.y - coord.y0,
})),
color: rgba(theme.eui.euiColorVis1, 0.5),
},
];
const scores = {
title: 'anomalyScores',
type: 'rectAnnotation',
data: anomalyTimeseries.anomalyScore,
color: theme.eui.euiColorVis9,
};
return { boundaries, scores };
}

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
ESSearchRequest,
ESSearchResponse,
} from '../../../../../../src/core/types/elasticsearch';
import { Setup } from '../helpers/setup_request';
interface SharedFields {
job_id: string;
bucket_span: number;
detector_index: number;
timestamp: number;
partition_field_name: string;
partition_field_value: string;
by_field_name: string;
by_field_value: string;
}
interface MlModelPlot extends SharedFields {
result_type: 'model_plot';
model_feature: string;
model_lower: number;
model_upper: number;
model_median: number;
actual: number;
}
interface MlRecord extends SharedFields {
result_type: 'record';
record_score: number;
initial_record_score: number;
function: string;
function_description: string;
typical: number[];
actual: number[];
field_name: string;
is_interim: boolean;
}
type AnomalyDocument = MlRecord | MlModelPlot;
export async function anomalySearch<TParams extends ESSearchRequest>(
mlAnomalySearch: Required<Setup>['ml']['mlSystem']['mlAnomalySearch'],
params: TParams
): Promise<ESSearchResponse<AnomalyDocument, TParams>> {
const response = await mlAnomalySearch(params, []);
return response as unknown as ESSearchResponse<AnomalyDocument, TParams>;
}

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { termQuery, termsQuery } from '../../../../observability/server';
import {
ApmMlDetectorType,
getApmMlDetectorIndex,
} from '../../../common/anomaly_detection/apm_ml_detectors';
export function apmMlAnomalyQuery({
serviceName,
transactionType,
detectorTypes,
}: {
serviceName?: string;
detectorTypes?: ApmMlDetectorType[];
transactionType?: string;
}) {
return [
{
bool: {
filter: [
{
bool: {
should: [
{
bool: {
filter: [
...termQuery('is_interim', false),
...termQuery('result_type', 'record'),
],
},
},
{
bool: {
filter: termQuery('result_type', 'model_plot'),
},
},
],
minimum_should_match: 1,
},
},
...termsQuery(
'detector_index',
...(detectorTypes?.map((type) => getApmMlDetectorIndex(type)) ?? [])
),
...termQuery('partition_field_value', serviceName),
...termQuery('by_field_value', transactionType),
],
},
},
] as QueryDslQueryContainer[];
}

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
MlJob,
QueryDslQueryContainer,
} from '@elastic/elasticsearch/lib/api/types';
export function apmMlJobsQuery(jobs: MlJob[]) {
if (!jobs.length) {
throw new Error('At least one ML job should be given');
}
return [
{
terms: {
job_id: jobs.map((job) => job.job_id),
},
},
] as QueryDslQueryContainer[];
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getBucketSize } from '../helpers/get_bucket_size';
export function getAnomalyResultBucketSize({
start,
end,
}: {
start: number;
end: number;
}) {
return getBucketSize({
start,
end,
numBuckets: 100,
});
}

View file

@ -0,0 +1,206 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import { compact, keyBy } from 'lodash';
import { rangeQuery } from '../../../../observability/server';
import { apmMlAnomalyQuery } from './apm_ml_anomaly_query';
import {
ApmMlDetectorType,
getApmMlDetectorType,
} from '../../../common/anomaly_detection/apm_ml_detectors';
import type { ServiceAnomalyTimeseries } from '../../../common/anomaly_detection/service_anomaly_timeseries';
import { apmMlJobsQuery } from './apm_ml_jobs_query';
import { asMutableArray } from '../../../common/utils/as_mutable_array';
import { maybe } from '../../../common/utils/maybe';
import type { Setup } from '../helpers/setup_request';
import { anomalySearch } from './anomaly_search';
import { getAnomalyResultBucketSize } from './get_anomaly_result_bucket_size';
import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group';
export async function getAnomalyTimeseries({
serviceName,
transactionType,
start,
end,
logger,
mlSetup,
}: {
serviceName: string;
transactionType: string;
start: number;
end: number;
logger: Logger;
mlSetup: Required<Setup>['ml'];
}): Promise<ServiceAnomalyTimeseries[]> {
if (!mlSetup) {
return [];
}
const { intervalString } = getAnomalyResultBucketSize({
start,
end,
});
const { jobs: mlJobs } = await getMlJobsWithAPMGroup(
mlSetup.anomalyDetectors
);
if (!mlJobs.length) {
return [];
}
const anomaliesResponse = await anomalySearch(
mlSetup.mlSystem.mlAnomalySearch,
{
body: {
size: 0,
query: {
bool: {
filter: [
...apmMlAnomalyQuery({
serviceName,
transactionType,
}),
...rangeQuery(start, end, 'timestamp'),
...apmMlJobsQuery(mlJobs),
],
},
},
aggs: {
by_timeseries_id: {
composite: {
size: 5000,
sources: asMutableArray([
{
jobId: {
terms: {
field: 'job_id',
},
},
},
{
detectorIndex: {
terms: {
field: 'detector_index',
},
},
},
{
serviceName: {
terms: {
field: 'partition_field_value',
},
},
},
{
transactionType: {
terms: {
field: 'by_field_value',
},
},
},
] as const),
},
aggs: {
timeseries: {
date_histogram: {
field: 'timestamp',
fixed_interval: intervalString,
extended_bounds: {
min: start,
max: end,
},
},
aggs: {
top_anomaly: {
top_metrics: {
metrics: asMutableArray([
{ field: 'record_score' },
{ field: 'actual' },
] as const),
size: 1,
sort: {
record_score: 'desc',
},
},
},
model_lower: {
min: {
field: 'model_lower',
},
},
model_upper: {
max: {
field: 'model_upper',
},
},
},
},
},
},
},
},
}
);
const jobsById = keyBy(mlJobs, (job) => job.job_id);
function divide(value: number | null, divider: number) {
if (value === null) {
return null;
}
return value / divider;
}
const series: Array<ServiceAnomalyTimeseries | undefined> =
anomaliesResponse.aggregations?.by_timeseries_id.buckets.map((bucket) => {
const jobId = bucket.key.jobId as string;
const job = maybe(jobsById[jobId]);
if (!job) {
logger.warn(`Could not find job for id ${jobId}`);
return undefined;
}
const type = getApmMlDetectorType(Number(bucket.key.detectorIndex));
// ml failure rate is stored as 0-100, we calculate failure rate as 0-1
const divider = type === ApmMlDetectorType.txFailureRate ? 100 : 1;
return {
jobId,
type,
serviceName: bucket.key.serviceName as string,
environment: job.custom_settings!.job_tags!.environment as string,
transactionType: bucket.key.transactionType as string,
version: Number(job.custom_settings!.job_tags!.apm_ml_version),
anomalies: bucket.timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key as number,
y:
(dateBucket.top_anomaly.top[0]?.metrics.record_score as
| number
| null
| undefined) ?? null,
actual: divide(
(dateBucket.top_anomaly.top[0]?.metrics.actual as
| number
| null
| undefined) ?? null,
divider
),
})),
bounds: bucket.timeseries.buckets.map((dateBucket) => ({
x: dateBucket.key as number,
y0: divide(dateBucket.model_lower.value, divider),
y1: divide(dateBucket.model_upper.value, divider),
})),
};
}) ?? [];
return compact(series);
}

View file

@ -14,6 +14,7 @@ import { ProcessorEvent } from '../../../common/processor_event';
import { rangeQuery, termQuery } from '../../../../observability/server';
import { getProcessorEventForTransactions } from '../../lib/helpers/transactions';
import { Setup } from '../../lib/helpers/setup_request';
import { Environment } from '../../../common/environment_rt';
/**
* This is used for getting the list of environments for the environments selector,
@ -78,5 +79,5 @@ export async function getEnvironments({
(environmentBucket) => environmentBucket.key as string
);
return environments;
return environments as Environment[];
}

View file

@ -22,8 +22,8 @@ import { rangeQuery } from '../../../../observability/server';
import { withApmSpan } from '../../utils/with_apm_span';
import { getMlJobsWithAPMGroup } from '../../lib/anomaly_detection/get_ml_jobs_with_apm_group';
import { Setup } from '../../lib/helpers/setup_request';
import { apmMlAnomalyQuery } from '../../../common/anomaly_detection/apm_ml_anomaly_query';
import { ApmMlDetectorIndex } from '../../../common/anomaly_detection/apm_ml_detectors';
import { apmMlAnomalyQuery } from '../../lib/anomaly_detection/apm_ml_anomaly_query';
import { ApmMlDetectorType } from '../../../common/anomaly_detection/apm_ml_detectors';
export const DEFAULT_ANOMALIES: ServiceAnomaliesResponse = {
mlJobIds: [],
@ -58,7 +58,9 @@ export async function getServiceAnomalies({
query: {
bool: {
filter: [
...apmMlAnomalyQuery(ApmMlDetectorIndex.txLatency),
...apmMlAnomalyQuery({
detectorTypes: [ApmMlDetectorType.txLatency],
}),
...rangeQuery(
Math.min(end - 30 * 60 * 1000, start),
end,

View file

@ -44,7 +44,14 @@ import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_pr
import { getServicesDetailedStatistics } from './get_services_detailed_statistics';
import { getServiceDependenciesBreakdown } from './get_service_dependencies_breakdown';
import { getBucketSizeForAggregatedTransactions } from '../../lib/helpers/get_bucket_size_for_aggregated_transactions';
import { getAnomalyTimeseries } from '../../lib/anomaly_detection/get_anomaly_timeseries';
import {
UnknownMLCapabilitiesError,
InsufficientMLCapabilities,
MLPrivilegesUninitialized,
} from '../../../../ml/server';
import { getServiceInstancesDetailedStatisticsPeriods } from './get_service_instances/detailed_statistics';
import { ML_ERRORS } from '../../../common/anomaly_detection';
const servicesRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services',
@ -848,6 +855,55 @@ const serviceInfrastructureRoute = createApmServerRoute({
},
});
const serviceAnomalyChartsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/services/{serviceName}/anomaly_charts',
params: t.type({
path: t.type({
serviceName: t.string,
}),
query: t.intersection([rangeRt, t.type({ transactionType: t.string })]),
}),
options: {
tags: ['access:apm'],
},
handler: async (resources) => {
const setup = await setupRequest(resources);
if (!setup.ml) {
throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE);
}
const {
path: { serviceName },
query: { start, end, transactionType },
} = resources.params;
try {
const allAnomalyTimeseries = await getAnomalyTimeseries({
serviceName,
transactionType,
start,
end,
mlSetup: setup.ml,
logger: resources.logger,
});
return {
allAnomalyTimeseries,
};
} catch (error) {
if (
error instanceof UnknownMLCapabilitiesError ||
error instanceof InsufficientMLCapabilities ||
error instanceof MLPrivilegesUninitialized
) {
throw Boom.forbidden(error.message);
}
throw error;
}
},
});
export const serviceRouteRepository = createApmServerRouteRepository()
.add(servicesRoute)
.add(servicesDetailedStatisticsRoute)
@ -867,4 +923,5 @@ export const serviceRouteRepository = createApmServerRouteRepository()
.add(serviceProfilingTimelineRoute)
.add(serviceProfilingStatisticsRoute)
.add(serviceAlertsRoute)
.add(serviceInfrastructureRoute);
.add(serviceInfrastructureRoute)
.add(serviceAnomalyChartsRoute);

View file

@ -1,91 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ESSearchResponse } from '../../../../../../../src/core/types/elasticsearch';
import { PromiseReturnType } from '../../../../../observability/typings/common';
import { rangeQuery } from '../../../../../observability/server';
import { asMutableArray } from '../../../../common/utils/as_mutable_array';
import { withApmSpan } from '../../../utils/with_apm_span';
import { Setup } from '../../../lib/helpers/setup_request';
import { apmMlAnomalyQuery } from '../../../../common/anomaly_detection/apm_ml_anomaly_query';
import { ApmMlDetectorIndex } from '../../../../common/anomaly_detection/apm_ml_detectors';
export type ESResponse = Exclude<
PromiseReturnType<typeof anomalySeriesFetcher>,
undefined
>;
export function anomalySeriesFetcher({
serviceName,
transactionType,
intervalString,
ml,
start,
end,
}: {
serviceName: string;
transactionType: string;
intervalString: string;
ml: Required<Setup>['ml'];
start: number;
end: number;
}) {
return withApmSpan('get_latency_anomaly_data', async () => {
const params = {
body: {
size: 0,
query: {
bool: {
filter: [
...apmMlAnomalyQuery(ApmMlDetectorIndex.txLatency),
{ term: { partition_field_value: serviceName } },
{ term: { by_field_value: transactionType } },
...rangeQuery(start, end, 'timestamp'),
] as QueryDslQueryContainer[],
},
},
aggs: {
job_id: {
terms: {
field: 'job_id',
},
aggs: {
ml_avg_response_times: {
date_histogram: {
field: 'timestamp',
fixed_interval: intervalString,
extended_bounds: { min: start, max: end },
},
aggs: {
anomaly_score: {
top_metrics: {
metrics: asMutableArray([
{ field: 'record_score' },
{ field: 'timestamp' },
{ field: 'bucket_span' },
] as const),
sort: {
record_score: 'desc' as const,
},
},
},
lower: { min: { field: 'model_lower' } },
upper: { max: { field: 'model_upper' } },
},
},
},
},
},
},
};
return ml.mlSystem.mlAnomalySearch(params, []) as unknown as Promise<
ESSearchResponse<unknown, typeof params>
>;
});
}

View file

@ -1,138 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { compact } from 'lodash';
import { Logger } from 'src/core/server';
import { isFiniteNumber } from '../../../../common/utils/is_finite_number';
import { maybe } from '../../../../common/utils/maybe';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import { getBucketSize } from '../../../lib/helpers/get_bucket_size';
import { Setup } from '../../../lib/helpers/setup_request';
import { anomalySeriesFetcher } from './fetcher';
import { getMLJobIds } from '../../service_map/get_service_anomalies';
import { ANOMALY_THRESHOLD } from '../../../../common/ml_constants';
import { withApmSpan } from '../../../utils/with_apm_span';
export async function getAnomalySeries({
environment,
serviceName,
transactionType,
transactionName,
kuery,
setup,
logger,
start,
end,
}: {
environment: string;
serviceName: string;
transactionType: string;
transactionName?: string;
kuery: string;
setup: Setup;
logger: Logger;
start: number;
end: number;
}) {
const { ml } = setup;
// don't fetch anomalies if the ML plugin is not setup
if (!ml) {
return undefined;
}
// don't fetch anomalies if requested for a specific transaction name
// as ML results are not partitioned by transaction name
if (!!transactionName) {
return undefined;
}
// don't fetch anomalies when no specific environment is selected
if (!environment || environment === ENVIRONMENT_ALL.value) {
return undefined;
}
// Don't fetch anomalies if kuery is present
if (kuery) {
return undefined;
}
return withApmSpan('get_latency_anomaly_series', async () => {
const { intervalString } = getBucketSize({ start, end });
// move the start back with one bucket size, to ensure to get anomaly data in the beginning
// this is required because ML has a minimum bucket size (default is 900s) so if our buckets
// are smaller, we might have several null buckets in the beginning
const mlStart = start - 900 * 1000;
const [anomaliesResponse, jobIds] = await Promise.all([
anomalySeriesFetcher({
serviceName,
transactionType,
intervalString,
ml,
start: mlStart,
end,
}),
getMLJobIds(ml.anomalyDetectors, environment),
]);
const scoreSeriesCollection =
anomaliesResponse?.aggregations?.job_id.buckets
.filter((bucket) => jobIds.includes(bucket.key as string))
.map((bucket) => {
const dateBuckets = bucket.ml_avg_response_times.buckets;
return {
jobId: bucket.key as string,
anomalyScore: compact(
dateBuckets.map((dateBucket) => {
const metrics = maybe(dateBucket.anomaly_score.top[0])?.metrics;
const score = metrics?.record_score;
if (
!metrics ||
!isFiniteNumber(score) ||
score < ANOMALY_THRESHOLD.CRITICAL
) {
return null;
}
const anomalyStart = Date.parse(metrics.timestamp as string);
const anomalyEnd =
anomalyStart + (metrics.bucket_span as number) * 1000;
return {
x0: anomalyStart,
x: anomalyEnd,
y: score,
};
})
),
anomalyBoundaries: dateBuckets
.filter(
(dateBucket) =>
dateBucket.lower.value !== null &&
dateBucket.upper.value !== null
)
.map((dateBucket) => ({
x: dateBucket.key,
y0: dateBucket.lower.value as number,
y: dateBucket.upper.value as number,
})),
};
});
if ((scoreSeriesCollection?.length ?? 0) > 1) {
logger.warn(
`More than one ML job was found for ${serviceName} for environment ${environment}. Only showing results from ${scoreSeriesCollection?.[0].jobId}`
);
}
return scoreSeriesCollection?.[0];
});
}

View file

@ -18,7 +18,6 @@ import { getServiceTransactionGroups } from '../services/get_service_transaction
import { getServiceTransactionGroupDetailedStatisticsPeriods } from '../services/get_service_transaction_group_detailed_statistics';
import { getTransactionBreakdown } from './breakdown';
import { getTransactionTraceSamples } from './trace_samples';
import { getAnomalySeries } from './get_anomaly_data';
import { getLatencyPeriods } from './get_latency_charts';
import { getErrorRatePeriods } from '../../lib/transaction_groups/get_error_rate';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
@ -204,26 +203,16 @@ const transactionLatencyChartsRoute = createApmServerRoute({
end,
};
const [{ currentPeriod, previousPeriod }, anomalyTimeseries] =
await Promise.all([
getLatencyPeriods({
...options,
latencyAggregationType:
latencyAggregationType as LatencyAggregationType,
comparisonStart,
comparisonEnd,
}),
getAnomalySeries(options).catch((error) => {
logger.warn(`Unable to retrieve anomalies for latency charts.`);
logger.error(error);
return undefined;
}),
]);
const { currentPeriod, previousPeriod } = await getLatencyPeriods({
...options,
latencyAggregationType: latencyAggregationType as LatencyAggregationType,
comparisonStart,
comparisonEnd,
});
return {
currentPeriod,
previousPeriod,
anomalyTimeseries,
};
},
});

View file

@ -11,6 +11,8 @@ import {
Fit,
FitConfig,
LineSeriesStyle,
SeriesColorAccessorFn,
SeriesColorsArray,
} from '@elastic/charts';
import { DeepPartial } from 'utility-types';
import { Maybe } from '../typings/common';
@ -20,6 +22,12 @@ export interface Coordinate {
y: Maybe<number>;
}
export interface BandCoordinate {
x: number;
y0: number | null;
y1: number | null;
}
export interface RectCoordinate {
x: number;
x0: number;
@ -28,26 +36,37 @@ export interface RectCoordinate {
type Accessor = Array<string | number | AccessorFn>;
export type TimeSeries<
TCoordinate extends { x: number } = Coordinate | RectCoordinate
TCoordinate extends { x: number } =
| Coordinate
| RectCoordinate
| BandCoordinate
> = APMChartSpec<TCoordinate>;
export interface APMChartSpec<
TCoordinate extends { x: number } = Coordinate | RectCoordinate
TCoordinate extends { x: number } =
| Coordinate
| RectCoordinate
| BandCoordinate
> {
title: string;
id?: string;
titleShort?: string;
hideLegend?: boolean;
hideTooltipValue?: boolean;
data: TCoordinate[];
legendValue?: string;
type: string;
color: string;
color: string | SeriesColorsArray | SeriesColorAccessorFn;
areaColor?: string;
fit?: Exclude<Fit, 'explicit'> | FitConfig;
stackAccessors?: Accessor;
yAccessors?: Accessor;
y0Accessors?: Accessor;
splitSeriesAccessors?: Accessor;
markSizeAccessor?: string | AccessorFn;
lineSeriesStyle?: DeepPartial<LineSeriesStyle>;
areaSeriesStyle?: DeepPartial<AreaSeriesStyle>;
groupId?: string;
}
export type ChartType = 'area' | 'linemark';

View file

@ -17,7 +17,7 @@ import {
unwrapEsResponse,
WrappedElasticsearchClientError,
} from '../common/utils/unwrap_es_response';
export { rangeQuery, kqlQuery, termQuery } from './utils/queries';
export { rangeQuery, kqlQuery, termQuery, termsQuery } from './utils/queries';
export { getInspectResponse } from '../common/utils/get_inspect_response';
export * from './types';

View file

@ -4,16 +4,37 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { reject } from 'lodash';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
export function termQuery<T extends string>(field: T, value: string | undefined) {
if (!value) {
function isUndefinedOrNull(value: any): value is undefined | null {
return value === undefined || value === null;
}
export function termQuery<T extends string>(
field: T,
value: string | boolean | number | undefined | null
): QueryDslQueryContainer[] {
if (isUndefinedOrNull(value)) {
return [];
}
return [{ term: { [field]: value } as Record<T, string> }];
return [{ term: { [field]: value } }];
}
export function termsQuery(
field: string,
...values: Array<string | boolean | undefined | number | null>
): QueryDslQueryContainer[] {
const filtered = reject(values, isUndefinedOrNull);
if (!filtered.length) {
return [];
}
return [{ terms: { [field]: filtered } }];
}
export function rangeQuery(

View file

@ -16,6 +16,7 @@ import { APMFtrConfigName } from '../configs';
import { createApmApiClient } from './apm_api_supertest';
import { RegistryProvider } from './registry';
import { synthtraceEsClientService } from './synthtrace_es_client_service';
import { MachineLearningAPIProvider } from '../../functional/services/ml/api';
export interface ApmFtrConfig {
name: APMFtrConfigName;
@ -99,7 +100,7 @@ export function createTestConfig(config: ApmFtrConfig) {
),
};
},
ml: MachineLearningAPIProvider,
// legacy clients
legacySupertestAsNoAccessUser: getLegacySupertestClient(kibanaServer, ApmUser.noAccessUser),
legacySupertestAsApmReadUser: getLegacySupertestClient(kibanaServer, ApmUser.apmReadUser),

View file

@ -0,0 +1,358 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { range, omit } from 'lodash';
import { apm, timerange } from '@elastic/apm-synthtrace';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { ApmApiError } from '../../common/apm_api_supertest';
import job from '../../../../plugins/ml/server/models/data_recognizer/modules/apm_transaction/ml/apm_tx_metrics.json';
import datafeed from '../../../../plugins/ml/server/models/data_recognizer/modules/apm_transaction/ml/datafeed_apm_tx_metrics.json';
import { ServiceAnomalyTimeseries } from '../../../../plugins/apm/common/anomaly_detection/service_anomaly_timeseries';
import { ApmMlDetectorType } from '../../../../plugins/apm/common/anomaly_detection/apm_ml_detectors';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const ml = getService('ml');
const synthtraceEsClient = getService('synthtraceEsClient');
async function statusOf(p: Promise<{ status: number }>) {
try {
const { status } = await p;
return status;
} catch (err) {
if (err instanceof ApmApiError) {
return err.res.status;
}
throw err;
}
}
function getAnomalyCharts(
{
start,
end,
transactionType,
serviceName,
}: {
start: string;
end: string;
transactionType: string;
serviceName: string;
},
user = apmApiClient.readUser
) {
return user({
endpoint: 'GET /internal/apm/services/{serviceName}/anomaly_charts',
params: {
path: {
serviceName,
},
query: {
start,
end,
transactionType,
},
},
});
}
registry.when(
'fetching service anomalies with a basic license',
{ config: 'basic', archives: ['apm_mappings_only_8.0.0'] },
() => {
it('returns a 501', async () => {
const status = await statusOf(
getAnomalyCharts({
serviceName: 'a',
transactionType: 'request',
start: '2021-01-01T00:00:00.000Z',
end: '2021-01-01T00:15:00.000Z',
})
);
expect(status).to.eql(501);
});
}
);
registry.when(
'fetching service anomalies with a trial license',
{ config: 'trial', archives: ['apm_mappings_only_8.0.0'] },
() => {
const start = '2021-01-01T00:00:00.000Z';
const end = '2021-01-08T00:15:00.000Z';
const spikeStart = new Date('2021-01-03T00:00:00.000Z').getTime();
const spikeEnd = new Date('2021-01-03T02:00:00.000Z').getTime();
const NORMAL_DURATION = 100;
const NORMAL_RATE = 1;
before(async () => {
const serviceA = apm.service('a', 'production', 'java').instance('a');
const serviceB = apm.service('b', 'development', 'go').instance('b');
const events = timerange(new Date(start).getTime(), new Date(end).getTime())
.interval('1m')
.rate(1)
.flatMap((timestamp) => {
const isInSpike = timestamp >= spikeStart && timestamp < spikeEnd;
const count = isInSpike ? 4 : NORMAL_RATE;
const duration = isInSpike ? 1000 : NORMAL_DURATION;
const outcome = isInSpike ? 'failure' : 'success';
return [
...range(0, count).flatMap((_) =>
serviceA
.transaction('tx', 'request')
.timestamp(timestamp)
.duration(duration)
.outcome(outcome)
.serialize()
),
...serviceB
.transaction('tx', 'Worker')
.timestamp(timestamp)
.duration(duration)
.success()
.serialize(),
];
});
await synthtraceEsClient.index(events);
});
after(async () => {
await synthtraceEsClient.clean();
});
it('returns a 403 for a user without access to ML', async () => {
expect(
await statusOf(
getAnomalyCharts(
{
serviceName: 'a',
transactionType: 'request',
start,
end,
},
apmApiClient.noMlAccessUser
)
)
).to.eql(403);
});
describe('without ml jobs', () => {
it('returns a 200 for a user _with_ access to ML', async () => {
const status = await statusOf(
getAnomalyCharts({
serviceName: 'a',
transactionType: 'request',
start,
end,
})
);
expect(status).to.eql(200);
});
});
describe('with ml jobs', () => {
before(async () => {
await Promise.all([
ml.createAndRunAnomalyDetectionLookbackJob(
// @ts-expect-error not entire job config
{
...job,
job_id: 'apm-tx-metrics-prod',
allow_lazy_open: false,
custom_settings: {
job_tags: {
apm_ml_version: '3',
environment: 'production',
},
},
},
{
...datafeed,
job_id: 'apm-tx-metrics-prod',
indices: ['apm-*'],
datafeed_id: 'apm-tx-metrics-prod-datafeed',
query: {
bool: {
filter: [
...datafeed.query.bool.filter,
{ term: { 'service.environment': 'production' } },
],
},
},
}
),
ml.createAndRunAnomalyDetectionLookbackJob(
// @ts-expect-error not entire job config
{
...job,
job_id: 'apm-tx-metrics-development',
allow_lazy_open: false,
custom_settings: {
job_tags: {
apm_ml_version: '3',
environment: 'development',
},
},
},
{
...datafeed,
job_id: 'apm-tx-metrics-development',
indices: ['apm-*'],
datafeed_id: 'apm-tx-metrics-development-datafeed',
query: {
bool: {
filter: [
...datafeed.query.bool.filter,
{ term: { 'service.environment': 'development' } },
],
},
},
}
),
]);
});
after(async () => {
await ml.cleanMlIndices();
});
it('returns a 200 for a user _with_ access to ML', async () => {
const status = await statusOf(
getAnomalyCharts({
serviceName: 'a',
transactionType: 'request',
start,
end,
})
);
expect(status).to.eql(200);
});
describe('inspecting the body', () => {
let allAnomalyTimeseries: ServiceAnomalyTimeseries[];
let latencySeries: ServiceAnomalyTimeseries | undefined;
let throughputSeries: ServiceAnomalyTimeseries | undefined;
let failureRateSeries: ServiceAnomalyTimeseries | undefined;
before(async () => {
allAnomalyTimeseries = (
await getAnomalyCharts({
serviceName: 'a',
transactionType: 'request',
start,
end,
})
).body.allAnomalyTimeseries;
latencySeries = allAnomalyTimeseries.find(
(spec) => spec.type === ApmMlDetectorType.txLatency
);
throughputSeries = allAnomalyTimeseries.find(
(spec) => spec.type === ApmMlDetectorType.txThroughput
);
failureRateSeries = allAnomalyTimeseries.find(
(spec) => spec.type === ApmMlDetectorType.txFailureRate
);
});
it('returns model plots for all detectors and job ids for the given transaction type', () => {
expect(allAnomalyTimeseries.length).to.eql(3);
expect(
allAnomalyTimeseries.every((spec) => spec.bounds.some((bound) => bound.y0 ?? 0 > 0))
);
});
it('returns the correct metadata', () => {
function omitTimeseriesData(series: ServiceAnomalyTimeseries | undefined) {
return series ? omit(series, 'anomalies', 'bounds') : undefined;
}
expect(omitTimeseriesData(latencySeries)).to.eql({
type: ApmMlDetectorType.txLatency,
jobId: 'apm-tx-metrics-prod',
serviceName: 'a',
environment: 'production',
transactionType: 'request',
version: 3,
});
expect(omitTimeseriesData(throughputSeries)).to.eql({
type: ApmMlDetectorType.txThroughput,
jobId: 'apm-tx-metrics-prod',
serviceName: 'a',
environment: 'production',
transactionType: 'request',
version: 3,
});
expect(omitTimeseriesData(failureRateSeries)).to.eql({
type: ApmMlDetectorType.txFailureRate,
jobId: 'apm-tx-metrics-prod',
serviceName: 'a',
environment: 'production',
transactionType: 'request',
version: 3,
});
});
it('returns anomalies for during the spike', () => {
const latencyAnomalies = latencySeries?.anomalies.filter(
(anomaly) => anomaly.y ?? 0 > 0
);
const throughputAnomalies = throughputSeries?.anomalies.filter(
(anomaly) => anomaly.y ?? 0 > 0
);
const failureRateAnomalies = failureRateSeries?.anomalies.filter(
(anomaly) => anomaly.y ?? 0 > 0
);
expect(latencyAnomalies?.length).to.be.greaterThan(0);
expect(throughputAnomalies?.length).to.be.greaterThan(0);
expect(failureRateAnomalies?.length).to.be.greaterThan(0);
expect(
latencyAnomalies?.every(
(anomaly) => anomaly.x >= spikeStart && (anomaly.actual ?? 0) > NORMAL_DURATION
)
);
expect(
throughputAnomalies?.every(
(anomaly) => anomaly.x >= spikeStart && (anomaly.actual ?? 0) > NORMAL_RATE
)
);
expect(
failureRateAnomalies?.every(
(anomaly) => anomaly.x >= spikeStart && (anomaly.actual ?? 0) > 0
)
);
});
});
});
}
);
}

View file

@ -129,18 +129,3 @@ Array [
},
]
`;
exports[`APM API tests trial apm_8.0.0 Transaction latency with a trial license when data is loaded with environment selected should return a non-empty anomaly series 1`] = `
Array [
Object {
"x": 1627974000000,
"y": 85916.7945636914,
"y0": 24329.7607058434,
},
Object {
"x": 1627974900000,
"y": 88154.9755916935,
"y0": 23498.4070709888,
},
]
`;

View file

@ -295,24 +295,6 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('should have a successful response', () => {
expect(response.status).to.eql(200);
});
it('should return the ML job id for anomalies of the selected environment', () => {
const latencyChartReturn = response.body as LatencyChartReturnType;
expect(latencyChartReturn).to.have.property('anomalyTimeseries');
expect(latencyChartReturn.anomalyTimeseries).to.have.property('jobId');
expectSnapshot(latencyChartReturn.anomalyTimeseries?.jobId).toMatchInline(
`"apm-production-6117-high_mean_transaction_duration"`
);
});
it('should return a non-empty anomaly series', () => {
const latencyChartReturn = response.body as LatencyChartReturnType;
expect(latencyChartReturn).to.have.property('anomalyTimeseries');
expect(latencyChartReturn.anomalyTimeseries?.anomalyBoundaries?.length).to.be.greaterThan(
0
);
expectSnapshot(latencyChartReturn.anomalyTimeseries?.anomalyBoundaries).toMatch();
});
});
describe('with all environments selected', () => {
@ -335,11 +317,6 @@ export default function ApiTest({ getService }: FtrProviderContext) {
it('should have a successful response', () => {
expect(response.status).to.eql(200);
});
it('should not return anomaly timeseries data', () => {
const latencyChartReturn = response.body as LatencyChartReturnType;
expect(latencyChartReturn).to.not.have.property('anomalyTimeseries');
});
});
}
);