mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b9ebc5f617
commit
563d7935d0
51 changed files with 1537 additions and 572 deletions
|
@ -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())];
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
);
|
||||
}
|
|
@ -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 }>;
|
||||
}
|
|
@ -184,10 +184,7 @@ export function ErrorGroupOverview() {
|
|||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<FailedTransactionRateChart
|
||||
kuery={kuery}
|
||||
environment={environment}
|
||||
/>
|
||||
<FailedTransactionRateChart kuery={kuery} />
|
||||
</EuiFlexItem>
|
||||
</ChartPointerEventContextProvider>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 />}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -95,8 +95,8 @@ export const settings = {
|
|||
}),
|
||||
params: t.partial({
|
||||
query: t.partial({
|
||||
name: t.string,
|
||||
environment: t.string,
|
||||
name: t.string,
|
||||
pageStep: agentConfigurationPageStepRt,
|
||||
}),
|
||||
}),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ export default {
|
|||
alerts: [],
|
||||
transactionTypes: [],
|
||||
serviceName,
|
||||
fallbackToTransactions: false,
|
||||
}}
|
||||
>
|
||||
<KibanaContext.Provider>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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] ===
|
||||
|
|
|
@ -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 },
|
||||
},
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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[];
|
||||
}
|
|
@ -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[];
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
>;
|
||||
});
|
||||
}
|
|
@ -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];
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue