mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[APM] Mobile crashes & errors (#165892)
## Summary This PR adds back the `Errors` tab to mobile apm services under the title `Errors & Crashes`. This new page is split into too sections: errors, and crashes. Error Tab: <img width="1456" alt="Screenshot 2023-10-25 at 10 57 00" src="20277c31
-d88c-44ae-b896-1da4223cb392"> Crashes Tab: <img width="1454" alt="Screenshot 2023-10-25 at 10 57 35" src="2b0dea23
-cbab-4e68-a14a-c3b14d4bd860"> ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Katerina <kate@kpatticha.com>
This commit is contained in:
parent
eac36e8024
commit
33c74aeb03
71 changed files with 5539 additions and 71 deletions
|
@ -13,6 +13,7 @@ Learn how to perform common APM app tasks.
|
|||
* <<correlations>>
|
||||
* <<agent-explorer>>
|
||||
* <<machine-learning-integration>>
|
||||
* <<mobile-session-explorer>>
|
||||
* <<apm-lambda>>
|
||||
* <<advanced-queries>>
|
||||
* <<storage-explorer>>
|
||||
|
@ -35,6 +36,8 @@ include::agent-explorer.asciidoc[]
|
|||
|
||||
include::machine-learning.asciidoc[]
|
||||
|
||||
include::mobile-session-explorer.asciidoc[]
|
||||
|
||||
include::lambda.asciidoc[]
|
||||
|
||||
include::advanced-queries.asciidoc[]
|
||||
|
|
BIN
docs/apm/images/mobile-session-error-details.png
Normal file
BIN
docs/apm/images/mobile-session-error-details.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 374 KiB |
BIN
docs/apm/images/mobile-session-explorer-apm.png
Normal file
BIN
docs/apm/images/mobile-session-explorer-apm.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1 MiB |
BIN
docs/apm/images/mobile-session-explorer-nav.png
Normal file
BIN
docs/apm/images/mobile-session-explorer-nav.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 997 KiB |
BIN
docs/apm/images/mobile-session-filter-discover.png
Normal file
BIN
docs/apm/images/mobile-session-filter-discover.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
36
docs/apm/mobile-errors.asciidoc
Normal file
36
docs/apm/mobile-errors.asciidoc
Normal file
|
@ -0,0 +1,36 @@
|
|||
[role="xpack"]
|
||||
[[mobile-errors-crashes]]
|
||||
=== Mobile errors and crashes
|
||||
|
||||
TIP: {apm-guide-ref}/data-model-errors.html[Errors] are groups of exceptions with a similar exception or log message.
|
||||
|
||||
The *Errors & Crashes* overview provides a high-level view of errors and crashes that APM mobile agents catch,
|
||||
or that users manually report with APM agent APIs. Errors and crashes are separated into two tabs for easy differentiation.
|
||||
Like errors are grouped together to make it easy to quickly see which errors are affecting your services,
|
||||
and to take actions to rectify them.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[role="screenshot"]
|
||||
image::apm/images/mobile-errors-overview.png[Mobile Errors overview]
|
||||
|
||||
Selecting an error group ID or error message brings you to the *Error group*.
|
||||
|
||||
[role="screenshot"]
|
||||
image::apm/images/mobile-error-group.png[Mobile Error group]
|
||||
|
||||
The error group details page visualizes the number of error occurrences over time and compared to a recent time range.
|
||||
This allows you to quickly determine if the error rate is changing or remaining constant.
|
||||
You'll also see the "most affected" chart which can be oriented to 'by device' or 'by app version'.
|
||||
|
||||
Further down, you'll see an Error sample.
|
||||
The error shown is always the most recent to occur.
|
||||
The sample includes the exception message, culprit, stack trace where the error occurred (when available),
|
||||
and additional contextual information to help debug the issue--all of which can be copied with the click of a button.
|
||||
|
||||
In some cases, you might also see a Transaction sample ID.
|
||||
This feature allows you to make a connection between the errors and transactions,
|
||||
by linking you to the specific transaction where the error occurred.
|
||||
This allows you to see the whole trace, including which services the request went through.
|
|
@ -9,7 +9,7 @@ to make data-driven decisions about how to improve your user experience.
|
|||
|
||||
For example, see:
|
||||
|
||||
* Crash Rate (Crashes per minute) -- coming soon
|
||||
* Crash Rate (Crashes per session)
|
||||
* Slowest App load time -- coming soon
|
||||
* Number of sessions
|
||||
* Number of HTTP requests
|
||||
|
@ -28,6 +28,8 @@ of their mobile application environment and the impact of backend errors and bot
|
|||
Understand the impact of slow application load times and variations in application crash rate on user traffic (coming soon).
|
||||
Visualize session and HTTP trends, and see where your users are located--enabling you to optimize your infrastructure deployment and routing topology.
|
||||
|
||||
Note: due to the way crash rate is calculated (crashes per session) it is possible to have greater than 100% rate, due to fact that a session may contain multiple crashes.
|
||||
|
||||
[role="screenshot"]
|
||||
image::apm/images/mobile-location.png[mobile service overview centered on location map]
|
||||
|
||||
|
|
43
docs/apm/mobile-session-explorer.asciidoc
Normal file
43
docs/apm/mobile-session-explorer.asciidoc
Normal file
|
@ -0,0 +1,43 @@
|
|||
[role="xpack]
|
||||
[[mobile-session-explorer]]
|
||||
=== Exploring mobile sessions with Discover
|
||||
Elastic Mobile APM provides session tracking by attaching a `session.id`, a guid, to every span and event.
|
||||
This allows for the recall of the activities of a specific user during a specific period of time. The best way recall
|
||||
these data points is using the xref:document-explorer[Discover document explorer]. This guide will explain how to do that.
|
||||
|
||||
=== Viewing sessions with Discover
|
||||
|
||||
The first step is to find the relevant `session.id`. In this example, we'll walk through investigating a crash.
|
||||
Since all events and spans have `session.id` attributes, a crash is no different.
|
||||
|
||||
The steps to follow are:
|
||||
|
||||
* copy the `session.id` from the relevant document.
|
||||
* Open the Discover page.
|
||||
* Select the appropriate data view (use `APM` to search all datastreams)
|
||||
* set filter to the copied `session.id`
|
||||
|
||||
Here we can see the `session.id` guid in the metadata viewer in the error detail view:
|
||||
[role="screenshot"]
|
||||
image::images/mobile-session-error-details.png[Example of session.id in error details]
|
||||
|
||||
Copy this value and open the Discover page:
|
||||
|
||||
[role="screenshot"]
|
||||
image::images/mobile-session-explorer-nav.png[Example view of navigation to Discover]
|
||||
|
||||
|
||||
set the data view. `APM` selected in the example:
|
||||
|
||||
[role="screenshot"]
|
||||
image::images/mobile-session-explorer-apm.png[Example view of Explorer selecting APM data view]
|
||||
|
||||
filter using the `session.id`: `session.id: "<copied session id guid>"`:
|
||||
|
||||
[role="screenshot"]
|
||||
image::images/mobile-session-filter-discover.png[Filter Explor using session.id]
|
||||
|
||||
explore all the documents associated with that session id including crashes, lifecycle events, network requests, errors, and other custom events!
|
||||
|
||||
|
||||
|
|
@ -64,6 +64,14 @@ export class Instance extends Entity<ApmFields> {
|
|||
});
|
||||
}
|
||||
|
||||
crash({ message, type }: { message: string; type?: string }) {
|
||||
return new ApmError({
|
||||
...this.fields,
|
||||
'error.type': 'crash',
|
||||
'error.exception': [{ message, ...(type ? { type } : {}) }],
|
||||
'error.grouping_name': getErrorGroupingKey(message),
|
||||
});
|
||||
}
|
||||
error({ message, type }: { message: string; type?: string }) {
|
||||
return new ApmError({
|
||||
...this.fields,
|
||||
|
|
|
@ -5,7 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getKueryWithMobileFilters } from './get_kuery_with_mobile_filters';
|
||||
import {
|
||||
getKueryWithMobileFilters,
|
||||
getKueryWithMobileCrashFilter,
|
||||
getKueryWithMobileErrorFilter,
|
||||
} from './get_kuery_with_mobile_filters';
|
||||
describe('getKueryWithMobileFilters', () => {
|
||||
it('should handle empty and undefined values', () => {
|
||||
const result = getKueryWithMobileFilters({
|
||||
|
@ -70,3 +74,68 @@ describe('getKueryWithMobileFilters', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKueryWithMobileCrashFilter', () => {
|
||||
it('should handle empty and undefined values', () => {
|
||||
const result = getKueryWithMobileCrashFilter({
|
||||
groupId: undefined,
|
||||
kuery: '',
|
||||
});
|
||||
expect(result).toBe('error.type: crash');
|
||||
});
|
||||
it('should return kuery and crash filter when groupId is empty', () => {
|
||||
const result = getKueryWithMobileCrashFilter({
|
||||
groupId: undefined,
|
||||
kuery: 'foo.bar: test',
|
||||
});
|
||||
expect(result).toBe('foo.bar: test and error.type: crash');
|
||||
});
|
||||
it('should return crash filter and groupId when kuery is empty', () => {
|
||||
const result = getKueryWithMobileCrashFilter({
|
||||
groupId: '1',
|
||||
kuery: '',
|
||||
});
|
||||
expect(result).toBe('error.type: crash and error.grouping_key: 1');
|
||||
});
|
||||
it('should return crash filter, groupId, and kuery in kql format', () => {
|
||||
const result = getKueryWithMobileCrashFilter({
|
||||
groupId: '1',
|
||||
kuery: 'foo.bar: test',
|
||||
});
|
||||
expect(result).toBe(
|
||||
'foo.bar: test and error.type: crash and error.grouping_key: 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('getKueryWithMobileErrorFilter', () => {
|
||||
it('should handle empty and undefined values', () => {
|
||||
const result = getKueryWithMobileErrorFilter({
|
||||
groupId: undefined,
|
||||
kuery: '',
|
||||
});
|
||||
expect(result).toBe('NOT error.type: crash');
|
||||
});
|
||||
it('should return kuery and error filter when groupId is empty', () => {
|
||||
const result = getKueryWithMobileErrorFilter({
|
||||
kuery: 'foo.bar: test',
|
||||
groupId: undefined,
|
||||
});
|
||||
expect(result).toBe('foo.bar: test and NOT error.type: crash');
|
||||
});
|
||||
it('should return error filter and groupId when kuery is empty', () => {
|
||||
const result = getKueryWithMobileErrorFilter({
|
||||
groupId: '1',
|
||||
kuery: '',
|
||||
});
|
||||
expect(result).toBe('NOT error.type: crash and error.grouping_key: 1');
|
||||
});
|
||||
it('should return error filter, groupId, and kuery in kql format', () => {
|
||||
const result = getKueryWithMobileErrorFilter({
|
||||
groupId: '1',
|
||||
kuery: 'foo.bar: test',
|
||||
});
|
||||
expect(result).toBe(
|
||||
'foo.bar: test and NOT error.type: crash and error.grouping_key: 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,6 +10,8 @@ import {
|
|||
DEVICE_MODEL_IDENTIFIER,
|
||||
NETWORK_CONNECTION_TYPE,
|
||||
SERVICE_VERSION,
|
||||
ERROR_TYPE,
|
||||
ERROR_GROUP_ID,
|
||||
} from '../es_fields/apm';
|
||||
import { fieldValuePairToKql } from './field_value_pair_to_kql';
|
||||
|
||||
|
@ -38,3 +40,37 @@ export function getKueryWithMobileFilters({
|
|||
|
||||
return kueryWithFilters;
|
||||
}
|
||||
|
||||
export function getKueryWithMobileCrashFilter({
|
||||
groupId,
|
||||
kuery,
|
||||
}: {
|
||||
groupId: string | undefined;
|
||||
kuery: string;
|
||||
}) {
|
||||
const kueryWithFilters = [
|
||||
kuery,
|
||||
...fieldValuePairToKql(ERROR_TYPE, 'crash'),
|
||||
...fieldValuePairToKql(ERROR_GROUP_ID, groupId),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
return kueryWithFilters;
|
||||
}
|
||||
|
||||
export function getKueryWithMobileErrorFilter({
|
||||
groupId,
|
||||
kuery,
|
||||
}: {
|
||||
groupId: string | undefined;
|
||||
kuery: string;
|
||||
}) {
|
||||
const kueryWithFilters = [
|
||||
kuery,
|
||||
`NOT ${ERROR_TYPE}: crash`,
|
||||
...fieldValuePairToKql(ERROR_GROUP_ID, groupId),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
return kueryWithFilters;
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ import { TraceSearchType } from '../../../../../common/trace_explorer';
|
|||
import { APMError } from '../../../../../typings/es_schemas/ui/apm_error';
|
||||
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
|
||||
import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params';
|
||||
import { useApmParams } from '../../../../hooks/use_apm_params';
|
||||
import { useAnyOfApmParams } from '../../../../hooks/use_apm_params';
|
||||
import { useApmRouter } from '../../../../hooks/use_apm_router';
|
||||
import { FETCH_STATUS, isPending } from '../../../../hooks/use_fetcher';
|
||||
import { useTraceExplorerEnabledSetting } from '../../../../hooks/use_trace_explorer_enabled_setting';
|
||||
|
@ -102,7 +102,11 @@ export function ErrorSampleDetails({
|
|||
const {
|
||||
path: { groupId },
|
||||
query,
|
||||
} = useApmParams('/services/{serviceName}/errors/{groupId}');
|
||||
} = useAnyOfApmParams(
|
||||
'/services/{serviceName}/errors/{groupId}',
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/errors/{groupId}',
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/crashes/{groupId}'
|
||||
);
|
||||
|
||||
const { kuery } = query;
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import { EuiLoadingSpinner } from '@elastic/eui';
|
|||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { fromQuery, toQuery } from '../../../shared/links/url_helpers';
|
||||
import { useApmParams } from '../../../../hooks/use_apm_params';
|
||||
import { useAnyOfApmParams } from '../../../../hooks/use_apm_params';
|
||||
import {
|
||||
FETCH_STATUS,
|
||||
isPending,
|
||||
|
@ -36,7 +36,11 @@ export function ErrorSampler({
|
|||
const {
|
||||
path: { groupId },
|
||||
query,
|
||||
} = useApmParams('/services/{serviceName}/errors/{groupId}');
|
||||
} = useAnyOfApmParams(
|
||||
'/services/{serviceName}/errors/{groupId}',
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/errors/{groupId}',
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/crashes/{groupId}'
|
||||
);
|
||||
|
||||
const { rangeFrom, rangeTo, environment, kuery, errorId } = query;
|
||||
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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, { useState } from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { TreemapSelect, TreemapTypes } from './treemap_select';
|
||||
import { TreemapChart } from '../../../../shared/charts/treemap_chart';
|
||||
import { useFetcher } from '../../../../../hooks/use_fetcher';
|
||||
import {
|
||||
DEVICE_MODEL_IDENTIFIER,
|
||||
SERVICE_VERSION,
|
||||
} from '../../../../../../common/es_fields/apm';
|
||||
|
||||
const ES_FIELD_MAPPING: Record<TreemapTypes, string> = {
|
||||
[TreemapTypes.Devices]: DEVICE_MODEL_IDENTIFIER,
|
||||
[TreemapTypes.Versions]: SERVICE_VERSION,
|
||||
};
|
||||
|
||||
export function MobileErrorsAndCrashesTreemap({
|
||||
kuery,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
environment,
|
||||
}: {
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
start: string;
|
||||
end: string;
|
||||
environment: string;
|
||||
}) {
|
||||
const [selectedTreemap, selectTreemap] = useState(TreemapTypes.Devices);
|
||||
|
||||
const { data, status } = useFetcher(
|
||||
(callApmApi) => {
|
||||
const fieldName = ES_FIELD_MAPPING[selectedTreemap];
|
||||
if (fieldName) {
|
||||
return callApmApi(
|
||||
'GET /internal/apm/mobile-services/{serviceName}/error_terms',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
environment,
|
||||
kuery,
|
||||
start,
|
||||
end,
|
||||
fieldName,
|
||||
size: 500,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[environment, kuery, serviceName, start, end, selectedTreemap]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<TreemapSelect
|
||||
selectedTreemap={selectedTreemap}
|
||||
onChange={selectTreemap}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<TreemapChart
|
||||
fetchStatus={status}
|
||||
data={data?.terms ?? []}
|
||||
id="device-treemap"
|
||||
height={320}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiTitle,
|
||||
EuiSuperSelect,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import type { EuiSuperSelectOption } from '@elastic/eui';
|
||||
|
||||
export enum TreemapTypes {
|
||||
Devices = 'devices',
|
||||
Versions = 'versions',
|
||||
}
|
||||
|
||||
const options: Array<EuiSuperSelectOption<TreemapTypes>> = [
|
||||
{
|
||||
value: TreemapTypes.Devices,
|
||||
label: i18n.translate(
|
||||
'xpack.apm.transactionOverview.treemap.dropdown.devices',
|
||||
{
|
||||
defaultMessage: 'Devices',
|
||||
}
|
||||
),
|
||||
description: i18n.translate(
|
||||
'xpack.apm.errorOverview.treemap.dropdown.devices.subtitle',
|
||||
{
|
||||
defaultMessage:
|
||||
'This treemap view allows for easy and faster visual way the most affected devices',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TreemapTypes.Versions,
|
||||
label: i18n.translate(
|
||||
'xpack.apm.transactionOverview.treemap.versions.devices',
|
||||
{
|
||||
defaultMessage: 'Versions',
|
||||
}
|
||||
),
|
||||
description: i18n.translate(
|
||||
'xpack.apm.errorOverview.treemap.dropdown.versions.subtitle',
|
||||
{
|
||||
defaultMessage:
|
||||
'This treemap view allows for easy and faster visual way the most affected versions.',
|
||||
}
|
||||
),
|
||||
},
|
||||
].map(({ value, label, description }) => ({
|
||||
inputDisplay: label,
|
||||
value,
|
||||
dropdownDisplay: (
|
||||
<>
|
||||
<strong>{label}</strong>
|
||||
<EuiText size="s" color="subdued">
|
||||
<p>{description}</p>
|
||||
</EuiText>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
export function TreemapSelect({
|
||||
selectedTreemap,
|
||||
onChange,
|
||||
}: {
|
||||
selectedTreemap: TreemapTypes;
|
||||
onChange: (value: TreemapTypes) => void;
|
||||
}) {
|
||||
const currentTreemap =
|
||||
options.find(({ value }) => value === selectedTreemap) ?? options[0];
|
||||
|
||||
return (
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xs">
|
||||
<h2>
|
||||
{i18n.translate('xpack.apm.errorOverview.treemap.title', {
|
||||
defaultMessage: 'Most affected {currentTreemap}',
|
||||
values: { currentTreemap: currentTreemap.value },
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiText size="s" color="subdued">
|
||||
{i18n.translate('xpack.apm.errorOverview.treemap.subtitle', {
|
||||
defaultMessage:
|
||||
'Treemap showing the total and most affected {currentTreemap}',
|
||||
values: { currentTreemap: currentTreemap.value },
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText style={{ fontWeight: 'bold' }} size="s">
|
||||
{i18n.translate('xpack.apm.transactionOverview.treemap.show', {
|
||||
defaultMessage: 'Show',
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSuperSelect
|
||||
fullWidth
|
||||
style={{ minWidth: '300px' }}
|
||||
options={options}
|
||||
valueOfSelected={selectedTreemap}
|
||||
onChange={onChange}
|
||||
itemLayoutAlign="top"
|
||||
hasDividers
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiPanel,
|
||||
EuiTitle,
|
||||
EuiIconTip,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { getComparisonChartTheme } from '../../../../shared/time_comparison/get_comparison_chart_theme';
|
||||
import { TimeseriesChartWithContext } from '../../../../shared/charts/timeseries_chart_with_context';
|
||||
|
||||
import { useFetcher } from '../../../../../hooks/use_fetcher';
|
||||
|
||||
import {
|
||||
ChartType,
|
||||
getTimeSeriesColor,
|
||||
} from '../../../../shared/charts/helper/get_timeseries_color';
|
||||
import { usePreviousPeriodLabel } from '../../../../../hooks/use_previous_period_text';
|
||||
|
||||
const INITIAL_STATE = {
|
||||
currentPeriod: { timeseries: [] },
|
||||
previousPeriod: { timeseries: [] },
|
||||
};
|
||||
|
||||
export function HttpErrorRateChart({
|
||||
height,
|
||||
kuery,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
environment,
|
||||
offset,
|
||||
comparisonEnabled,
|
||||
}: {
|
||||
height: number;
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
start: string;
|
||||
end: string;
|
||||
environment: string;
|
||||
offset?: string;
|
||||
comparisonEnabled: boolean;
|
||||
}) {
|
||||
const comparisonChartTheme = getComparisonChartTheme();
|
||||
const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor(
|
||||
ChartType.HTTP_REQUESTS
|
||||
);
|
||||
const { data = INITIAL_STATE, status } = useFetcher(
|
||||
(callApmApi) => {
|
||||
return callApmApi(
|
||||
'GET /internal/apm/mobile-services/{serviceName}/error/http_error_rate',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
environment,
|
||||
kuery,
|
||||
start,
|
||||
end,
|
||||
offset: comparisonEnabled ? offset : undefined,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[environment, kuery, serviceName, start, end, offset, comparisonEnabled]
|
||||
);
|
||||
|
||||
const previousPeriodLabel = usePreviousPeriodLabel();
|
||||
|
||||
const timeseries = [
|
||||
{
|
||||
data: data.currentPeriod.timeseries,
|
||||
type: 'linemark',
|
||||
color: currentPeriodColor,
|
||||
title: i18n.translate('xpack.apm.errors.httpErrorRateTitle', {
|
||||
defaultMessage: 'HTTP error rate',
|
||||
}),
|
||||
},
|
||||
...(comparisonEnabled
|
||||
? [
|
||||
{
|
||||
data: data.previousPeriod.timeseries,
|
||||
type: 'area',
|
||||
color: previousPeriodColor,
|
||||
title: previousPeriodLabel,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiPanel hasBorder={true}>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xs">
|
||||
<h2>
|
||||
{i18n.translate('xpack.apm.mobile.errors.httpErrorRate', {
|
||||
defaultMessage: 'HTTP Error Rate',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip
|
||||
content={i18n.translate(
|
||||
'xpack.apm.mobile.errors.httpErrorRateTooltip',
|
||||
{
|
||||
defaultMessage: 'Http error rate consisting of 4xx & 5xx.',
|
||||
}
|
||||
)}
|
||||
position="right"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<TimeseriesChartWithContext
|
||||
id="httpErrors"
|
||||
height={height}
|
||||
showAnnotations={false}
|
||||
fetchStatus={status}
|
||||
timeseries={timeseries}
|
||||
customTheme={comparisonChartTheme}
|
||||
yLabelFormat={(y) => `${y}`}
|
||||
/>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* 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, { useState } from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { TreemapSelect, TreemapTypes } from './treemap_select';
|
||||
import { TreemapChart } from '../../../../shared/charts/treemap_chart';
|
||||
import { useFetcher } from '../../../../../hooks/use_fetcher';
|
||||
import {
|
||||
DEVICE_MODEL_IDENTIFIER,
|
||||
HOST_OS_VERSION,
|
||||
SERVICE_VERSION,
|
||||
} from '../../../../../../common/es_fields/apm';
|
||||
|
||||
const ES_FIELD_MAPPING: Record<TreemapTypes, string> = {
|
||||
[TreemapTypes.Devices]: DEVICE_MODEL_IDENTIFIER,
|
||||
[TreemapTypes.AppVersions]: SERVICE_VERSION,
|
||||
[TreemapTypes.OsVersions]: HOST_OS_VERSION,
|
||||
};
|
||||
|
||||
export function MobileTreemap({
|
||||
kuery,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
environment,
|
||||
}: {
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
start: string;
|
||||
end: string;
|
||||
environment: string;
|
||||
}) {
|
||||
const [selectedTreemap, selectTreemap] = useState(TreemapTypes.Devices);
|
||||
|
||||
const { data, status } = useFetcher(
|
||||
(callApmApi) => {
|
||||
const fieldName = ES_FIELD_MAPPING[selectedTreemap];
|
||||
if (fieldName) {
|
||||
return callApmApi(
|
||||
'GET /internal/apm/mobile-services/{serviceName}/terms',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
environment,
|
||||
kuery,
|
||||
start,
|
||||
end,
|
||||
fieldName,
|
||||
size: 500,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[environment, kuery, serviceName, start, end, selectedTreemap]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<TreemapSelect
|
||||
selectedTreemap={selectedTreemap}
|
||||
onChange={selectTreemap}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<TreemapChart
|
||||
fetchStatus={status}
|
||||
data={data?.terms ?? []}
|
||||
id="device-treemap"
|
||||
height={320}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiTitle,
|
||||
EuiSuperSelect,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import type { EuiSuperSelectOption } from '@elastic/eui';
|
||||
|
||||
export enum TreemapTypes {
|
||||
OsVersions = 'osVersions',
|
||||
AppVersions = 'appVersions',
|
||||
Devices = 'devices',
|
||||
}
|
||||
|
||||
const options: Array<EuiSuperSelectOption<TreemapTypes>> = [
|
||||
{
|
||||
value: TreemapTypes.Devices,
|
||||
label: i18n.translate(
|
||||
'xpack.apm.mobile.errorOverview.treemap.dropdown.devices',
|
||||
{
|
||||
defaultMessage: 'Devices',
|
||||
}
|
||||
),
|
||||
description: i18n.translate(
|
||||
'xpack.apm.mobile.errorOverview.treemap.dropdown.devices.subtitle',
|
||||
{
|
||||
defaultMessage: 'Treemap displaying the most affected devices.',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TreemapTypes.AppVersions,
|
||||
label: i18n.translate(
|
||||
'xpack.apm.mobile.errorOverview.treemap.versions.devices',
|
||||
{
|
||||
defaultMessage: 'App versions',
|
||||
}
|
||||
),
|
||||
description: i18n.translate(
|
||||
'xpack.apm.mobile.errorOverview.treemap.dropdown.versions.subtitle',
|
||||
{
|
||||
defaultMessage:
|
||||
'Treemap displaying the most affected application versions.',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TreemapTypes.OsVersions,
|
||||
label: i18n.translate(
|
||||
'xpack.apm.mobile.errorOverview.treemap.dropdown.osVersions',
|
||||
{
|
||||
defaultMessage: 'OS versions',
|
||||
}
|
||||
),
|
||||
description: i18n.translate(
|
||||
'xpack.apm.mobile.errorOverview.treemap.dropdown.osVersions.subtitle',
|
||||
{
|
||||
defaultMessage: 'Treemap displaying the most affected OS versions.',
|
||||
}
|
||||
),
|
||||
},
|
||||
].map(({ value, label, description }) => ({
|
||||
inputDisplay: label,
|
||||
value,
|
||||
dropdownDisplay: (
|
||||
<>
|
||||
<strong>{label}</strong>
|
||||
<EuiText size="s" color="subdued">
|
||||
<p>{description}</p>
|
||||
</EuiText>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
export function TreemapSelect({
|
||||
selectedTreemap,
|
||||
onChange,
|
||||
}: {
|
||||
selectedTreemap: TreemapTypes;
|
||||
onChange: (value: TreemapTypes) => void;
|
||||
}) {
|
||||
const currentTreemap =
|
||||
options.find(({ value }) => value === selectedTreemap) ?? options[0];
|
||||
|
||||
return (
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xs">
|
||||
<h2>
|
||||
{i18n.translate('xpack.apm.errorsOverview.treemap.title', {
|
||||
defaultMessage: 'Most affected {currentTreemap}',
|
||||
values: { currentTreemap: currentTreemap.value },
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSuperSelect
|
||||
fullWidth
|
||||
style={{ minWidth: '150px' }}
|
||||
options={options}
|
||||
valueOfSelected={selectedTreemap}
|
||||
onChange={onChange}
|
||||
itemLayoutAlign="top"
|
||||
hasDividers
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -26,7 +26,7 @@ const options: Array<EuiSuperSelectOption<TreemapTypes>> = [
|
|||
label: i18n.translate(
|
||||
'xpack.apm.transactionOverview.treemap.dropdown.devices',
|
||||
{
|
||||
defaultMessage: 'Devices treemap',
|
||||
defaultMessage: 'Devices',
|
||||
}
|
||||
),
|
||||
description: i18n.translate(
|
||||
|
@ -42,7 +42,7 @@ const options: Array<EuiSuperSelectOption<TreemapTypes>> = [
|
|||
label: i18n.translate(
|
||||
'xpack.apm.transactionOverview.treemap.versions.devices',
|
||||
{
|
||||
defaultMessage: 'Versions treemap',
|
||||
defaultMessage: 'Versions',
|
||||
}
|
||||
),
|
||||
description: i18n.translate(
|
||||
|
|
|
@ -0,0 +1,274 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiBadge,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useEffect } from 'react';
|
||||
import { omit } from 'lodash';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n';
|
||||
import { useApmServiceContext } from '../../../../../context/apm_service/use_apm_service_context';
|
||||
import { useBreadcrumb } from '../../../../../context/breadcrumbs/use_breadcrumb';
|
||||
import { useApmParams } from '../../../../../hooks/use_apm_params';
|
||||
import { useApmRouter } from '../../../../../hooks/use_apm_router';
|
||||
import { useCrashGroupDistributionFetcher } from '../../../../../hooks/use_crash_group_distribution_fetcher';
|
||||
import { FETCH_STATUS, useFetcher } from '../../../../../hooks/use_fetcher';
|
||||
import { useTimeRange } from '../../../../../hooks/use_time_range';
|
||||
import type { APIReturnType } from '../../../../../services/rest/create_call_apm_api';
|
||||
import { ErrorSampler } from '../../../error_group_details/error_sampler';
|
||||
import { ErrorDistribution } from '../shared/distribution';
|
||||
import { ChartPointerEventContextProvider } from '../../../../../context/chart_pointer_event/chart_pointer_event_context';
|
||||
import { MobileErrorsAndCrashesTreemap } from '../../charts/mobile_errors_and_crashes_treemap';
|
||||
import { maybe } from '../../../../../../common/utils/maybe';
|
||||
import { fromQuery, toQuery } from '../../../../shared/links/url_helpers';
|
||||
import {
|
||||
getKueryWithMobileCrashFilter,
|
||||
getKueryWithMobileFilters,
|
||||
} from '../../../../../../common/utils/get_kuery_with_mobile_filters';
|
||||
|
||||
type ErrorSamplesAPIResponse =
|
||||
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/{groupId}/samples'>;
|
||||
|
||||
const emptyErrorSamples: ErrorSamplesAPIResponse = {
|
||||
errorSampleIds: [],
|
||||
occurrencesCount: 0,
|
||||
};
|
||||
|
||||
function getShortGroupId(errorGroupId?: string) {
|
||||
if (!errorGroupId) {
|
||||
return NOT_AVAILABLE_LABEL;
|
||||
}
|
||||
|
||||
return errorGroupId.slice(0, 5);
|
||||
}
|
||||
|
||||
function CrashGroupHeader({
|
||||
groupId,
|
||||
occurrencesCount,
|
||||
}: {
|
||||
groupId: string;
|
||||
occurrencesCount?: number;
|
||||
}) {
|
||||
return (
|
||||
<EuiFlexGroup alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
{i18n.translate('xpack.apm.CrashGroupDetails.CrashGroupTitle', {
|
||||
defaultMessage: 'Crash group {errorGroupId}',
|
||||
values: {
|
||||
errorGroupId: getShortGroupId(groupId),
|
||||
},
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge color="hollow">
|
||||
{i18n.translate('xpack.apm.errorGroupDetails.occurrencesLabel', {
|
||||
defaultMessage: '{occurrencesCount} occ',
|
||||
values: { occurrencesCount },
|
||||
})}
|
||||
</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrashGroupDetails() {
|
||||
const { serviceName } = useApmServiceContext();
|
||||
|
||||
const apmRouter = useApmRouter();
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
path: { groupId },
|
||||
query: {
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
environment,
|
||||
kuery,
|
||||
serviceGroup,
|
||||
comparisonEnabled,
|
||||
errorId,
|
||||
device,
|
||||
osVersion,
|
||||
appVersion,
|
||||
netConnectionType,
|
||||
},
|
||||
} = useApmParams(
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/crashes/{groupId}'
|
||||
);
|
||||
|
||||
const kueryWithMobileFilters = getKueryWithMobileFilters({
|
||||
device,
|
||||
osVersion,
|
||||
appVersion,
|
||||
netConnectionType,
|
||||
kuery,
|
||||
});
|
||||
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
|
||||
|
||||
useBreadcrumb(
|
||||
() => ({
|
||||
title: groupId,
|
||||
href: apmRouter.link(
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/crashes/{groupId}',
|
||||
{
|
||||
path: {
|
||||
serviceName,
|
||||
groupId,
|
||||
},
|
||||
query: {
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
serviceGroup,
|
||||
comparisonEnabled,
|
||||
},
|
||||
}
|
||||
),
|
||||
}),
|
||||
[
|
||||
apmRouter,
|
||||
comparisonEnabled,
|
||||
environment,
|
||||
groupId,
|
||||
kueryWithMobileFilters,
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
serviceGroup,
|
||||
serviceName,
|
||||
]
|
||||
);
|
||||
|
||||
const kueryForTreemap = getKueryWithMobileCrashFilter({
|
||||
kuery: kueryWithMobileFilters,
|
||||
groupId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: errorSamplesData = emptyErrorSamples,
|
||||
status: errorSamplesFetchStatus,
|
||||
} = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (start && end) {
|
||||
return callApmApi(
|
||||
'GET /internal/apm/services/{serviceName}/errors/{groupId}/samples',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
groupId,
|
||||
},
|
||||
query: {
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[environment, kueryWithMobileFilters, serviceName, start, end, groupId]
|
||||
);
|
||||
|
||||
const { crashDistributionData, status: crashDistributionStatus } =
|
||||
useCrashGroupDistributionFetcher({
|
||||
serviceName,
|
||||
groupId,
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const selectedSample = errorSamplesData?.errorSampleIds.find(
|
||||
(sample) => sample === errorId
|
||||
);
|
||||
|
||||
if (errorSamplesFetchStatus === FETCH_STATUS.SUCCESS && !selectedSample) {
|
||||
// selected sample was not found. select a new one:
|
||||
const selectedErrorId = maybe(errorSamplesData?.errorSampleIds[0]);
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
search: fromQuery({
|
||||
...omit(toQuery(history.location.search), ['errorId']),
|
||||
errorId: selectedErrorId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, [history, errorId, errorSamplesData, errorSamplesFetchStatus]);
|
||||
|
||||
// If there are 0 occurrences, show only charts w. empty message
|
||||
const showDetails = errorSamplesData.occurrencesCount !== 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size={'s'} />
|
||||
|
||||
<CrashGroupHeader
|
||||
groupId={groupId}
|
||||
occurrencesCount={errorSamplesData?.occurrencesCount}
|
||||
/>
|
||||
|
||||
<EuiSpacer size={'m'} />
|
||||
<EuiFlexGroup>
|
||||
<ChartPointerEventContextProvider>
|
||||
<EuiFlexItem grow={3}>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<ErrorDistribution
|
||||
fetchStatus={crashDistributionStatus}
|
||||
distribution={crashDistributionData}
|
||||
title={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.crashOccurrencesChart.title',
|
||||
{ defaultMessage: 'Crash occurrences' }
|
||||
)}
|
||||
height={300}
|
||||
tip={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.errorOccurrencesChart.tip',
|
||||
{
|
||||
defaultMessage: `Crash occurrence is measured in crashes per minute.`,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</ChartPointerEventContextProvider>
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<MobileErrorsAndCrashesTreemap
|
||||
serviceName={serviceName}
|
||||
kuery={`${kueryForTreemap}`}
|
||||
environment={environment}
|
||||
start={start}
|
||||
end={end}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
{showDetails && (
|
||||
<ErrorSampler
|
||||
errorSampleIds={errorSamplesData.errorSampleIds}
|
||||
errorSamplesFetchStatus={errorSamplesFetchStatus}
|
||||
occurrencesCount={errorSamplesData.occurrencesCount}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,275 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiBadge,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useEffect } from 'react';
|
||||
import { omit } from 'lodash';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n';
|
||||
import { useApmServiceContext } from '../../../../../context/apm_service/use_apm_service_context';
|
||||
import { useBreadcrumb } from '../../../../../context/breadcrumbs/use_breadcrumb';
|
||||
import { useApmParams } from '../../../../../hooks/use_apm_params';
|
||||
import { useApmRouter } from '../../../../../hooks/use_apm_router';
|
||||
import { useErrorGroupDistributionFetcher } from '../../../../../hooks/use_error_group_distribution_fetcher';
|
||||
import { FETCH_STATUS, useFetcher } from '../../../../../hooks/use_fetcher';
|
||||
import { useTimeRange } from '../../../../../hooks/use_time_range';
|
||||
import type { APIReturnType } from '../../../../../services/rest/create_call_apm_api';
|
||||
import { ErrorSampler } from '../../../error_group_details/error_sampler';
|
||||
import { ErrorDistribution } from '../shared/distribution';
|
||||
import { ChartPointerEventContextProvider } from '../../../../../context/chart_pointer_event/chart_pointer_event_context';
|
||||
import { MobileErrorsAndCrashesTreemap } from '../../charts/mobile_errors_and_crashes_treemap';
|
||||
import { maybe } from '../../../../../../common/utils/maybe';
|
||||
import { fromQuery, toQuery } from '../../../../shared/links/url_helpers';
|
||||
import {
|
||||
getKueryWithMobileFilters,
|
||||
getKueryWithMobileErrorFilter,
|
||||
} from '../../../../../../common/utils/get_kuery_with_mobile_filters';
|
||||
|
||||
type ErrorSamplesAPIResponse =
|
||||
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/{groupId}/samples'>;
|
||||
|
||||
const emptyErrorSamples: ErrorSamplesAPIResponse = {
|
||||
errorSampleIds: [],
|
||||
occurrencesCount: 0,
|
||||
};
|
||||
|
||||
function getShortGroupId(errorGroupId?: string) {
|
||||
if (!errorGroupId) {
|
||||
return NOT_AVAILABLE_LABEL;
|
||||
}
|
||||
|
||||
return errorGroupId.slice(0, 5);
|
||||
}
|
||||
|
||||
function ErrorGroupHeader({
|
||||
groupId,
|
||||
occurrencesCount,
|
||||
}: {
|
||||
groupId: string;
|
||||
occurrencesCount?: number;
|
||||
}) {
|
||||
return (
|
||||
<EuiFlexGroup alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
{i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', {
|
||||
defaultMessage: 'Error group {errorGroupId}',
|
||||
values: {
|
||||
errorGroupId: getShortGroupId(groupId),
|
||||
},
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge color="hollow">
|
||||
{i18n.translate('xpack.apm.errorGroupDetails.occurrencesLabel', {
|
||||
defaultMessage: '{occurrencesCount} occ',
|
||||
values: { occurrencesCount },
|
||||
})}
|
||||
</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorGroupDetails() {
|
||||
const { serviceName } = useApmServiceContext();
|
||||
|
||||
const apmRouter = useApmRouter();
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
path: { groupId },
|
||||
query: {
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
environment,
|
||||
kuery,
|
||||
serviceGroup,
|
||||
comparisonEnabled,
|
||||
errorId,
|
||||
device,
|
||||
osVersion,
|
||||
appVersion,
|
||||
netConnectionType,
|
||||
},
|
||||
} = useApmParams(
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/errors/{groupId}'
|
||||
);
|
||||
const kueryWithMobileFilters = getKueryWithMobileFilters({
|
||||
device,
|
||||
osVersion,
|
||||
appVersion,
|
||||
netConnectionType,
|
||||
kuery,
|
||||
});
|
||||
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
|
||||
|
||||
useBreadcrumb(
|
||||
() => ({
|
||||
title: groupId,
|
||||
href: apmRouter.link(
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/errors/{groupId}',
|
||||
{
|
||||
path: {
|
||||
serviceName,
|
||||
groupId,
|
||||
},
|
||||
query: {
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
serviceGroup,
|
||||
comparisonEnabled,
|
||||
},
|
||||
}
|
||||
),
|
||||
}),
|
||||
[
|
||||
apmRouter,
|
||||
comparisonEnabled,
|
||||
environment,
|
||||
groupId,
|
||||
kueryWithMobileFilters,
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
serviceGroup,
|
||||
serviceName,
|
||||
]
|
||||
);
|
||||
|
||||
const {
|
||||
data: errorSamplesData = emptyErrorSamples,
|
||||
status: errorSamplesFetchStatus,
|
||||
} = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (start && end) {
|
||||
return callApmApi(
|
||||
'GET /internal/apm/services/{serviceName}/errors/{groupId}/samples',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
groupId,
|
||||
},
|
||||
query: {
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[environment, kueryWithMobileFilters, serviceName, start, end, groupId]
|
||||
);
|
||||
|
||||
const { errorDistributionData, status: errorDistributionStatus } =
|
||||
useErrorGroupDistributionFetcher({
|
||||
serviceName,
|
||||
groupId,
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const selectedSample = errorSamplesData?.errorSampleIds.find(
|
||||
(sample) => sample === errorId
|
||||
);
|
||||
|
||||
if (errorSamplesFetchStatus === FETCH_STATUS.SUCCESS && !selectedSample) {
|
||||
// selected sample was not found. select a new one:
|
||||
const selectedErrorId = maybe(errorSamplesData?.errorSampleIds[0]);
|
||||
|
||||
history.replace({
|
||||
...history.location,
|
||||
search: fromQuery({
|
||||
...omit(toQuery(history.location.search), ['errorId']),
|
||||
errorId: selectedErrorId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, [history, errorId, errorSamplesData, errorSamplesFetchStatus]);
|
||||
|
||||
// If there are 0 occurrences, show only charts w. empty message
|
||||
const showDetails = errorSamplesData.occurrencesCount !== 0;
|
||||
|
||||
const kueryForTreemap = getKueryWithMobileErrorFilter({
|
||||
groupId,
|
||||
kuery: kueryWithMobileFilters,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size={'s'} />
|
||||
|
||||
<ErrorGroupHeader
|
||||
groupId={groupId}
|
||||
occurrencesCount={errorSamplesData?.occurrencesCount}
|
||||
/>
|
||||
|
||||
<EuiSpacer size={'m'} />
|
||||
<EuiFlexGroup>
|
||||
<ChartPointerEventContextProvider>
|
||||
<EuiFlexItem grow={3}>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<ErrorDistribution
|
||||
fetchStatus={errorDistributionStatus}
|
||||
distribution={errorDistributionData}
|
||||
title={i18n.translate(
|
||||
'xpack.apm.errorGroupDetails.occurrencesChartLabel',
|
||||
{
|
||||
defaultMessage: 'Error occurrences',
|
||||
}
|
||||
)}
|
||||
height={300}
|
||||
tip={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.errorRateChart.tip',
|
||||
{
|
||||
defaultMessage: `Error rate is measured in transactions per minute.`,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</ChartPointerEventContextProvider>
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<MobileErrorsAndCrashesTreemap
|
||||
serviceName={serviceName}
|
||||
kuery={`${kueryForTreemap}`}
|
||||
environment={environment}
|
||||
start={start}
|
||||
end={end}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
{showDetails && (
|
||||
<ErrorSampler
|
||||
errorSampleIds={errorSamplesData.errorSampleIds}
|
||||
errorSamplesFetchStatus={errorSamplesFetchStatus}
|
||||
occurrencesCount={errorSamplesData.occurrencesCount}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { ComponentType } from 'react';
|
||||
import { ErrorDistribution } from '.';
|
||||
import { MockApmPluginStorybook } from '../../../../../../context/apm_plugin/mock_apm_plugin_storybook';
|
||||
import { FETCH_STATUS } from '../../../../../../hooks/use_fetcher';
|
||||
|
||||
export default {
|
||||
title: 'app/ErrorGroupDetails/distribution',
|
||||
component: ErrorDistribution,
|
||||
decorators: [
|
||||
(Story: ComponentType) => {
|
||||
return (
|
||||
<MockApmPluginStorybook routePath="/services/{serviceName}/errors/{groupId}?kuery=&rangeFrom=now-15m&rangeTo=now&environment=ENVIRONMENT_ALL&serviceGroup=&comparisonEnabled=true&transactionType=request&offset=1d">
|
||||
<Story />
|
||||
</MockApmPluginStorybook>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function Example() {
|
||||
const distribution = {
|
||||
bucketSize: 62350,
|
||||
currentPeriod: [
|
||||
{ x: 1624279912350, y: 6 },
|
||||
{ x: 1624279974700, y: 1 },
|
||||
{ x: 1624280037050, y: 2 },
|
||||
{ x: 1624280099400, y: 3 },
|
||||
{ x: 1624280161750, y: 13 },
|
||||
{ x: 1624280224100, y: 1 },
|
||||
{ x: 1624280286450, y: 2 },
|
||||
{ x: 1624280348800, y: 0 },
|
||||
{ x: 1624280411150, y: 4 },
|
||||
{ x: 1624280473500, y: 4 },
|
||||
{ x: 1624280535850, y: 1 },
|
||||
{ x: 1624280598200, y: 4 },
|
||||
{ x: 1624280660550, y: 0 },
|
||||
{ x: 1624280722900, y: 2 },
|
||||
{ x: 1624280785250, y: 3 },
|
||||
{ x: 1624280847600, y: 0 },
|
||||
],
|
||||
previousPeriod: [
|
||||
{ x: 1624279912350, y: 6 },
|
||||
{ x: 1624279974700, y: 1 },
|
||||
{ x: 1624280037050, y: 2 },
|
||||
{ x: 1624280099400, y: 3 },
|
||||
{ x: 1624280161750, y: 13 },
|
||||
{ x: 1624280224100, y: 1 },
|
||||
{ x: 1624280286450, y: 2 },
|
||||
{ x: 1624280348800, y: 0 },
|
||||
{ x: 1624280411150, y: 4 },
|
||||
{ x: 1624280473500, y: 4 },
|
||||
{ x: 1624280535850, y: 1 },
|
||||
{ x: 1624280598200, y: 4 },
|
||||
{ x: 1624280660550, y: 0 },
|
||||
{ x: 1624280722900, y: 2 },
|
||||
{ x: 1624280785250, y: 3 },
|
||||
{ x: 1624280847600, y: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorDistribution
|
||||
fetchStatus={FETCH_STATUS.SUCCESS}
|
||||
distribution={distribution}
|
||||
height={300}
|
||||
tip={'hello, world'}
|
||||
title="Foo title"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<ErrorDistribution
|
||||
fetchStatus={FETCH_STATUS.SUCCESS}
|
||||
height={300}
|
||||
tip={'hello, world'}
|
||||
distribution={{
|
||||
bucketSize: 10,
|
||||
currentPeriod: [],
|
||||
previousPeriod: [],
|
||||
}}
|
||||
title="Foo title"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* 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 { EuiTitle, EuiIconTip, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { TimeseriesChartWithContext } from '../../../../../shared/charts/timeseries_chart_with_context';
|
||||
import { useLegacyUrlParams } from '../../../../../../context/url_params_context/use_url_params';
|
||||
import { FETCH_STATUS } from '../../../../../../hooks/use_fetcher';
|
||||
import { usePreviousPeriodLabel } from '../../../../../../hooks/use_previous_period_text';
|
||||
import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api';
|
||||
import { getComparisonChartTheme } from '../../../../../shared/time_comparison/get_comparison_chart_theme';
|
||||
|
||||
import {
|
||||
ChartType,
|
||||
getTimeSeriesColor,
|
||||
} from '../../../../../shared/charts/helper/get_timeseries_color';
|
||||
|
||||
type ErrorDistributionAPIResponse =
|
||||
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/distribution'>;
|
||||
|
||||
interface Props {
|
||||
fetchStatus: FETCH_STATUS;
|
||||
distribution?: ErrorDistributionAPIResponse;
|
||||
title: string;
|
||||
tip: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function ErrorDistribution({
|
||||
distribution,
|
||||
title,
|
||||
tip,
|
||||
height,
|
||||
fetchStatus,
|
||||
}: Props) {
|
||||
const { urlParams } = useLegacyUrlParams();
|
||||
const { comparisonEnabled } = urlParams;
|
||||
|
||||
const previousPeriodLabel = usePreviousPeriodLabel();
|
||||
const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor(
|
||||
ChartType.ERROR_OCCURRENCES
|
||||
);
|
||||
const timeseries = [
|
||||
{
|
||||
data: distribution?.currentPeriod ?? [],
|
||||
type: 'linemark',
|
||||
color: currentPeriodColor,
|
||||
title,
|
||||
},
|
||||
...(comparisonEnabled
|
||||
? [
|
||||
{
|
||||
data: distribution?.previousPeriod ?? [],
|
||||
type: 'area',
|
||||
color: previousPeriodColor,
|
||||
title: previousPeriodLabel,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const comparisonChartTheme = getComparisonChartTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup alignItems="center" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xs">
|
||||
<h2>{title}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip content={tip} position="right" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<TimeseriesChartWithContext
|
||||
id={title}
|
||||
height={height}
|
||||
showAnnotations={false}
|
||||
fetchStatus={fetchStatus}
|
||||
yLabelFormat={(value) => `${value}`}
|
||||
timeseries={timeseries}
|
||||
customTheme={comparisonChartTheme}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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 { Meta, Story } from '@storybook/react';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context';
|
||||
import { MockUrlParamsContextProvider } from '../../../../../context/url_params_context/mock_url_params_context_provider';
|
||||
|
||||
import { MobileCrashGroupList } from '.';
|
||||
|
||||
type Args = ComponentProps<typeof MobileCrashGroupList>;
|
||||
|
||||
const stories: Meta<Args> = {
|
||||
title: 'app/CrashGroupOverview/MobileCrashGroupList',
|
||||
component: MobileCrashGroupList,
|
||||
decorators: [
|
||||
(StoryComponent) => {
|
||||
return (
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
'/mobile-services/{serviceName}/errors-and-crashes?rangeFrom=now-15m&rangeTo=now',
|
||||
]}
|
||||
>
|
||||
<MockApmPluginContextWrapper>
|
||||
<MockUrlParamsContextProvider>
|
||||
<StoryComponent />
|
||||
</MockUrlParamsContextProvider>
|
||||
</MockApmPluginContextWrapper>
|
||||
</MemoryRouter>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
export default stories;
|
||||
|
||||
export const Example: Story<Args> = (args) => {
|
||||
return <MobileCrashGroupList {...args} />;
|
||||
};
|
||||
Example.args = {
|
||||
mainStatistics: [
|
||||
{
|
||||
name: 'net/http: abort Handler',
|
||||
occurrences: 14,
|
||||
culprit: 'Main.func2',
|
||||
groupId: '83a653297ec29afed264d7b60d5cda7b',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'errorString',
|
||||
},
|
||||
{
|
||||
name: 'POST /api/orders (500)',
|
||||
occurrences: 5,
|
||||
culprit: 'logrusMiddleware',
|
||||
groupId: '7a640436a9be648fd708703d1ac84650',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'OpError',
|
||||
},
|
||||
{
|
||||
name: 'write tcp 10.36.2.24:3000->10.36.1.14:34232: write: connection reset by peer',
|
||||
occurrences: 4,
|
||||
culprit: 'apiHandlers.getProductCustomers',
|
||||
groupId: '95ca0e312c109aa11e298bcf07f1445b',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'OpError',
|
||||
},
|
||||
{
|
||||
name: 'write tcp 10.36.0.21:3000->10.36.1.252:57070: write: connection reset by peer',
|
||||
occurrences: 3,
|
||||
culprit: 'apiHandlers.getCustomers',
|
||||
groupId: '4053d7e33d2b716c819bd96d9d6121a2',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'OpError',
|
||||
},
|
||||
{
|
||||
name: 'write tcp 10.36.0.21:3000->10.36.0.88:33926: write: broken pipe',
|
||||
occurrences: 2,
|
||||
culprit: 'apiHandlers.getOrders',
|
||||
groupId: '94f4ca8ec8c02e5318cf03f46ae4c1f3',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'OpError',
|
||||
},
|
||||
],
|
||||
serviceName: 'test service',
|
||||
};
|
||||
|
||||
export const EmptyState: Story<Args> = (args) => {
|
||||
return <MobileCrashGroupList {...args} />;
|
||||
};
|
||||
EmptyState.args = {
|
||||
mainStatistics: [],
|
||||
serviceName: 'test service',
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 { composeStories } from '@storybook/testing-react';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import * as stories from './crash_group_list.stories';
|
||||
|
||||
const { Example } = composeStories(stories);
|
||||
|
||||
describe('MobileCrashGroupList', () => {
|
||||
it('renders', () => {
|
||||
expect(() => render(<Example />)).not.toThrowError();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,204 @@
|
|||
/*
|
||||
* 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 { EuiToolTip, RIGHT_ALIGNMENT, LEFT_ALIGNMENT } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { euiStyled } from '@kbn/kibana-react-plugin/common';
|
||||
import React, { useMemo } from 'react';
|
||||
import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n';
|
||||
import { asInteger } from '../../../../../../common/utils/formatters';
|
||||
import { useApmParams } from '../../../../../hooks/use_apm_params';
|
||||
import { APIReturnType } from '../../../../../services/rest/create_call_apm_api';
|
||||
import { truncate } from '../../../../../utils/style';
|
||||
import {
|
||||
ChartType,
|
||||
getTimeSeriesColor,
|
||||
} from '../../../../shared/charts/helper/get_timeseries_color';
|
||||
import { SparkPlot } from '../../../../shared/charts/spark_plot';
|
||||
import { CrashDetailLink } from '../../../../shared/links/apm/mobile/crash_detail_link';
|
||||
import { ErrorOverviewLink } from '../../../../shared/links/apm/mobile/error_overview_link';
|
||||
import { ITableColumn, ManagedTable } from '../../../../shared/managed_table';
|
||||
import { TimestampTooltip } from '../../../../shared/timestamp_tooltip';
|
||||
import { isTimeComparison } from '../../../../shared/time_comparison/get_comparison_options';
|
||||
|
||||
const MessageAndCulpritCell = euiStyled.div`
|
||||
${truncate('100%')};
|
||||
`;
|
||||
|
||||
const ErrorLink = euiStyled(ErrorOverviewLink)`
|
||||
${truncate('100%')};
|
||||
`;
|
||||
|
||||
type ErrorGroupItem =
|
||||
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/errors/groups/main_statistics'>['errorGroups'][0];
|
||||
type ErrorGroupDetailedStatistics =
|
||||
APIReturnType<'POST /internal/apm/mobile-services/{serviceName}/errors/groups/detailed_statistics'>;
|
||||
|
||||
interface Props {
|
||||
mainStatistics: ErrorGroupItem[];
|
||||
serviceName: string;
|
||||
detailedStatisticsLoading: boolean;
|
||||
detailedStatistics: ErrorGroupDetailedStatistics;
|
||||
initialSortField: string;
|
||||
initialSortDirection: 'asc' | 'desc';
|
||||
comparisonEnabled?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function MobileCrashGroupList({
|
||||
mainStatistics,
|
||||
serviceName,
|
||||
detailedStatisticsLoading,
|
||||
detailedStatistics,
|
||||
comparisonEnabled,
|
||||
initialSortField,
|
||||
initialSortDirection,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const { query } = useApmParams(
|
||||
'/mobile-services/{serviceName}/errors-and-crashes'
|
||||
);
|
||||
const { offset } = query;
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: i18n.translate('xpack.apm.errorsTable.typeColumnLabel', {
|
||||
defaultMessage: 'Type',
|
||||
}),
|
||||
field: 'type',
|
||||
sortable: false,
|
||||
render: (_, { type }) => {
|
||||
return (
|
||||
<ErrorLink
|
||||
title={type}
|
||||
serviceName={serviceName}
|
||||
query={{
|
||||
...query,
|
||||
kuery: `error.exception.type:"${type}"`,
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</ErrorLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate(
|
||||
'xpack.apm.crashTable.crashMessageAndCulpritColumnLabel',
|
||||
{
|
||||
defaultMessage: 'Crash message',
|
||||
}
|
||||
),
|
||||
field: 'message',
|
||||
sortable: false,
|
||||
width: '30%',
|
||||
render: (_, item: ErrorGroupItem) => {
|
||||
return (
|
||||
<MessageAndCulpritCell>
|
||||
<EuiToolTip
|
||||
id="error-message-tooltip"
|
||||
content={item.name || NOT_AVAILABLE_LABEL}
|
||||
>
|
||||
<CrashDetailLink
|
||||
serviceName={serviceName}
|
||||
groupId={item.groupId}
|
||||
query={query}
|
||||
>
|
||||
{item.name || NOT_AVAILABLE_LABEL}
|
||||
</CrashDetailLink>
|
||||
</EuiToolTip>
|
||||
</MessageAndCulpritCell>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'lastSeen',
|
||||
sortable: true,
|
||||
name: i18n.translate('xpack.apm.errorsTable.lastSeenColumnLabel', {
|
||||
defaultMessage: 'Last seen',
|
||||
}),
|
||||
align: LEFT_ALIGNMENT,
|
||||
render: (_, { lastSeen }) =>
|
||||
lastSeen ? (
|
||||
<TimestampTooltip time={lastSeen} timeUnit="minutes" />
|
||||
) : (
|
||||
NOT_AVAILABLE_LABEL
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'occurrences',
|
||||
name: i18n.translate('xpack.apm.errorsTable.occurrencesColumnLabel', {
|
||||
defaultMessage: 'Occurrences',
|
||||
}),
|
||||
sortable: true,
|
||||
dataType: 'number',
|
||||
align: RIGHT_ALIGNMENT,
|
||||
render: (_, { occurrences, groupId }) => {
|
||||
const currentPeriodTimeseries =
|
||||
detailedStatistics?.currentPeriod?.[groupId]?.timeseries;
|
||||
const previousPeriodTimeseries =
|
||||
detailedStatistics?.previousPeriod?.[groupId]?.timeseries;
|
||||
const { currentPeriodColor, previousPeriodColor } =
|
||||
getTimeSeriesColor(ChartType.FAILED_TRANSACTION_RATE);
|
||||
|
||||
return (
|
||||
<SparkPlot
|
||||
type="bar"
|
||||
color={currentPeriodColor}
|
||||
isLoading={detailedStatisticsLoading}
|
||||
series={currentPeriodTimeseries}
|
||||
valueLabel={i18n.translate(
|
||||
'xpack.apm.serviceOverview.errorsTableOccurrences',
|
||||
{
|
||||
defaultMessage: `{occurrences} occ.`,
|
||||
values: {
|
||||
occurrences: asInteger(occurrences),
|
||||
},
|
||||
}
|
||||
)}
|
||||
comparisonSeries={
|
||||
comparisonEnabled && isTimeComparison(offset)
|
||||
? previousPeriodTimeseries
|
||||
: undefined
|
||||
}
|
||||
comparisonSeriesColor={previousPeriodColor}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
] as Array<ITableColumn<ErrorGroupItem>>;
|
||||
}, [
|
||||
serviceName,
|
||||
query,
|
||||
detailedStatistics,
|
||||
comparisonEnabled,
|
||||
detailedStatisticsLoading,
|
||||
offset,
|
||||
]);
|
||||
return (
|
||||
<ManagedTable
|
||||
noItemsMessage={
|
||||
isLoading
|
||||
? i18n.translate('xpack.apm.errorsTable.loading', {
|
||||
defaultMessage: 'Loading...',
|
||||
})
|
||||
: i18n.translate('xpack.apm.crashTable.noCrashesLabel', {
|
||||
defaultMessage: 'No crashes found',
|
||||
})
|
||||
}
|
||||
items={mainStatistics}
|
||||
columns={columns}
|
||||
initialSortField={initialSortField}
|
||||
initialSortDirection={initialSortDirection}
|
||||
sortItems={false}
|
||||
initialPageSize={25}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { MobileCrashGroupList };
|
|
@ -0,0 +1,267 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { orderBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useTimeRange } from '../../../../hooks/use_time_range';
|
||||
import { useCrashGroupDistributionFetcher } from '../../../../hooks/use_crash_group_distribution_fetcher';
|
||||
import { MobileErrorsAndCrashesTreemap } from '../charts/mobile_errors_and_crashes_treemap';
|
||||
import { MobileCrashGroupList } from './crash_group_list';
|
||||
import {
|
||||
FETCH_STATUS,
|
||||
isPending,
|
||||
useFetcher,
|
||||
} from '../../../../hooks/use_fetcher';
|
||||
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
|
||||
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
|
||||
import { useApmParams } from '../../../../hooks/use_apm_params';
|
||||
import { ErrorDistribution } from '../errors_and_crashes_group_details/shared/distribution';
|
||||
import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event/chart_pointer_event_context';
|
||||
import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options';
|
||||
import {
|
||||
getKueryWithMobileCrashFilter,
|
||||
getKueryWithMobileFilters,
|
||||
} from '../../../../../common/utils/get_kuery_with_mobile_filters';
|
||||
|
||||
type MobileCrashGroupMainStatistics =
|
||||
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/crashes/groups/main_statistics'>;
|
||||
type MobileCrashGroupDetailedStatistics =
|
||||
APIReturnType<'POST /internal/apm/mobile-services/{serviceName}/crashes/groups/detailed_statistics'>;
|
||||
|
||||
const INITIAL_STATE_MAIN_STATISTICS: {
|
||||
mobileCrashGroupMainStatistics: MobileCrashGroupMainStatistics['errorGroups'];
|
||||
requestId?: string;
|
||||
currentPageGroupIds: MobileCrashGroupMainStatistics['errorGroups'];
|
||||
} = {
|
||||
mobileCrashGroupMainStatistics: [],
|
||||
requestId: undefined,
|
||||
currentPageGroupIds: [],
|
||||
};
|
||||
|
||||
const INITIAL_STATE_DETAILED_STATISTICS: MobileCrashGroupDetailedStatistics = {
|
||||
currentPeriod: {},
|
||||
previousPeriod: {},
|
||||
};
|
||||
|
||||
export function MobileCrashesOverview() {
|
||||
const { serviceName } = useApmServiceContext();
|
||||
|
||||
const {
|
||||
query: {
|
||||
environment,
|
||||
kuery,
|
||||
sortField = 'occurrences',
|
||||
sortDirection = 'desc',
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
offset,
|
||||
comparisonEnabled,
|
||||
page = 0,
|
||||
pageSize = 25,
|
||||
device,
|
||||
osVersion,
|
||||
appVersion,
|
||||
netConnectionType,
|
||||
},
|
||||
} = useApmParams('/mobile-services/{serviceName}/errors-and-crashes/');
|
||||
|
||||
const kueryWithMobileFilters = getKueryWithMobileFilters({
|
||||
device,
|
||||
osVersion,
|
||||
appVersion,
|
||||
netConnectionType,
|
||||
kuery,
|
||||
});
|
||||
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
|
||||
const { crashDistributionData, status } = useCrashGroupDistributionFetcher({
|
||||
serviceName,
|
||||
groupId: undefined,
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
});
|
||||
|
||||
const {
|
||||
data: crashGroupListData = INITIAL_STATE_MAIN_STATISTICS,
|
||||
status: crashGroupListDataStatus,
|
||||
} = useFetcher(
|
||||
(callApmApi) => {
|
||||
const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if (start && end) {
|
||||
return callApmApi(
|
||||
'GET /internal/apm/mobile-services/{serviceName}/crashes/groups/main_statistics',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
start,
|
||||
end,
|
||||
sortField,
|
||||
sortDirection: normalizedSortDirection,
|
||||
},
|
||||
},
|
||||
}
|
||||
).then((response) => {
|
||||
const currentPageGroupIds = orderBy(
|
||||
response.errorGroups,
|
||||
sortField,
|
||||
sortDirection
|
||||
)
|
||||
.slice(page * pageSize, (page + 1) * pageSize)
|
||||
.map(({ groupId }) => groupId)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
// Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched.
|
||||
requestId: uuidv4(),
|
||||
mobileCrashGroupMainStatistics: response.errorGroups,
|
||||
currentPageGroupIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
environment,
|
||||
kueryWithMobileFilters,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
sortField,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
]
|
||||
);
|
||||
|
||||
const { requestId, mobileCrashGroupMainStatistics, currentPageGroupIds } =
|
||||
crashGroupListData;
|
||||
const {
|
||||
data: mobileCrashGroupDetailedStatistics = INITIAL_STATE_DETAILED_STATISTICS,
|
||||
status: mobileCrashGroupDetailedStatisticsStatus,
|
||||
} = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (requestId && currentPageGroupIds.length && start && end) {
|
||||
return callApmApi(
|
||||
'POST /internal/apm/mobile-services/{serviceName}/crashes/groups/detailed_statistics',
|
||||
{
|
||||
params: {
|
||||
path: { serviceName },
|
||||
query: {
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
start,
|
||||
end,
|
||||
numBuckets: 20,
|
||||
offset:
|
||||
comparisonEnabled && isTimeComparison(offset)
|
||||
? offset
|
||||
: undefined,
|
||||
},
|
||||
body: {
|
||||
groupIds: JSON.stringify(currentPageGroupIds),
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
// only fetches agg results when requestId changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[requestId],
|
||||
{ preservePreviousData: false }
|
||||
);
|
||||
|
||||
const kueryForTreemap = getKueryWithMobileCrashFilter({
|
||||
kuery: kueryWithMobileFilters,
|
||||
groupId: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="row" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<ChartPointerEventContextProvider>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<ErrorDistribution
|
||||
fetchStatus={status}
|
||||
distribution={crashDistributionData}
|
||||
height={375}
|
||||
title={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.crashOccurrencesChart.title',
|
||||
{ defaultMessage: 'Crash occurrences' }
|
||||
)}
|
||||
tip={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.errorOccurrencesChart.tip',
|
||||
{
|
||||
defaultMessage: `Crash occurrence is measured in crashes per minute.`,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</ChartPointerEventContextProvider>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<MobileErrorsAndCrashesTreemap
|
||||
serviceName={serviceName}
|
||||
kuery={kueryForTreemap}
|
||||
environment={environment}
|
||||
start={start}
|
||||
end={end}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<EuiTitle size="xs">
|
||||
<h3>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.crashes.title',
|
||||
{ defaultMessage: 'Crashes' }
|
||||
)}
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<MobileCrashGroupList
|
||||
mainStatistics={mobileCrashGroupMainStatistics}
|
||||
serviceName={serviceName}
|
||||
detailedStatisticsLoading={isPending(
|
||||
mobileCrashGroupDetailedStatisticsStatus
|
||||
)}
|
||||
detailedStatistics={mobileCrashGroupDetailedStatistics}
|
||||
comparisonEnabled={comparisonEnabled}
|
||||
initialSortField={sortField}
|
||||
initialSortDirection={sortDirection}
|
||||
isLoading={crashGroupListDataStatus === FETCH_STATUS.LOADING}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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 { Meta, Story } from '@storybook/react';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context';
|
||||
import { MockUrlParamsContextProvider } from '../../../../../context/url_params_context/mock_url_params_context_provider';
|
||||
|
||||
import { MobileErrorGroupList } from '.';
|
||||
|
||||
type Args = ComponentProps<typeof MobileErrorGroupList>;
|
||||
|
||||
const stories: Meta<Args> = {
|
||||
title: 'app/ErrorGroupOverview/MobileErrorGroupList',
|
||||
component: MobileErrorGroupList,
|
||||
decorators: [
|
||||
(StoryComponent) => {
|
||||
return (
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
'/mobile-services/{serviceName}/errors-and-crashes?rangeFrom=now-15m&rangeTo=now',
|
||||
]}
|
||||
>
|
||||
<MockApmPluginContextWrapper>
|
||||
<MockUrlParamsContextProvider>
|
||||
<StoryComponent />
|
||||
</MockUrlParamsContextProvider>
|
||||
</MockApmPluginContextWrapper>
|
||||
</MemoryRouter>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
export default stories;
|
||||
|
||||
export const Example: Story<Args> = (args) => {
|
||||
return <MobileErrorGroupList {...args} />;
|
||||
};
|
||||
Example.args = {
|
||||
mainStatistics: [
|
||||
{
|
||||
name: 'net/http: abort Handler',
|
||||
occurrences: 14,
|
||||
culprit: 'Main.func2',
|
||||
groupId: '83a653297ec29afed264d7b60d5cda7b',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'errorString',
|
||||
},
|
||||
{
|
||||
name: 'POST /api/orders (500)',
|
||||
occurrences: 5,
|
||||
culprit: 'logrusMiddleware',
|
||||
groupId: '7a640436a9be648fd708703d1ac84650',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'OpError',
|
||||
},
|
||||
{
|
||||
name: 'write tcp 10.36.2.24:3000->10.36.1.14:34232: write: connection reset by peer',
|
||||
occurrences: 4,
|
||||
culprit: 'apiHandlers.getProductCustomers',
|
||||
groupId: '95ca0e312c109aa11e298bcf07f1445b',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'OpError',
|
||||
},
|
||||
{
|
||||
name: 'write tcp 10.36.0.21:3000->10.36.1.252:57070: write: connection reset by peer',
|
||||
occurrences: 3,
|
||||
culprit: 'apiHandlers.getCustomers',
|
||||
groupId: '4053d7e33d2b716c819bd96d9d6121a2',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'OpError',
|
||||
},
|
||||
{
|
||||
name: 'write tcp 10.36.0.21:3000->10.36.0.88:33926: write: broken pipe',
|
||||
occurrences: 2,
|
||||
culprit: 'apiHandlers.getOrders',
|
||||
groupId: '94f4ca8ec8c02e5318cf03f46ae4c1f3',
|
||||
lastSeen: 1634833121434,
|
||||
handled: false,
|
||||
type: 'OpError',
|
||||
},
|
||||
],
|
||||
serviceName: 'test service',
|
||||
};
|
||||
|
||||
export const EmptyState: Story<Args> = (args) => {
|
||||
return <MobileErrorGroupList {...args} />;
|
||||
};
|
||||
EmptyState.args = {
|
||||
mainStatistics: [],
|
||||
serviceName: 'test service',
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 { composeStories } from '@storybook/testing-react';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import * as stories from './error_group_list.stories';
|
||||
|
||||
const { Example } = composeStories(stories);
|
||||
|
||||
describe('ErrorGroupList', () => {
|
||||
it('renders', () => {
|
||||
expect(() => render(<Example />)).not.toThrowError();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,223 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiBadge,
|
||||
EuiToolTip,
|
||||
RIGHT_ALIGNMENT,
|
||||
LEFT_ALIGNMENT,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { euiStyled } from '@kbn/kibana-react-plugin/common';
|
||||
import React, { useMemo } from 'react';
|
||||
import { NOT_AVAILABLE_LABEL } from '../../../../../../common/i18n';
|
||||
import { asInteger } from '../../../../../../common/utils/formatters';
|
||||
import { useApmParams } from '../../../../../hooks/use_apm_params';
|
||||
import { APIReturnType } from '../../../../../services/rest/create_call_apm_api';
|
||||
import { truncate } from '../../../../../utils/style';
|
||||
import {
|
||||
ChartType,
|
||||
getTimeSeriesColor,
|
||||
} from '../../../../shared/charts/helper/get_timeseries_color';
|
||||
import { SparkPlot } from '../../../../shared/charts/spark_plot';
|
||||
import { ErrorDetailLink } from '../../../../shared/links/apm/mobile/error_detail_link';
|
||||
import { ErrorOverviewLink } from '../../../../shared/links/apm/mobile/error_overview_link';
|
||||
import { ITableColumn, ManagedTable } from '../../../../shared/managed_table';
|
||||
import { TimestampTooltip } from '../../../../shared/timestamp_tooltip';
|
||||
import { isTimeComparison } from '../../../../shared/time_comparison/get_comparison_options';
|
||||
|
||||
const MessageAndCulpritCell = euiStyled.div`
|
||||
${truncate('100%')};
|
||||
`;
|
||||
|
||||
const ErrorLink = euiStyled(ErrorOverviewLink)`
|
||||
${truncate('100%')};
|
||||
`;
|
||||
|
||||
type ErrorGroupItem =
|
||||
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/errors/groups/main_statistics'>['errorGroups'][0];
|
||||
type ErrorGroupDetailedStatistics =
|
||||
APIReturnType<'POST /internal/apm/mobile-services/{serviceName}/errors/groups/detailed_statistics'>;
|
||||
|
||||
interface Props {
|
||||
mainStatistics: ErrorGroupItem[];
|
||||
serviceName: string;
|
||||
detailedStatisticsLoading: boolean;
|
||||
detailedStatistics: ErrorGroupDetailedStatistics;
|
||||
initialSortField: string;
|
||||
initialSortDirection: 'asc' | 'desc';
|
||||
comparisonEnabled?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function MobileErrorGroupList({
|
||||
mainStatistics,
|
||||
serviceName,
|
||||
detailedStatisticsLoading,
|
||||
detailedStatistics,
|
||||
comparisonEnabled,
|
||||
initialSortField,
|
||||
initialSortDirection,
|
||||
isLoading,
|
||||
}: Props) {
|
||||
const { query } = useApmParams(
|
||||
'/mobile-services/{serviceName}/errors-and-crashes'
|
||||
);
|
||||
const { offset } = query;
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: i18n.translate('xpack.apm.errorsTable.typeColumnLabel', {
|
||||
defaultMessage: 'Type',
|
||||
}),
|
||||
field: 'type',
|
||||
sortable: false,
|
||||
render: (_, { type }) => {
|
||||
return (
|
||||
<ErrorLink
|
||||
title={type}
|
||||
serviceName={serviceName}
|
||||
query={{
|
||||
...query,
|
||||
kuery: `error.exception.type:"${type}"`,
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</ErrorLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate(
|
||||
'xpack.apm.errorsTable.errorMessageAndCulpritColumnLabel',
|
||||
{
|
||||
defaultMessage: 'Error message and culprit',
|
||||
}
|
||||
),
|
||||
field: 'message',
|
||||
sortable: false,
|
||||
width: '30%',
|
||||
render: (_, item: ErrorGroupItem) => {
|
||||
return (
|
||||
<MessageAndCulpritCell>
|
||||
<EuiToolTip
|
||||
id="error-message-tooltip"
|
||||
content={item.name || NOT_AVAILABLE_LABEL}
|
||||
>
|
||||
<ErrorDetailLink
|
||||
serviceName={serviceName}
|
||||
groupId={item.groupId}
|
||||
query={query}
|
||||
>
|
||||
{item.name || NOT_AVAILABLE_LABEL}
|
||||
</ErrorDetailLink>
|
||||
</EuiToolTip>
|
||||
</MessageAndCulpritCell>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
field: 'handled',
|
||||
sortable: false,
|
||||
align: RIGHT_ALIGNMENT,
|
||||
render: (_, { handled }) =>
|
||||
handled === false && (
|
||||
<EuiBadge color="warning">
|
||||
{i18n.translate('xpack.apm.errorsTable.unhandledLabel', {
|
||||
defaultMessage: 'Unhandled',
|
||||
})}
|
||||
</EuiBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'lastSeen',
|
||||
sortable: true,
|
||||
name: i18n.translate('xpack.apm.errorsTable.lastSeenColumnLabel', {
|
||||
defaultMessage: 'Last seen',
|
||||
}),
|
||||
align: LEFT_ALIGNMENT,
|
||||
render: (_, { lastSeen }) =>
|
||||
lastSeen ? (
|
||||
<TimestampTooltip time={lastSeen} timeUnit="minutes" />
|
||||
) : (
|
||||
NOT_AVAILABLE_LABEL
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'occurrences',
|
||||
name: i18n.translate('xpack.apm.errorsTable.occurrencesColumnLabel', {
|
||||
defaultMessage: 'Occurrences',
|
||||
}),
|
||||
sortable: true,
|
||||
dataType: 'number',
|
||||
align: RIGHT_ALIGNMENT,
|
||||
render: (_, { occurrences, groupId }) => {
|
||||
const currentPeriodTimeseries =
|
||||
detailedStatistics?.currentPeriod?.[groupId]?.timeseries;
|
||||
const previousPeriodTimeseries =
|
||||
detailedStatistics?.previousPeriod?.[groupId]?.timeseries;
|
||||
const { currentPeriodColor, previousPeriodColor } =
|
||||
getTimeSeriesColor(ChartType.FAILED_TRANSACTION_RATE);
|
||||
|
||||
return (
|
||||
<SparkPlot
|
||||
type="bar"
|
||||
color={currentPeriodColor}
|
||||
isLoading={detailedStatisticsLoading}
|
||||
series={currentPeriodTimeseries}
|
||||
valueLabel={i18n.translate(
|
||||
'xpack.apm.serviceOverview.errorsTableOccurrences',
|
||||
{
|
||||
defaultMessage: `{occurrences} occ.`,
|
||||
values: {
|
||||
occurrences: asInteger(occurrences),
|
||||
},
|
||||
}
|
||||
)}
|
||||
comparisonSeries={
|
||||
comparisonEnabled && isTimeComparison(offset)
|
||||
? previousPeriodTimeseries
|
||||
: undefined
|
||||
}
|
||||
comparisonSeriesColor={previousPeriodColor}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
] as Array<ITableColumn<ErrorGroupItem>>;
|
||||
}, [
|
||||
serviceName,
|
||||
query,
|
||||
detailedStatistics,
|
||||
comparisonEnabled,
|
||||
detailedStatisticsLoading,
|
||||
offset,
|
||||
]);
|
||||
return (
|
||||
<ManagedTable
|
||||
noItemsMessage={
|
||||
isLoading
|
||||
? i18n.translate('xpack.apm.errorsTable.loading', {
|
||||
defaultMessage: 'Loading...',
|
||||
})
|
||||
: i18n.translate('xpack.apm.errorsTable.noErrorsLabel', {
|
||||
defaultMessage: 'No errors found',
|
||||
})
|
||||
}
|
||||
items={mainStatistics}
|
||||
columns={columns}
|
||||
initialSortField={initialSortField}
|
||||
initialSortDirection={initialSortDirection}
|
||||
sortItems={false}
|
||||
initialPageSize={25}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { MobileErrorGroupList };
|
|
@ -0,0 +1,275 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { orderBy } from 'lodash';
|
||||
import React from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
|
||||
import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event/chart_pointer_event_context';
|
||||
import { useApmParams } from '../../../../hooks/use_apm_params';
|
||||
import { useErrorGroupDistributionFetcher } from '../../../../hooks/use_error_group_distribution_fetcher';
|
||||
import {
|
||||
FETCH_STATUS,
|
||||
isPending,
|
||||
useFetcher,
|
||||
} from '../../../../hooks/use_fetcher';
|
||||
import { useTimeRange } from '../../../../hooks/use_time_range';
|
||||
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
|
||||
import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options';
|
||||
import { HttpErrorRateChart } from '../charts/mobile_http_error_rate';
|
||||
import { ErrorDistribution } from '../errors_and_crashes_group_details/shared/distribution';
|
||||
import { MobileErrorGroupList } from './error_group_list';
|
||||
import { MobileErrorsAndCrashesTreemap } from '../charts/mobile_errors_and_crashes_treemap';
|
||||
import {
|
||||
getKueryWithMobileErrorFilter,
|
||||
getKueryWithMobileFilters,
|
||||
} from '../../../../../common/utils/get_kuery_with_mobile_filters';
|
||||
|
||||
type MobileErrorGroupMainStatistics =
|
||||
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/errors/groups/main_statistics'>;
|
||||
type MobileErrorGroupDetailedStatistics =
|
||||
APIReturnType<'POST /internal/apm/mobile-services/{serviceName}/errors/groups/detailed_statistics'>;
|
||||
|
||||
const INITIAL_STATE_MAIN_STATISTICS: {
|
||||
mobileErrorGroupMainStatistics: MobileErrorGroupMainStatistics['errorGroups'];
|
||||
requestId?: string;
|
||||
currentPageGroupIds: MobileErrorGroupMainStatistics['errorGroups'];
|
||||
} = {
|
||||
mobileErrorGroupMainStatistics: [],
|
||||
requestId: undefined,
|
||||
currentPageGroupIds: [],
|
||||
};
|
||||
|
||||
const INITIAL_STATE_DETAILED_STATISTICS: MobileErrorGroupDetailedStatistics = {
|
||||
currentPeriod: {},
|
||||
previousPeriod: {},
|
||||
};
|
||||
|
||||
export function MobileErrorsOverview() {
|
||||
const { serviceName } = useApmServiceContext();
|
||||
const {
|
||||
query: {
|
||||
environment,
|
||||
kuery,
|
||||
sortField = 'occurrences',
|
||||
sortDirection = 'desc',
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
offset,
|
||||
comparisonEnabled,
|
||||
page = 0,
|
||||
pageSize = 25,
|
||||
device,
|
||||
osVersion,
|
||||
appVersion,
|
||||
netConnectionType,
|
||||
},
|
||||
} = useApmParams('/mobile-services/{serviceName}/errors-and-crashes');
|
||||
const kueryWithMobileFilters = getKueryWithMobileFilters({
|
||||
device,
|
||||
osVersion,
|
||||
appVersion,
|
||||
netConnectionType,
|
||||
kuery,
|
||||
});
|
||||
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
|
||||
const { errorDistributionData, status } = useErrorGroupDistributionFetcher({
|
||||
serviceName,
|
||||
groupId: undefined,
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
});
|
||||
const {
|
||||
data: errorGroupListData = INITIAL_STATE_MAIN_STATISTICS,
|
||||
status: errorGroupListDataStatus,
|
||||
} = useFetcher(
|
||||
(callApmApi) => {
|
||||
const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if (start && end) {
|
||||
return callApmApi(
|
||||
'GET /internal/apm/mobile-services/{serviceName}/errors/groups/main_statistics',
|
||||
{
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query: {
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
start,
|
||||
end,
|
||||
sortField,
|
||||
sortDirection: normalizedSortDirection,
|
||||
},
|
||||
},
|
||||
}
|
||||
).then((response) => {
|
||||
const currentPageGroupIds = orderBy(
|
||||
response.errorGroups,
|
||||
sortField,
|
||||
sortDirection
|
||||
)
|
||||
.slice(page * pageSize, (page + 1) * pageSize)
|
||||
.map(({ groupId }) => groupId)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
// Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched.
|
||||
requestId: uuidv4(),
|
||||
mobileErrorGroupMainStatistics: response.errorGroups,
|
||||
currentPageGroupIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
environment,
|
||||
kueryWithMobileFilters,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
sortField,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
]
|
||||
);
|
||||
const { requestId, mobileErrorGroupMainStatistics, currentPageGroupIds } =
|
||||
errorGroupListData;
|
||||
const {
|
||||
data: mobileErrorGroupDetailedStatistics = INITIAL_STATE_DETAILED_STATISTICS,
|
||||
status: mobileErrorGroupDetailedStatisticsStatus,
|
||||
} = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (requestId && currentPageGroupIds.length && start && end) {
|
||||
return callApmApi(
|
||||
'POST /internal/apm/mobile-services/{serviceName}/errors/groups/detailed_statistics',
|
||||
{
|
||||
params: {
|
||||
path: { serviceName },
|
||||
query: {
|
||||
environment,
|
||||
kuery: kueryWithMobileFilters,
|
||||
start,
|
||||
end,
|
||||
numBuckets: 20,
|
||||
offset:
|
||||
comparisonEnabled && isTimeComparison(offset)
|
||||
? offset
|
||||
: undefined,
|
||||
},
|
||||
body: {
|
||||
groupIds: JSON.stringify(currentPageGroupIds),
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
// only fetches agg results when requestId changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[requestId],
|
||||
{ preservePreviousData: false }
|
||||
);
|
||||
const kueryForTreemap = getKueryWithMobileErrorFilter({
|
||||
kuery: kueryWithMobileFilters,
|
||||
groupId: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="row" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<ChartPointerEventContextProvider>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<ErrorDistribution
|
||||
fetchStatus={status}
|
||||
distribution={errorDistributionData}
|
||||
height={150}
|
||||
title={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.errorRateChart.title',
|
||||
{ defaultMessage: 'Error rate' }
|
||||
)}
|
||||
tip={i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.errorRateChart.tip',
|
||||
{
|
||||
defaultMessage: `Error rate is measured in transactions per minute.`,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<HttpErrorRateChart
|
||||
height={150}
|
||||
kuery={kueryWithMobileFilters}
|
||||
serviceName={serviceName}
|
||||
comparisonEnabled={comparisonEnabled}
|
||||
start={start}
|
||||
end={end}
|
||||
offset={offset}
|
||||
environment={environment}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</ChartPointerEventContextProvider>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<MobileErrorsAndCrashesTreemap
|
||||
serviceName={serviceName}
|
||||
kuery={kueryForTreemap}
|
||||
environment={environment}
|
||||
start={start}
|
||||
end={end}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiPanel hasBorder={true}>
|
||||
<EuiTitle size="xs">
|
||||
<h3>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceDetails.metrics.errorsList.title',
|
||||
{ defaultMessage: 'Errors' }
|
||||
)}
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<MobileErrorGroupList
|
||||
mainStatistics={mobileErrorGroupMainStatistics}
|
||||
serviceName={serviceName}
|
||||
detailedStatisticsLoading={isPending(
|
||||
mobileErrorGroupDetailedStatisticsStatus
|
||||
)}
|
||||
detailedStatistics={mobileErrorGroupDetailedStatistics}
|
||||
comparisonEnabled={comparisonEnabled}
|
||||
initialSortField={sortField}
|
||||
initialSortDirection={sortDirection}
|
||||
isLoading={errorGroupListDataStatus === FETCH_STATUS.LOADING}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Tabs, MobileErrorTabIds } from './tabs/tabs';
|
||||
import { useApmParams } from '../../../../hooks/use_apm_params';
|
||||
import { push } from '../../../shared/links/url_helpers';
|
||||
|
||||
export function MobileErrorCrashesOverview() {
|
||||
const {
|
||||
query: { mobileErrorTabId = MobileErrorTabIds.ERRORS },
|
||||
} = useApmParams('/mobile-services/{serviceName}/errors-and-crashes');
|
||||
const history = useHistory();
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexItem grow={false}>
|
||||
<Tabs
|
||||
onTabClick={(nextTab) => {
|
||||
push(history, {
|
||||
query: {
|
||||
mobileErrorTabId: nextTab,
|
||||
},
|
||||
});
|
||||
}}
|
||||
mobileErrorTabId={mobileErrorTabId as MobileErrorTabIds}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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 { EuiTab, EuiTabs, EuiSpacer } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { MobileErrorsOverview } from '../errors_overview';
|
||||
import { MobileCrashesOverview } from '../crashes_overview';
|
||||
|
||||
export enum MobileErrorTabIds {
|
||||
ERRORS = 'errors',
|
||||
CRASHES = 'crashes',
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: MobileErrorTabIds.ERRORS,
|
||||
name: i18n.translate('xpack.apm.mobile.errorsAndCrashes.errorsTab', {
|
||||
defaultMessage: 'Errors',
|
||||
}),
|
||||
'data-test-subj': 'apmMobileErrorsTabButton',
|
||||
},
|
||||
{
|
||||
id: MobileErrorTabIds.CRASHES,
|
||||
name: i18n.translate('xpack.apm.mobile.errorsAndCrashes.crashesTab', {
|
||||
defaultMessage: 'Crashes',
|
||||
}),
|
||||
append: <div />,
|
||||
'data-test-subj': 'apmMobileCrashesTabButton',
|
||||
},
|
||||
];
|
||||
|
||||
export function Tabs({
|
||||
mobileErrorTabId,
|
||||
onTabClick,
|
||||
}: {
|
||||
mobileErrorTabId: MobileErrorTabIds;
|
||||
onTabClick: (nextTab: MobileErrorTabIds) => void;
|
||||
}) {
|
||||
const selectedTabId = mobileErrorTabId;
|
||||
const tabEntries = tabs.map((tab, index) => (
|
||||
<EuiTab
|
||||
{...tab}
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
onTabClick(tab.id);
|
||||
}}
|
||||
isSelected={tab.id === selectedTabId}
|
||||
append={tab.append}
|
||||
>
|
||||
{tab.name}
|
||||
</EuiTab>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiTabs>{tabEntries}</EuiTabs>
|
||||
<EuiSpacer />
|
||||
{selectedTabId === MobileErrorTabIds.ERRORS && <MobileErrorsOverview />}
|
||||
{selectedTabId === MobileErrorTabIds.CRASHES && <MobileCrashesOverview />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -77,7 +77,8 @@ export function MobileFilters() {
|
|||
} = useAnyOfApmParams(
|
||||
'/mobile-services/{serviceName}/overview',
|
||||
'/mobile-services/{serviceName}/transactions',
|
||||
'/mobile-services/{serviceName}/transactions/view'
|
||||
'/mobile-services/{serviceName}/transactions/view',
|
||||
'/mobile-services/{serviceName}/errors-and-crashes'
|
||||
);
|
||||
|
||||
const filters = { netConnectionType, device, osVersion, appVersion };
|
||||
|
|
|
@ -15,9 +15,7 @@ import {
|
|||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiCallOut,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AnnotationsContextProvider } from '../../../../context/annotations/annotations_context';
|
||||
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
|
||||
|
@ -111,42 +109,6 @@ export function MobileServiceOverview() {
|
|||
<EuiFlexItem>
|
||||
<EuiHorizontalRule />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.apm.serviceOverview.mobileCallOutTitle',
|
||||
{
|
||||
defaultMessage: 'Mobile APM',
|
||||
}
|
||||
)}
|
||||
iconType="mobile"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.apm.serviceOverview.mobileCallOutText"
|
||||
defaultMessage="This is a mobile service, which is currently released as a technical
|
||||
preview. You can help us improve the experience by giving feedback. {feedbackLink}."
|
||||
values={{
|
||||
feedbackLink: (
|
||||
<EuiLink
|
||||
target={'_blank'}
|
||||
data-test-subj="apmMobileServiceOverviewGiveFeedbackLink"
|
||||
href="https://ela.st/feedback-apm-mobile"
|
||||
>
|
||||
{i18n.translate(
|
||||
'xpack.apm.serviceOverview.mobileCallOutLink',
|
||||
{
|
||||
defaultMessage: 'Give feedback',
|
||||
}
|
||||
)}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<MobileStats
|
||||
start={start}
|
||||
|
|
|
@ -134,7 +134,7 @@ export function MobileLocationStats({
|
|||
},
|
||||
{
|
||||
color: euiTheme.eui.euiColorLightestShade,
|
||||
title: i18n.translate('xpack.apm.mobile.location.metrics.crashes', {
|
||||
title: i18n.translate('xpack.apm.mobile.location.metrics.mostCrashes', {
|
||||
defaultMessage: 'Most crashes',
|
||||
}),
|
||||
extra: getComparisonValueFormatter({
|
||||
|
|
|
@ -8,7 +8,7 @@ import React from 'react';
|
|||
import { getVizColorForIndex } from '../../../../common/viz_colors';
|
||||
import { Coordinate, TimeSeries } from '../../../../typings/timeseries';
|
||||
import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
|
||||
import { useApmParams } from '../../../hooks/use_apm_params';
|
||||
import { useAnyOfApmParams } from '../../../hooks/use_apm_params';
|
||||
import { useFetcher } from '../../../hooks/use_fetcher';
|
||||
import { useTimeRange } from '../../../hooks/use_time_range';
|
||||
import { BreakdownChart } from '../../shared/charts/breakdown_chart';
|
||||
|
@ -22,7 +22,10 @@ export function ServiceDependenciesBreakdownChart({
|
|||
|
||||
const {
|
||||
query: { kuery, environment, rangeFrom, rangeTo },
|
||||
} = useApmParams('/services/{serviceName}/dependencies');
|
||||
} = useAnyOfApmParams(
|
||||
'/services/{serviceName}/dependencies',
|
||||
'/mobile-services/{serviceName}/dependencies'
|
||||
);
|
||||
|
||||
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
|
||||
|
||||
|
|
|
@ -22,6 +22,10 @@ import { MobileTransactionOverview } from '../../app/mobile/transaction_overview
|
|||
import { TransactionDetails } from '../../app/transaction_details';
|
||||
import { RedirectToDefaultServiceRouteView } from '../service_detail/redirect_to_default_service_route_view';
|
||||
import { ApmTimeRangeMetadataContextProvider } from '../../../context/time_range_metadata/time_range_metadata_context';
|
||||
import { ErrorGroupDetails } from '../../app/mobile/errors_and_crashes_group_details/error_group_details';
|
||||
import { CrashGroupDetails } from '../../app/mobile/errors_and_crashes_group_details/crash_group_details';
|
||||
import { MobileErrorCrashesOverview } from '../../app/mobile/errors_and_crashes_overview';
|
||||
import { ServiceDependencies } from '../../app/service_dependencies';
|
||||
|
||||
export function page({
|
||||
title,
|
||||
|
@ -178,6 +182,67 @@ export const mobileServiceDetailRoute = {
|
|||
},
|
||||
},
|
||||
},
|
||||
'/mobile-services/{serviceName}/errors-and-crashes': {
|
||||
...page({
|
||||
tabKey: 'errors-and-crashes',
|
||||
title: i18n.translate('xpack.apm.views.errorsAndCrashes.title', {
|
||||
defaultMessage: 'Errors & Crashes',
|
||||
}),
|
||||
element: <Outlet />,
|
||||
searchBarOptions: {
|
||||
showTimeComparison: true,
|
||||
showMobileFilters: true,
|
||||
},
|
||||
}),
|
||||
params: t.partial({
|
||||
query: t.partial({
|
||||
page: toNumberRt,
|
||||
pageSize: toNumberRt,
|
||||
sortField: t.string,
|
||||
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
|
||||
mobileErrorTabId: t.string,
|
||||
device: t.string,
|
||||
osVersion: t.string,
|
||||
appVersion: t.string,
|
||||
netConnectionType: t.string,
|
||||
}),
|
||||
}),
|
||||
children: {
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/errors/{groupId}':
|
||||
{
|
||||
element: <ErrorGroupDetails />,
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
groupId: t.string,
|
||||
}),
|
||||
query: t.partial({ errorId: t.string }),
|
||||
}),
|
||||
},
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/': {
|
||||
element: <MobileErrorCrashesOverview />,
|
||||
},
|
||||
'/mobile-services/{serviceName}/errors-and-crashes/crashes/{groupId}':
|
||||
{
|
||||
element: <CrashGroupDetails />,
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
groupId: t.string,
|
||||
}),
|
||||
query: t.partial({ errorId: t.string }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
'/mobile-services/{serviceName}/dependencies': page({
|
||||
element: <ServiceDependencies />,
|
||||
tabKey: 'dependencies',
|
||||
title: i18n.translate('xpack.apm.views.dependencies.title', {
|
||||
defaultMessage: 'Dependencies',
|
||||
}),
|
||||
searchBarOptions: {
|
||||
showTimeComparison: true,
|
||||
},
|
||||
}),
|
||||
'/mobile-services/{serviceName}/service-map': page({
|
||||
tabKey: 'service-map',
|
||||
title: i18n.translate('xpack.apm.views.serviceMap.title', {
|
||||
|
|
|
@ -24,13 +24,18 @@ import { useTimeRange } from '../../../../hooks/use_time_range';
|
|||
import { getAlertingCapabilities } from '../../../alerting/utils/get_alerting_capabilities';
|
||||
import { MobileSearchBar } from '../../../app/mobile/search_bar';
|
||||
import { ServiceIcons } from '../../../shared/service_icons';
|
||||
import { BetaBadge } from '../../../shared/beta_badge';
|
||||
import { TechnicalPreviewBadge } from '../../../shared/technical_preview_badge';
|
||||
import { ApmMainTemplate } from '../apm_main_template';
|
||||
import { AnalyzeDataButton } from '../apm_service_template/analyze_data_button';
|
||||
|
||||
type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
|
||||
key: 'overview' | 'transactions' | 'service-map' | 'alerts';
|
||||
key:
|
||||
| 'overview'
|
||||
| 'transactions'
|
||||
| 'dependencies'
|
||||
| 'errors-and-crashes'
|
||||
| 'service-map'
|
||||
| 'alerts';
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
|
@ -122,9 +127,6 @@ function TemplateWithContext({
|
|||
end={end}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<BetaBadge icon="beta" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
|
||||
|
@ -190,6 +192,26 @@ function useTabs({ selectedTabKey }: { selectedTabKey: Tab['key'] }) {
|
|||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dependencies',
|
||||
href: router.link('/mobile-services/{serviceName}/dependencies', {
|
||||
path: { serviceName },
|
||||
query,
|
||||
}),
|
||||
label: i18n.translate('xpack.apm.serviceDetails.dependenciesTabLabel', {
|
||||
defaultMessage: 'Dependencies',
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'errors-and-crashes',
|
||||
href: router.link('/mobile-services/{serviceName}/errors-and-crashes', {
|
||||
path: { serviceName },
|
||||
query,
|
||||
}),
|
||||
label: i18n.translate('xpack.apm.serviceDetails.mobileErrorsTabLabel', {
|
||||
defaultMessage: 'Errors & Crashes',
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'service-map',
|
||||
href: router.link('/mobile-services/{serviceName}/service-map', {
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { TypeOf } from '@kbn/typed-react-router-config';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import { mobileServiceDetailRoute } from '../../../../routing/mobile_service_detail';
|
||||
import { useApmRouter } from '../../../../../hooks/use_apm_router';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
serviceName: string;
|
||||
groupId: string;
|
||||
query: TypeOf<
|
||||
typeof mobileServiceDetailRoute,
|
||||
'/mobile-services/{serviceName}/errors-and-crashes'
|
||||
>['query'];
|
||||
}
|
||||
|
||||
function CrashDetailLink({ serviceName, groupId, query, ...rest }: Props) {
|
||||
const router = useApmRouter();
|
||||
const crashDetailsLink = router.link(
|
||||
`/mobile-services/{serviceName}/errors-and-crashes/crashes/{groupId}`,
|
||||
{
|
||||
path: {
|
||||
serviceName,
|
||||
groupId,
|
||||
},
|
||||
query,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiLink
|
||||
data-test-subj="apmCrashDetailsLink"
|
||||
href={crashDetailsLink}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { CrashDetailLink };
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { TypeOf } from '@kbn/typed-react-router-config';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import { useApmRouter } from '../../../../../hooks/use_apm_router';
|
||||
import { mobileServiceDetailRoute } from '../../../../routing/mobile_service_detail';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
serviceName: string;
|
||||
groupId: string;
|
||||
query: TypeOf<
|
||||
typeof mobileServiceDetailRoute,
|
||||
'/mobile-services/{serviceName}/errors-and-crashes'
|
||||
>['query'];
|
||||
}
|
||||
|
||||
function ErrorDetailLink({ serviceName, groupId, query, ...rest }: Props) {
|
||||
const router = useApmRouter();
|
||||
const errorDetailsLink = router.link(
|
||||
`/mobile-services/{serviceName}/errors-and-crashes/errors/{groupId}`,
|
||||
{
|
||||
path: {
|
||||
serviceName,
|
||||
groupId,
|
||||
},
|
||||
query,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiLink
|
||||
data-test-subj="apmErrorDetailsLink"
|
||||
href={errorDetailsLink}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ErrorDetailLink };
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { EuiLink } from '@elastic/eui';
|
||||
import { TypeOf } from '@kbn/typed-react-router-config';
|
||||
import { useApmRouter } from '../../../../../hooks/use_apm_router';
|
||||
import { mobileServiceDetailRoute } from '../../../../routing/mobile_service_detail';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
serviceName: string;
|
||||
query: TypeOf<
|
||||
typeof mobileServiceDetailRoute,
|
||||
'/mobile-services/{serviceName}/errors-and-crashes'
|
||||
>['query'];
|
||||
}
|
||||
|
||||
export function ErrorOverviewLink({ serviceName, query, ...rest }: Props) {
|
||||
const router = useApmRouter();
|
||||
const errorOverviewLink = router.link(
|
||||
'/mobile-services/{serviceName}/errors-and-crashes',
|
||||
{
|
||||
path: {
|
||||
serviceName,
|
||||
},
|
||||
query,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiLink
|
||||
data-test-subj="apmErrorOverviewLinkLink"
|
||||
href={errorOverviewLink}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* 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 { isTimeComparison } from '../components/shared/time_comparison/get_comparison_options';
|
||||
import { useAnyOfApmParams } from './use_apm_params';
|
||||
import { useFetcher } from './use_fetcher';
|
||||
import { useTimeRange } from './use_time_range';
|
||||
|
||||
export function useCrashGroupDistributionFetcher({
|
||||
serviceName,
|
||||
groupId,
|
||||
kuery,
|
||||
environment,
|
||||
}: {
|
||||
serviceName: string;
|
||||
groupId: string | undefined;
|
||||
kuery: string;
|
||||
environment: string;
|
||||
}) {
|
||||
const {
|
||||
query: { rangeFrom, rangeTo, offset, comparisonEnabled },
|
||||
} = useAnyOfApmParams(
|
||||
'/services/{serviceName}/errors',
|
||||
'/mobile-services/{serviceName}/errors-and-crashes'
|
||||
);
|
||||
|
||||
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
|
||||
|
||||
const { data, status } = useFetcher(
|
||||
(callApmApi) => {
|
||||
if (start && end) {
|
||||
return callApmApi(
|
||||
'GET /internal/apm/mobile-services/{serviceName}/crashes/distribution',
|
||||
{
|
||||
params: {
|
||||
path: { serviceName },
|
||||
query: {
|
||||
environment,
|
||||
kuery,
|
||||
start,
|
||||
end,
|
||||
offset:
|
||||
comparisonEnabled && isTimeComparison(offset)
|
||||
? offset
|
||||
: undefined,
|
||||
groupId,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
groupId,
|
||||
comparisonEnabled,
|
||||
]
|
||||
);
|
||||
|
||||
return { crashDistributionData: data, status };
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import { isTimeComparison } from '../components/shared/time_comparison/get_comparison_options';
|
||||
import { useApmParams } from './use_apm_params';
|
||||
import { useAnyOfApmParams } from './use_apm_params';
|
||||
import { useFetcher } from './use_fetcher';
|
||||
import { useTimeRange } from './use_time_range';
|
||||
|
||||
|
@ -22,7 +22,10 @@ export function useErrorGroupDistributionFetcher({
|
|||
}) {
|
||||
const {
|
||||
query: { rangeFrom, rangeTo, offset, comparisonEnabled },
|
||||
} = useApmParams('/services/{serviceName}/errors');
|
||||
} = useAnyOfApmParams(
|
||||
'/services/{serviceName}/errors',
|
||||
'/mobile-services/{serviceName}/errors-and-crashes'
|
||||
);
|
||||
|
||||
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
|
||||
|
||||
|
|
|
@ -165,7 +165,6 @@ export function registerRoutes({
|
|||
_inspect: inspectableEsQueriesMap.get(request),
|
||||
}
|
||||
: { ...data };
|
||||
|
||||
if (!options.disableTelemetry && telemetryUsageCounter) {
|
||||
telemetryUsageCounter.incrementCounter({
|
||||
counterName: `${method.toUpperCase()} ${pathname}`,
|
||||
|
|
|
@ -50,6 +50,11 @@ Array [
|
|||
},
|
||||
},
|
||||
],
|
||||
"must_not": Object {
|
||||
"term": Object {
|
||||
"error.type": "crash",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
|
|
|
@ -42,6 +42,11 @@ Object {
|
|||
},
|
||||
},
|
||||
],
|
||||
"must_not": Object {
|
||||
"term": Object {
|
||||
"error.type": "crash",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
|
@ -97,6 +102,11 @@ Object {
|
|||
},
|
||||
},
|
||||
],
|
||||
"must_not": Object {
|
||||
"term": Object {
|
||||
"error.type": "crash",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
|
|
|
@ -49,6 +49,9 @@ export async function getBuckets({
|
|||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
must_not: {
|
||||
term: { 'error.type': 'crash' },
|
||||
},
|
||||
filter: [
|
||||
{ term: { [SERVICE_NAME]: serviceName } },
|
||||
...rangeQuery(start, end),
|
||||
|
|
63
x-pack/plugins/apm/server/routes/mobile/crashes/distribution/__snapshots__/get_buckets.test.ts.snap
generated
Normal file
63
x-pack/plugins/apm/server/routes/mobile/crashes/distribution/__snapshots__/get_buckets.test.ts.snap
generated
Normal file
|
@ -0,0 +1,63 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`get buckets should make the correct query 1`] = `
|
||||
Array [
|
||||
Array [
|
||||
"get_error_distribution_buckets",
|
||||
Object {
|
||||
"apm": Object {
|
||||
"events": Array [
|
||||
"error",
|
||||
],
|
||||
},
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"distribution": Object {
|
||||
"histogram": Object {
|
||||
"extended_bounds": Object {
|
||||
"max": 1528977600000,
|
||||
"min": 1528113600000,
|
||||
},
|
||||
"field": "@timestamp",
|
||||
"interval": 10,
|
||||
"min_doc_count": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"error.type": "crash",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"service.name": "myServiceName",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"format": "epoch_millis",
|
||||
"gte": 1528113600000,
|
||||
"lte": 1528977600000,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"service.environment": "prod",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
"track_total_hits": false,
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
110
x-pack/plugins/apm/server/routes/mobile/crashes/distribution/__snapshots__/queries.test.ts.snap
generated
Normal file
110
x-pack/plugins/apm/server/routes/mobile/crashes/distribution/__snapshots__/queries.test.ts.snap
generated
Normal file
|
@ -0,0 +1,110 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`error distribution queries fetches an error distribution 1`] = `
|
||||
Object {
|
||||
"apm": Object {
|
||||
"events": Array [
|
||||
"error",
|
||||
],
|
||||
},
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"distribution": Object {
|
||||
"histogram": Object {
|
||||
"extended_bounds": Object {
|
||||
"max": 50000,
|
||||
"min": 0,
|
||||
},
|
||||
"field": "@timestamp",
|
||||
"interval": 3333,
|
||||
"min_doc_count": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"error.type": "crash",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"service.name": "serviceName",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"format": "epoch_millis",
|
||||
"gte": 0,
|
||||
"lte": 50000,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
"track_total_hits": false,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`error distribution queries fetches an error distribution with a group id 1`] = `
|
||||
Object {
|
||||
"apm": Object {
|
||||
"events": Array [
|
||||
"error",
|
||||
],
|
||||
},
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"distribution": Object {
|
||||
"histogram": Object {
|
||||
"extended_bounds": Object {
|
||||
"max": 50000,
|
||||
"min": 0,
|
||||
},
|
||||
"field": "@timestamp",
|
||||
"interval": 3333,
|
||||
"min_doc_count": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"error.type": "crash",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"service.name": "serviceName",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"format": "epoch_millis",
|
||||
"gte": 0,
|
||||
"lte": 50000,
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"error.grouping_key": "foo",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
"track_total_hits": false,
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { getBuckets } from './get_buckets';
|
||||
import { ProcessorEvent } from '@kbn/observability-plugin/common';
|
||||
|
||||
describe('get buckets', () => {
|
||||
let clientSpy: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
clientSpy = jest.fn().mockResolvedValueOnce({
|
||||
hits: {
|
||||
total: 100,
|
||||
},
|
||||
aggregations: {
|
||||
distribution: {
|
||||
buckets: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await getBuckets({
|
||||
environment: 'prod',
|
||||
serviceName: 'myServiceName',
|
||||
bucketSize: 10,
|
||||
kuery: '',
|
||||
apmEventClient: {
|
||||
search: clientSpy,
|
||||
} as any,
|
||||
start: 1528113600000,
|
||||
end: 1528977600000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should make the correct query', () => {
|
||||
expect(clientSpy.mock.calls).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should limit query results to error documents', () => {
|
||||
const query = clientSpy.mock.calls[0][1];
|
||||
expect(query.apm.events).toEqual([ProcessorEvent.error]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 {
|
||||
rangeQuery,
|
||||
kqlQuery,
|
||||
termQuery,
|
||||
} from '@kbn/observability-plugin/server';
|
||||
import { ProcessorEvent } from '@kbn/observability-plugin/common';
|
||||
import {
|
||||
ERROR_GROUP_ID,
|
||||
SERVICE_NAME,
|
||||
ERROR_TYPE,
|
||||
} from '../../../../../common/es_fields/apm';
|
||||
import { environmentQuery } from '../../../../../common/utils/environment_query';
|
||||
import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client';
|
||||
|
||||
export async function getBuckets({
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
groupId,
|
||||
bucketSize,
|
||||
apmEventClient,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
environment: string;
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
groupId?: string;
|
||||
bucketSize: number;
|
||||
apmEventClient: APMEventClient;
|
||||
start: number;
|
||||
end: number;
|
||||
}) {
|
||||
const params = {
|
||||
apm: {
|
||||
events: [ProcessorEvent.error],
|
||||
},
|
||||
body: {
|
||||
track_total_hits: false,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...termQuery(ERROR_TYPE, 'crash'),
|
||||
...termQuery(SERVICE_NAME, serviceName),
|
||||
...rangeQuery(start, end),
|
||||
...environmentQuery(environment),
|
||||
...kqlQuery(kuery),
|
||||
...termQuery(ERROR_GROUP_ID, groupId),
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
distribution: {
|
||||
histogram: {
|
||||
field: '@timestamp',
|
||||
min_doc_count: 0,
|
||||
interval: bucketSize,
|
||||
extended_bounds: {
|
||||
min: start,
|
||||
max: end,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resp = await apmEventClient.search(
|
||||
'get_error_distribution_buckets',
|
||||
params
|
||||
);
|
||||
|
||||
const buckets = (resp.aggregations?.distribution.buckets || []).map(
|
||||
(bucket) => ({
|
||||
x: bucket.key,
|
||||
y: bucket.doc_count,
|
||||
})
|
||||
);
|
||||
return { buckets };
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { offsetPreviousPeriodCoordinates } from '../../../../../common/utils/offset_previous_period_coordinate';
|
||||
import { BUCKET_TARGET_COUNT } from '../../../transactions/constants';
|
||||
import { getBuckets } from './get_buckets';
|
||||
import { getOffsetInMs } from '../../../../../common/utils/get_offset_in_ms';
|
||||
import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client';
|
||||
import { Maybe } from '../../../../../typings/common';
|
||||
|
||||
function getBucketSize({ start, end }: { start: number; end: number }) {
|
||||
return Math.floor((end - start) / BUCKET_TARGET_COUNT);
|
||||
}
|
||||
|
||||
export interface CrashDistributionResponse {
|
||||
currentPeriod: Array<{ x: number; y: number }>;
|
||||
previousPeriod: Array<{
|
||||
x: number;
|
||||
y: Maybe<number>;
|
||||
}>;
|
||||
bucketSize: number;
|
||||
}
|
||||
|
||||
export async function getCrashDistribution({
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
groupId,
|
||||
apmEventClient,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
}: {
|
||||
environment: string;
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
groupId?: string;
|
||||
apmEventClient: APMEventClient;
|
||||
start: number;
|
||||
end: number;
|
||||
offset?: string;
|
||||
}): Promise<CrashDistributionResponse> {
|
||||
const { startWithOffset, endWithOffset } = getOffsetInMs({
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
});
|
||||
|
||||
const bucketSize = getBucketSize({
|
||||
start: startWithOffset,
|
||||
end: endWithOffset,
|
||||
});
|
||||
|
||||
const commonProps = {
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
groupId,
|
||||
apmEventClient,
|
||||
bucketSize,
|
||||
};
|
||||
const currentPeriodPromise = getBuckets({
|
||||
...commonProps,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const previousPeriodPromise = offset
|
||||
? getBuckets({
|
||||
...commonProps,
|
||||
start: startWithOffset,
|
||||
end: endWithOffset,
|
||||
})
|
||||
: { buckets: [], bucketSize: null };
|
||||
|
||||
const [currentPeriod, previousPeriod] = await Promise.all([
|
||||
currentPeriodPromise,
|
||||
previousPeriodPromise,
|
||||
]);
|
||||
|
||||
return {
|
||||
currentPeriod: currentPeriod.buckets,
|
||||
previousPeriod: offsetPreviousPeriodCoordinates({
|
||||
currentPeriodTimeseries: currentPeriod.buckets,
|
||||
previousPeriodTimeseries: previousPeriod.buckets,
|
||||
}),
|
||||
bucketSize,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getCrashDistribution } from './get_distribution';
|
||||
import {
|
||||
SearchParamsMock,
|
||||
inspectSearchParams,
|
||||
} from '../../../../utils/test_helpers';
|
||||
import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values';
|
||||
|
||||
describe('error distribution queries', () => {
|
||||
let mock: SearchParamsMock;
|
||||
|
||||
afterEach(() => {
|
||||
mock.teardown();
|
||||
});
|
||||
|
||||
it('fetches an error distribution', async () => {
|
||||
mock = await inspectSearchParams(({ mockApmEventClient }) =>
|
||||
getCrashDistribution({
|
||||
serviceName: 'serviceName',
|
||||
apmEventClient: mockApmEventClient,
|
||||
environment: ENVIRONMENT_ALL.value,
|
||||
kuery: '',
|
||||
start: 0,
|
||||
end: 50000,
|
||||
})
|
||||
);
|
||||
|
||||
expect(mock.params).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('fetches an error distribution with a group id', async () => {
|
||||
mock = await inspectSearchParams(({ mockApmEventClient }) =>
|
||||
getCrashDistribution({
|
||||
serviceName: 'serviceName',
|
||||
groupId: 'foo',
|
||||
apmEventClient: mockApmEventClient,
|
||||
environment: ENVIRONMENT_ALL.value,
|
||||
kuery: '',
|
||||
start: 0,
|
||||
end: 50000,
|
||||
})
|
||||
);
|
||||
|
||||
expect(mock.params).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* 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 { AggregationsAggregateOrder } from '@elastic/elasticsearch/lib/api/types';
|
||||
import {
|
||||
kqlQuery,
|
||||
rangeQuery,
|
||||
termQuery,
|
||||
} from '@kbn/observability-plugin/server';
|
||||
import { ProcessorEvent } from '@kbn/observability-plugin/common';
|
||||
import {
|
||||
ERROR_CULPRIT,
|
||||
ERROR_TYPE,
|
||||
ERROR_EXC_HANDLED,
|
||||
ERROR_EXC_MESSAGE,
|
||||
ERROR_EXC_TYPE,
|
||||
ERROR_GROUP_ID,
|
||||
ERROR_LOG_MESSAGE,
|
||||
SERVICE_NAME,
|
||||
TRANSACTION_NAME,
|
||||
TRANSACTION_TYPE,
|
||||
} from '../../../../../common/es_fields/apm';
|
||||
import { environmentQuery } from '../../../../../common/utils/environment_query';
|
||||
import { getErrorName } from '../../../../lib/helpers/get_error_name';
|
||||
import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client';
|
||||
|
||||
export type MobileCrashGroupMainStatisticsResponse = Array<{
|
||||
groupId: string;
|
||||
name: string;
|
||||
lastSeen: number;
|
||||
occurrences: number;
|
||||
culprit: string | undefined;
|
||||
handled: boolean | undefined;
|
||||
type: string | undefined;
|
||||
}>;
|
||||
|
||||
export async function getMobileCrashGroupMainStatistics({
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
environment,
|
||||
sortField,
|
||||
sortDirection = 'desc',
|
||||
start,
|
||||
end,
|
||||
maxNumberOfErrorGroups = 500,
|
||||
transactionName,
|
||||
transactionType,
|
||||
}: {
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
apmEventClient: APMEventClient;
|
||||
environment: string;
|
||||
sortField?: string;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
start: number;
|
||||
end: number;
|
||||
maxNumberOfErrorGroups?: number;
|
||||
transactionName?: string;
|
||||
transactionType?: string;
|
||||
}): Promise<MobileCrashGroupMainStatisticsResponse> {
|
||||
// sort buckets by last occurrence of error
|
||||
const sortByLatestOccurrence = sortField === 'lastSeen';
|
||||
|
||||
const maxTimestampAggKey = 'max_timestamp';
|
||||
|
||||
const order: AggregationsAggregateOrder = sortByLatestOccurrence
|
||||
? { [maxTimestampAggKey]: sortDirection }
|
||||
: { _count: sortDirection };
|
||||
|
||||
const response = await apmEventClient.search(
|
||||
'get_crash_group_main_statistics',
|
||||
{
|
||||
apm: {
|
||||
events: [ProcessorEvent.error],
|
||||
},
|
||||
body: {
|
||||
track_total_hits: false,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...termQuery(SERVICE_NAME, serviceName),
|
||||
...termQuery(TRANSACTION_NAME, transactionName),
|
||||
...termQuery(TRANSACTION_TYPE, transactionType),
|
||||
...rangeQuery(start, end),
|
||||
...environmentQuery(environment),
|
||||
...termQuery(ERROR_TYPE, 'crash'),
|
||||
...kqlQuery(kuery),
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
crash_groups: {
|
||||
terms: {
|
||||
field: ERROR_GROUP_ID,
|
||||
size: maxNumberOfErrorGroups,
|
||||
order,
|
||||
},
|
||||
aggs: {
|
||||
sample: {
|
||||
top_hits: {
|
||||
size: 1,
|
||||
_source: [
|
||||
ERROR_LOG_MESSAGE,
|
||||
ERROR_EXC_MESSAGE,
|
||||
ERROR_EXC_HANDLED,
|
||||
ERROR_EXC_TYPE,
|
||||
ERROR_CULPRIT,
|
||||
ERROR_GROUP_ID,
|
||||
'@timestamp',
|
||||
],
|
||||
sort: {
|
||||
'@timestamp': 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
...(sortByLatestOccurrence
|
||||
? { [maxTimestampAggKey]: { max: { field: '@timestamp' } } }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
response.aggregations?.crash_groups.buckets.map((bucket) => ({
|
||||
groupId: bucket.key as string,
|
||||
name: getErrorName(bucket.sample.hits.hits[0]._source),
|
||||
lastSeen: new Date(
|
||||
bucket.sample.hits.hits[0]?._source['@timestamp']
|
||||
).getTime(),
|
||||
occurrences: bucket.doc_count,
|
||||
culprit: bucket.sample.hits.hits[0]?._source.error.culprit,
|
||||
handled: bucket.sample.hits.hits[0]?._source.error.exception?.[0].handled,
|
||||
type: bucket.sample.hits.hits[0]?._source.error.exception?.[0].type,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
* 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 { keyBy } from 'lodash';
|
||||
import {
|
||||
rangeQuery,
|
||||
kqlQuery,
|
||||
termQuery,
|
||||
termsQuery,
|
||||
} from '@kbn/observability-plugin/server';
|
||||
import { ProcessorEvent } from '@kbn/observability-plugin/common';
|
||||
import { offsetPreviousPeriodCoordinates } from '../../../../common/utils/offset_previous_period_coordinate';
|
||||
import { Coordinate } from '../../../../typings/timeseries';
|
||||
import {
|
||||
ERROR_GROUP_ID,
|
||||
ERROR_TYPE,
|
||||
SERVICE_NAME,
|
||||
} from '../../../../common/es_fields/apm';
|
||||
import { environmentQuery } from '../../../../common/utils/environment_query';
|
||||
import { getBucketSize } from '../../../../common/utils/get_bucket_size';
|
||||
import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms';
|
||||
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
|
||||
|
||||
interface CrashGroupDetailedStat {
|
||||
groupId: string;
|
||||
timeseries: Coordinate[];
|
||||
}
|
||||
|
||||
export async function getMobileCrashesGroupDetailedStatistics({
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
numBuckets,
|
||||
groupIds,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
}: {
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
apmEventClient: APMEventClient;
|
||||
numBuckets: number;
|
||||
groupIds: string[];
|
||||
environment: string;
|
||||
start: number;
|
||||
end: number;
|
||||
offset?: string;
|
||||
}): Promise<CrashGroupDetailedStat[]> {
|
||||
const { startWithOffset, endWithOffset } = getOffsetInMs({
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
});
|
||||
|
||||
const { intervalString } = getBucketSize({
|
||||
start: startWithOffset,
|
||||
end: endWithOffset,
|
||||
numBuckets,
|
||||
});
|
||||
|
||||
const timeseriesResponse = await apmEventClient.search(
|
||||
'get_service_error_group_detailed_statistics',
|
||||
{
|
||||
apm: {
|
||||
events: [ProcessorEvent.error],
|
||||
},
|
||||
body: {
|
||||
track_total_hits: false,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...termsQuery(ERROR_GROUP_ID, ...groupIds),
|
||||
...termsQuery(ERROR_TYPE, 'crash'),
|
||||
...termQuery(SERVICE_NAME, serviceName),
|
||||
...rangeQuery(startWithOffset, endWithOffset),
|
||||
...environmentQuery(environment),
|
||||
...kqlQuery(kuery),
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
error_groups: {
|
||||
terms: {
|
||||
field: ERROR_GROUP_ID,
|
||||
size: 500,
|
||||
},
|
||||
aggs: {
|
||||
timeseries: {
|
||||
date_histogram: {
|
||||
field: '@timestamp',
|
||||
fixed_interval: intervalString,
|
||||
min_doc_count: 0,
|
||||
extended_bounds: {
|
||||
min: startWithOffset,
|
||||
max: endWithOffset,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!timeseriesResponse.aggregations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return timeseriesResponse.aggregations.error_groups.buckets.map((bucket) => {
|
||||
const groupId = bucket.key as string;
|
||||
return {
|
||||
groupId,
|
||||
timeseries: bucket.timeseries.buckets.map((timeseriesBucket) => {
|
||||
return {
|
||||
x: timeseriesBucket.key,
|
||||
y: timeseriesBucket.doc_count,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface MobileCrashesGroupPeriodsResponse {
|
||||
currentPeriod: Record<string, CrashGroupDetailedStat>;
|
||||
previousPeriod: Record<string, CrashGroupDetailedStat>;
|
||||
}
|
||||
|
||||
export async function getMobileCrashesGroupPeriods({
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
numBuckets,
|
||||
groupIds,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
}: {
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
apmEventClient: APMEventClient;
|
||||
numBuckets: number;
|
||||
groupIds: string[];
|
||||
environment: string;
|
||||
start: number;
|
||||
end: number;
|
||||
offset?: string;
|
||||
}): Promise<MobileCrashesGroupPeriodsResponse> {
|
||||
const commonProps = {
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
numBuckets,
|
||||
groupIds,
|
||||
};
|
||||
|
||||
const currentPeriodPromise = getMobileCrashesGroupDetailedStatistics({
|
||||
...commonProps,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const previousPeriodPromise = offset
|
||||
? getMobileCrashesGroupDetailedStatistics({
|
||||
...commonProps,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
})
|
||||
: [];
|
||||
|
||||
const [currentPeriod, previousPeriod] = await Promise.all([
|
||||
currentPeriodPromise,
|
||||
previousPeriodPromise,
|
||||
]);
|
||||
|
||||
const firstCurrentPeriod = currentPeriod?.[0];
|
||||
|
||||
return {
|
||||
currentPeriod: keyBy(currentPeriod, 'groupId'),
|
||||
previousPeriod: keyBy(
|
||||
previousPeriod.map((crashRateGroup) => ({
|
||||
...crashRateGroup,
|
||||
timeseries: offsetPreviousPeriodCoordinates({
|
||||
currentPeriodTimeseries: firstCurrentPeriod?.timeseries,
|
||||
previousPeriodTimeseries: crashRateGroup.timeseries,
|
||||
}),
|
||||
})),
|
||||
'groupId'
|
||||
),
|
||||
};
|
||||
}
|
151
x-pack/plugins/apm/server/routes/mobile/crashes/route.ts
Normal file
151
x-pack/plugins/apm/server/routes/mobile/crashes/route.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* 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 * as t from 'io-ts';
|
||||
import { jsonRt, toNumberRt } from '@kbn/io-ts-utils';
|
||||
import { getApmEventClient } from '../../../lib/helpers/get_apm_event_client';
|
||||
import { createApmServerRoute } from '../../apm_routes/create_apm_server_route';
|
||||
import { environmentRt, kueryRt, rangeRt } from '../../default_api_types';
|
||||
import { offsetRt } from '../../../../common/comparison_rt';
|
||||
import {
|
||||
getMobileCrashGroupMainStatistics,
|
||||
MobileCrashGroupMainStatisticsResponse,
|
||||
} from './get_crash_groups/get_crash_group_main_statistics';
|
||||
import {
|
||||
MobileCrashesGroupPeriodsResponse,
|
||||
getMobileCrashesGroupPeriods,
|
||||
} from './get_mobile_crash_group_detailed_statistics';
|
||||
import {
|
||||
CrashDistributionResponse,
|
||||
getCrashDistribution,
|
||||
} from './distribution/get_distribution';
|
||||
|
||||
const mobileCrashDistributionRoute = createApmServerRoute({
|
||||
endpoint:
|
||||
'GET /internal/apm/mobile-services/{serviceName}/crashes/distribution',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
serviceName: t.string,
|
||||
}),
|
||||
query: t.intersection([
|
||||
t.partial({
|
||||
groupId: t.string,
|
||||
}),
|
||||
environmentRt,
|
||||
kueryRt,
|
||||
rangeRt,
|
||||
offsetRt,
|
||||
]),
|
||||
}),
|
||||
options: { tags: ['access:apm'] },
|
||||
handler: async (resources): Promise<CrashDistributionResponse> => {
|
||||
const apmEventClient = await getApmEventClient(resources);
|
||||
const { params } = resources;
|
||||
const { serviceName } = params.path;
|
||||
const { environment, kuery, groupId, start, end, offset } = params.query;
|
||||
return getCrashDistribution({
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
groupId,
|
||||
apmEventClient,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mobileCrashMainStatisticsRoute = createApmServerRoute({
|
||||
endpoint:
|
||||
'GET /internal/apm/mobile-services/{serviceName}/crashes/groups/main_statistics',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
serviceName: t.string,
|
||||
}),
|
||||
query: t.intersection([
|
||||
t.partial({
|
||||
sortField: t.string,
|
||||
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
|
||||
}),
|
||||
environmentRt,
|
||||
kueryRt,
|
||||
rangeRt,
|
||||
]),
|
||||
}),
|
||||
options: { tags: ['access:apm'] },
|
||||
handler: async (
|
||||
resources
|
||||
): Promise<{ errorGroups: MobileCrashGroupMainStatisticsResponse }> => {
|
||||
const { params } = resources;
|
||||
const apmEventClient = await getApmEventClient(resources);
|
||||
const { serviceName } = params.path;
|
||||
const { environment, kuery, sortField, sortDirection, start, end } =
|
||||
params.query;
|
||||
|
||||
const errorGroups = await getMobileCrashGroupMainStatistics({
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
sortField,
|
||||
sortDirection,
|
||||
apmEventClient,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
return { errorGroups };
|
||||
},
|
||||
});
|
||||
|
||||
const mobileCrashDetailedStatisticsRoute = createApmServerRoute({
|
||||
endpoint:
|
||||
'POST /internal/apm/mobile-services/{serviceName}/crashes/groups/detailed_statistics',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
serviceName: t.string,
|
||||
}),
|
||||
query: t.intersection([
|
||||
environmentRt,
|
||||
kueryRt,
|
||||
rangeRt,
|
||||
offsetRt,
|
||||
t.type({
|
||||
numBuckets: toNumberRt,
|
||||
}),
|
||||
]),
|
||||
body: t.type({ groupIds: jsonRt.pipe(t.array(t.string)) }),
|
||||
}),
|
||||
options: { tags: ['access:apm'] },
|
||||
handler: async (resources): Promise<MobileCrashesGroupPeriodsResponse> => {
|
||||
const apmEventClient = await getApmEventClient(resources);
|
||||
const { params } = resources;
|
||||
|
||||
const {
|
||||
path: { serviceName },
|
||||
query: { environment, kuery, numBuckets, start, end, offset },
|
||||
body: { groupIds },
|
||||
} = params;
|
||||
|
||||
return getMobileCrashesGroupPeriods({
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
numBuckets,
|
||||
groupIds,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const mobileCrashRoutes = {
|
||||
...mobileCrashDetailedStatisticsRoute,
|
||||
...mobileCrashMainStatisticsRoute,
|
||||
...mobileCrashDistributionRoute,
|
||||
};
|
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
* 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 { keyBy } from 'lodash';
|
||||
import {
|
||||
rangeQuery,
|
||||
kqlQuery,
|
||||
termQuery,
|
||||
termsQuery,
|
||||
} from '@kbn/observability-plugin/server';
|
||||
import { ProcessorEvent } from '@kbn/observability-plugin/common';
|
||||
import { offsetPreviousPeriodCoordinates } from '../../../../common/utils/offset_previous_period_coordinate';
|
||||
import { Coordinate } from '../../../../typings/timeseries';
|
||||
import { ERROR_GROUP_ID, SERVICE_NAME } from '../../../../common/es_fields/apm';
|
||||
import { environmentQuery } from '../../../../common/utils/environment_query';
|
||||
import { getBucketSize } from '../../../../common/utils/get_bucket_size';
|
||||
import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms';
|
||||
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
|
||||
|
||||
interface ErrorGroupDetailedStat {
|
||||
groupId: string;
|
||||
timeseries: Coordinate[];
|
||||
}
|
||||
|
||||
export async function getMobileErrorGroupDetailedStatistics({
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
numBuckets,
|
||||
groupIds,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
}: {
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
apmEventClient: APMEventClient;
|
||||
numBuckets: number;
|
||||
groupIds: string[];
|
||||
environment: string;
|
||||
start: number;
|
||||
end: number;
|
||||
offset?: string;
|
||||
}): Promise<ErrorGroupDetailedStat[]> {
|
||||
const { startWithOffset, endWithOffset } = getOffsetInMs({
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
});
|
||||
|
||||
const { intervalString } = getBucketSize({
|
||||
start: startWithOffset,
|
||||
end: endWithOffset,
|
||||
numBuckets,
|
||||
});
|
||||
|
||||
const timeseriesResponse = await apmEventClient.search(
|
||||
'get_service_error_group_detailed_statistics',
|
||||
{
|
||||
apm: {
|
||||
events: [ProcessorEvent.error],
|
||||
},
|
||||
body: {
|
||||
track_total_hits: false,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...termsQuery(ERROR_GROUP_ID, ...groupIds),
|
||||
...termQuery(SERVICE_NAME, serviceName),
|
||||
...rangeQuery(startWithOffset, endWithOffset),
|
||||
...environmentQuery(environment),
|
||||
...kqlQuery(kuery),
|
||||
],
|
||||
must_not: {
|
||||
term: { 'error.type': 'crash' },
|
||||
},
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
error_groups: {
|
||||
terms: {
|
||||
field: ERROR_GROUP_ID,
|
||||
size: 500,
|
||||
},
|
||||
aggs: {
|
||||
timeseries: {
|
||||
date_histogram: {
|
||||
field: '@timestamp',
|
||||
fixed_interval: intervalString,
|
||||
min_doc_count: 0,
|
||||
extended_bounds: {
|
||||
min: startWithOffset,
|
||||
max: endWithOffset,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!timeseriesResponse.aggregations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return timeseriesResponse.aggregations.error_groups.buckets.map((bucket) => {
|
||||
const groupId = bucket.key as string;
|
||||
return {
|
||||
groupId,
|
||||
timeseries: bucket.timeseries.buckets.map((timeseriesBucket) => {
|
||||
return {
|
||||
x: timeseriesBucket.key,
|
||||
y: timeseriesBucket.doc_count,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface MobileErrorGroupPeriodsResponse {
|
||||
currentPeriod: Record<string, ErrorGroupDetailedStat>;
|
||||
previousPeriod: Record<string, ErrorGroupDetailedStat>;
|
||||
}
|
||||
|
||||
export async function getMobileErrorGroupPeriods({
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
numBuckets,
|
||||
groupIds,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
}: {
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
apmEventClient: APMEventClient;
|
||||
numBuckets: number;
|
||||
groupIds: string[];
|
||||
environment: string;
|
||||
start: number;
|
||||
end: number;
|
||||
offset?: string;
|
||||
}): Promise<MobileErrorGroupPeriodsResponse> {
|
||||
const commonProps = {
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
numBuckets,
|
||||
groupIds,
|
||||
};
|
||||
|
||||
const currentPeriodPromise = getMobileErrorGroupDetailedStatistics({
|
||||
...commonProps,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
const previousPeriodPromise = offset
|
||||
? getMobileErrorGroupDetailedStatistics({
|
||||
...commonProps,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
})
|
||||
: [];
|
||||
|
||||
const [currentPeriod, previousPeriod] = await Promise.all([
|
||||
currentPeriodPromise,
|
||||
previousPeriodPromise,
|
||||
]);
|
||||
|
||||
const firstCurrentPeriod = currentPeriod?.[0];
|
||||
|
||||
return {
|
||||
currentPeriod: keyBy(currentPeriod, 'groupId'),
|
||||
previousPeriod: keyBy(
|
||||
previousPeriod.map((errorRateGroup) => ({
|
||||
...errorRateGroup,
|
||||
timeseries: offsetPreviousPeriodCoordinates({
|
||||
currentPeriodTimeseries: firstCurrentPeriod?.timeseries,
|
||||
previousPeriodTimeseries: errorRateGroup.timeseries,
|
||||
}),
|
||||
})),
|
||||
'groupId'
|
||||
),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* 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 { AggregationsAggregateOrder } from '@elastic/elasticsearch/lib/api/types';
|
||||
import {
|
||||
kqlQuery,
|
||||
rangeQuery,
|
||||
termQuery,
|
||||
} from '@kbn/observability-plugin/server';
|
||||
import { ProcessorEvent } from '@kbn/observability-plugin/common';
|
||||
import {
|
||||
ERROR_CULPRIT,
|
||||
ERROR_EXC_HANDLED,
|
||||
ERROR_EXC_MESSAGE,
|
||||
ERROR_EXC_TYPE,
|
||||
ERROR_GROUP_ID,
|
||||
ERROR_LOG_MESSAGE,
|
||||
SERVICE_NAME,
|
||||
TRANSACTION_NAME,
|
||||
TRANSACTION_TYPE,
|
||||
} from '../../../../common/es_fields/apm';
|
||||
import { environmentQuery } from '../../../../common/utils/environment_query';
|
||||
import { getErrorName } from '../../../lib/helpers/get_error_name';
|
||||
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
|
||||
|
||||
export type MobileErrorGroupMainStatisticsResponse = Array<{
|
||||
groupId: string;
|
||||
name: string;
|
||||
lastSeen: number;
|
||||
occurrences: number;
|
||||
culprit: string | undefined;
|
||||
handled: boolean | undefined;
|
||||
type: string | undefined;
|
||||
}>;
|
||||
|
||||
export async function getMobileErrorGroupMainStatistics({
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
environment,
|
||||
sortField,
|
||||
sortDirection = 'desc',
|
||||
start,
|
||||
end,
|
||||
maxNumberOfErrorGroups = 500,
|
||||
transactionName,
|
||||
transactionType,
|
||||
}: {
|
||||
kuery: string;
|
||||
serviceName: string;
|
||||
apmEventClient: APMEventClient;
|
||||
environment: string;
|
||||
sortField?: string;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
start: number;
|
||||
end: number;
|
||||
maxNumberOfErrorGroups?: number;
|
||||
transactionName?: string;
|
||||
transactionType?: string;
|
||||
}): Promise<MobileErrorGroupMainStatisticsResponse> {
|
||||
// sort buckets by last occurrence of error
|
||||
const sortByLatestOccurrence = sortField === 'lastSeen';
|
||||
|
||||
const maxTimestampAggKey = 'max_timestamp';
|
||||
|
||||
const order: AggregationsAggregateOrder = sortByLatestOccurrence
|
||||
? { [maxTimestampAggKey]: sortDirection }
|
||||
: { _count: sortDirection };
|
||||
|
||||
const response = await apmEventClient.search(
|
||||
'get_error_group_main_statistics',
|
||||
{
|
||||
apm: {
|
||||
events: [ProcessorEvent.error],
|
||||
},
|
||||
body: {
|
||||
track_total_hits: false,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
must_not: {
|
||||
term: { 'error.type': 'crash' },
|
||||
},
|
||||
filter: [
|
||||
...termQuery(SERVICE_NAME, serviceName),
|
||||
...termQuery(TRANSACTION_NAME, transactionName),
|
||||
...termQuery(TRANSACTION_TYPE, transactionType),
|
||||
...rangeQuery(start, end),
|
||||
...environmentQuery(environment),
|
||||
...kqlQuery(kuery),
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
error_groups: {
|
||||
terms: {
|
||||
field: ERROR_GROUP_ID,
|
||||
size: maxNumberOfErrorGroups,
|
||||
order,
|
||||
},
|
||||
aggs: {
|
||||
sample: {
|
||||
top_hits: {
|
||||
size: 1,
|
||||
_source: [
|
||||
ERROR_LOG_MESSAGE,
|
||||
ERROR_EXC_MESSAGE,
|
||||
ERROR_EXC_HANDLED,
|
||||
ERROR_EXC_TYPE,
|
||||
ERROR_CULPRIT,
|
||||
ERROR_GROUP_ID,
|
||||
'@timestamp',
|
||||
],
|
||||
sort: {
|
||||
'@timestamp': 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
...(sortByLatestOccurrence
|
||||
? { [maxTimestampAggKey]: { max: { field: '@timestamp' } } }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
response.aggregations?.error_groups.buckets.map((bucket) => ({
|
||||
groupId: bucket.key as string,
|
||||
name: getErrorName(bucket.sample.hits.hits[0]._source),
|
||||
lastSeen: new Date(
|
||||
bucket.sample.hits.hits[0]?._source['@timestamp']
|
||||
).getTime(),
|
||||
occurrences: bucket.doc_count,
|
||||
culprit: bucket.sample.hits.hits[0]?._source.error.culprit,
|
||||
handled: bucket.sample.hits.hits[0]?._source.error.exception?.[0].handled,
|
||||
type: bucket.sample.hits.hits[0]?._source.error.exception?.[0].type,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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 {
|
||||
termQuery,
|
||||
kqlQuery,
|
||||
rangeQuery,
|
||||
} from '@kbn/observability-plugin/server';
|
||||
import { ProcessorEvent } from '@kbn/observability-plugin/common';
|
||||
import { SERVICE_NAME } from '../../../../common/es_fields/apm';
|
||||
import { environmentQuery } from '../../../../common/utils/environment_query';
|
||||
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
|
||||
|
||||
export type MobileErrorTermsByFieldResponse = Array<{
|
||||
label: string;
|
||||
count: number;
|
||||
}>;
|
||||
|
||||
export async function getMobileErrorsTermsByField({
|
||||
kuery,
|
||||
apmEventClient,
|
||||
serviceName,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
size,
|
||||
fieldName,
|
||||
}: {
|
||||
kuery: string;
|
||||
apmEventClient: APMEventClient;
|
||||
serviceName: string;
|
||||
environment: string;
|
||||
start: number;
|
||||
end: number;
|
||||
size: number;
|
||||
fieldName: string;
|
||||
}): Promise<MobileErrorTermsByFieldResponse> {
|
||||
const response = await apmEventClient.search(
|
||||
`get_mobile_terms_by_${fieldName}`,
|
||||
{
|
||||
apm: {
|
||||
events: [ProcessorEvent.error],
|
||||
},
|
||||
body: {
|
||||
track_total_hits: false,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...termQuery(SERVICE_NAME, serviceName),
|
||||
...rangeQuery(start, end),
|
||||
...environmentQuery(environment),
|
||||
...kqlQuery(kuery),
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
terms: {
|
||||
terms: {
|
||||
field: fieldName,
|
||||
size,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
response.aggregations?.terms?.buckets?.map(({ key, doc_count: count }) => ({
|
||||
label: key as string,
|
||||
count,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* 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 { ProcessorEvent } from '@kbn/observability-plugin/common';
|
||||
import {
|
||||
kqlQuery,
|
||||
rangeQuery,
|
||||
termQuery,
|
||||
} from '@kbn/observability-plugin/server';
|
||||
import { APMEventClient } from '../../../lib/helpers/create_es_client/create_apm_event_client';
|
||||
import { getOffsetInMs } from '../../../../common/utils/get_offset_in_ms';
|
||||
import { environmentQuery } from '../../../../common/utils/environment_query';
|
||||
import {
|
||||
SERVICE_NAME,
|
||||
HTTP_RESPONSE_STATUS_CODE,
|
||||
} from '../../../../common/es_fields/apm';
|
||||
import { offsetPreviousPeriodCoordinates } from '../../../../common/utils/offset_previous_period_coordinate';
|
||||
import { Coordinate } from '../../../../typings/timeseries';
|
||||
import { BUCKET_TARGET_COUNT } from '../../transactions/constants';
|
||||
|
||||
interface Props {
|
||||
apmEventClient: APMEventClient;
|
||||
serviceName: string;
|
||||
environment: string;
|
||||
start: number;
|
||||
end: number;
|
||||
kuery: string;
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
function getBucketSize({ start, end }: { start: number; end: number }) {
|
||||
return Math.floor((end - start) / BUCKET_TARGET_COUNT);
|
||||
}
|
||||
|
||||
export interface MobileHttpErrorsTimeseries {
|
||||
currentPeriod: { timeseries: Coordinate[] };
|
||||
previousPeriod: { timeseries: Coordinate[] };
|
||||
}
|
||||
async function getMobileHttpErrorsTimeseries({
|
||||
kuery,
|
||||
apmEventClient,
|
||||
serviceName,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
}: Props) {
|
||||
const bucketSize = getBucketSize({
|
||||
start,
|
||||
end,
|
||||
});
|
||||
const response = await apmEventClient.search('get_mobile_http_errors', {
|
||||
apm: { events: [ProcessorEvent.error] },
|
||||
body: {
|
||||
track_total_hits: false,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
...termQuery(SERVICE_NAME, serviceName),
|
||||
...environmentQuery(environment),
|
||||
...rangeQuery(start, end),
|
||||
...rangeQuery(400, 599, HTTP_RESPONSE_STATUS_CODE),
|
||||
...kqlQuery(kuery),
|
||||
],
|
||||
must_not: {
|
||||
term: { 'error.type': 'crash' },
|
||||
},
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
timeseries: {
|
||||
histogram: {
|
||||
field: '@timestamp',
|
||||
min_doc_count: 0,
|
||||
interval: bucketSize,
|
||||
extended_bounds: {
|
||||
min: start,
|
||||
max: end,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const timeseries = (response?.aggregations?.timeseries.buckets || []).map(
|
||||
(bucket) => ({
|
||||
x: bucket.key,
|
||||
y: bucket.doc_count,
|
||||
})
|
||||
);
|
||||
return { timeseries };
|
||||
}
|
||||
|
||||
export async function getMobileHttpErrors({
|
||||
kuery,
|
||||
apmEventClient,
|
||||
serviceName,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
}: Props): Promise<MobileHttpErrorsTimeseries> {
|
||||
const options = {
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
kuery,
|
||||
environment,
|
||||
};
|
||||
const { startWithOffset, endWithOffset } = getOffsetInMs({
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
});
|
||||
|
||||
const currentPeriodPromise = getMobileHttpErrorsTimeseries({
|
||||
...options,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
const previousPeriodPromise = offset
|
||||
? getMobileHttpErrorsTimeseries({
|
||||
...options,
|
||||
start: startWithOffset,
|
||||
end: endWithOffset,
|
||||
})
|
||||
: { timeseries: [] as Coordinate[] };
|
||||
const [currentPeriod, previousPeriod] = await Promise.all([
|
||||
currentPeriodPromise,
|
||||
previousPeriodPromise,
|
||||
]);
|
||||
return {
|
||||
currentPeriod,
|
||||
previousPeriod: {
|
||||
timeseries: offsetPreviousPeriodCoordinates({
|
||||
currentPeriodTimeseries: currentPeriod.timeseries,
|
||||
previousPeriodTimeseries: previousPeriod.timeseries,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
197
x-pack/plugins/apm/server/routes/mobile/errors/route.ts
Normal file
197
x-pack/plugins/apm/server/routes/mobile/errors/route.ts
Normal file
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
/*
|
||||
* 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 * as t from 'io-ts';
|
||||
import { jsonRt, toNumberRt } from '@kbn/io-ts-utils';
|
||||
import { getApmEventClient } from '../../../lib/helpers/get_apm_event_client';
|
||||
import { createApmServerRoute } from '../../apm_routes/create_apm_server_route';
|
||||
import { environmentRt, kueryRt, rangeRt } from '../../default_api_types';
|
||||
import { offsetRt } from '../../../../common/comparison_rt';
|
||||
import {
|
||||
getMobileErrorGroupPeriods,
|
||||
MobileErrorGroupPeriodsResponse,
|
||||
} from './get_mobile_error_group_detailed_statistics';
|
||||
import {
|
||||
MobileErrorGroupMainStatisticsResponse,
|
||||
getMobileErrorGroupMainStatistics,
|
||||
} from './get_mobile_error_group_main_statistics';
|
||||
import {
|
||||
getMobileErrorsTermsByField,
|
||||
MobileErrorTermsByFieldResponse,
|
||||
} from './get_mobile_errors_terms_by_field';
|
||||
import {
|
||||
MobileHttpErrorsTimeseries,
|
||||
getMobileHttpErrors,
|
||||
} from './get_mobile_http_errors';
|
||||
|
||||
const mobileMobileHttpRatesRoute = createApmServerRoute({
|
||||
endpoint:
|
||||
'GET /internal/apm/mobile-services/{serviceName}/error/http_error_rate',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
serviceName: t.string,
|
||||
}),
|
||||
query: t.intersection([environmentRt, kueryRt, rangeRt, offsetRt]),
|
||||
}),
|
||||
options: { tags: ['access:apm'] },
|
||||
handler: async (resources): Promise<MobileHttpErrorsTimeseries> => {
|
||||
const apmEventClient = await getApmEventClient(resources);
|
||||
const { params } = resources;
|
||||
const { serviceName } = params.path;
|
||||
const { kuery, environment, start, end, offset } = params.query;
|
||||
const response = await getMobileHttpErrors({
|
||||
kuery,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
offset,
|
||||
});
|
||||
|
||||
return { ...response };
|
||||
},
|
||||
});
|
||||
|
||||
const mobileErrorsDetailedStatisticsRoute = createApmServerRoute({
|
||||
endpoint:
|
||||
'POST /internal/apm/mobile-services/{serviceName}/errors/groups/detailed_statistics',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
serviceName: t.string,
|
||||
}),
|
||||
query: t.intersection([
|
||||
environmentRt,
|
||||
kueryRt,
|
||||
rangeRt,
|
||||
offsetRt,
|
||||
t.type({
|
||||
numBuckets: toNumberRt,
|
||||
}),
|
||||
]),
|
||||
body: t.type({ groupIds: jsonRt.pipe(t.array(t.string)) }),
|
||||
}),
|
||||
options: { tags: ['access:apm'] },
|
||||
handler: async (resources): Promise<MobileErrorGroupPeriodsResponse> => {
|
||||
const apmEventClient = await getApmEventClient(resources);
|
||||
const { params } = resources;
|
||||
|
||||
const {
|
||||
path: { serviceName },
|
||||
query: { environment, kuery, numBuckets, start, end, offset },
|
||||
body: { groupIds },
|
||||
} = params;
|
||||
|
||||
return getMobileErrorGroupPeriods({
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
numBuckets,
|
||||
groupIds,
|
||||
start,
|
||||
end,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mobileErrorTermsByFieldRoute = createApmServerRoute({
|
||||
endpoint: 'GET /internal/apm/mobile-services/{serviceName}/error_terms',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
serviceName: t.string,
|
||||
}),
|
||||
query: t.intersection([
|
||||
kueryRt,
|
||||
rangeRt,
|
||||
environmentRt,
|
||||
t.type({
|
||||
size: toNumberRt,
|
||||
fieldName: t.string,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
options: { tags: ['access:apm'] },
|
||||
handler: async (
|
||||
resources
|
||||
): Promise<{
|
||||
terms: MobileErrorTermsByFieldResponse;
|
||||
}> => {
|
||||
const apmEventClient = await getApmEventClient(resources);
|
||||
const { params } = resources;
|
||||
const { serviceName } = params.path;
|
||||
const { kuery, environment, start, end, size, fieldName } = params.query;
|
||||
const terms = await getMobileErrorsTermsByField({
|
||||
kuery,
|
||||
environment,
|
||||
start,
|
||||
end,
|
||||
serviceName,
|
||||
apmEventClient,
|
||||
fieldName,
|
||||
size,
|
||||
});
|
||||
|
||||
return { terms };
|
||||
},
|
||||
});
|
||||
|
||||
const mobileErrorsMainStatisticsRoute = createApmServerRoute({
|
||||
endpoint:
|
||||
'GET /internal/apm/mobile-services/{serviceName}/errors/groups/main_statistics',
|
||||
params: t.type({
|
||||
path: t.type({
|
||||
serviceName: t.string,
|
||||
}),
|
||||
query: t.intersection([
|
||||
t.partial({
|
||||
sortField: t.string,
|
||||
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
|
||||
}),
|
||||
environmentRt,
|
||||
kueryRt,
|
||||
rangeRt,
|
||||
]),
|
||||
}),
|
||||
options: { tags: ['access:apm'] },
|
||||
handler: async (
|
||||
resources
|
||||
): Promise<{ errorGroups: MobileErrorGroupMainStatisticsResponse }> => {
|
||||
const { params } = resources;
|
||||
const apmEventClient = await getApmEventClient(resources);
|
||||
const { serviceName } = params.path;
|
||||
const { environment, kuery, sortField, sortDirection, start, end } =
|
||||
params.query;
|
||||
|
||||
const errorGroups = await getMobileErrorGroupMainStatistics({
|
||||
environment,
|
||||
kuery,
|
||||
serviceName,
|
||||
sortField,
|
||||
sortDirection,
|
||||
apmEventClient,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
return { errorGroups };
|
||||
},
|
||||
});
|
||||
|
||||
export const mobileErrorRoutes = {
|
||||
...mobileMobileHttpRatesRoute,
|
||||
...mobileErrorsMainStatisticsRoute,
|
||||
...mobileErrorsDetailedStatisticsRoute,
|
||||
...mobileErrorTermsByFieldRoute,
|
||||
};
|
|
@ -39,6 +39,8 @@ import {
|
|||
getMobileMostUsedCharts,
|
||||
MobileMostUsedChartResponse,
|
||||
} from './get_mobile_most_used_charts';
|
||||
import { mobileErrorRoutes } from './errors/route';
|
||||
import { mobileCrashRoutes } from './crashes/route';
|
||||
|
||||
const mobileFiltersRoute = createApmServerRoute({
|
||||
endpoint: 'GET /internal/apm/services/{serviceName}/mobile/filters',
|
||||
|
@ -306,7 +308,6 @@ const mobileTermsByFieldRoute = createApmServerRoute({
|
|||
const { params } = resources;
|
||||
const { serviceName } = params.path;
|
||||
const { kuery, environment, start, end, size, fieldName } = params.query;
|
||||
|
||||
const terms = await getMobileTermsByField({
|
||||
kuery,
|
||||
environment,
|
||||
|
@ -401,6 +402,8 @@ const mobileDetailedStatisticsByField = createApmServerRoute({
|
|||
});
|
||||
|
||||
export const mobileRouteRepository = {
|
||||
...mobileErrorRoutes,
|
||||
...mobileCrashRoutes,
|
||||
...mobileFiltersRoute,
|
||||
...mobileChartsRoute,
|
||||
...sessionsChartRoute,
|
||||
|
|
|
@ -8239,7 +8239,6 @@
|
|||
"xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrences} occ.",
|
||||
"xpack.apm.serviceOverview.embeddedMap.error.toastDescription": "L'usine incorporable ayant l'ID \"{embeddableFactoryId}\" est introuvable.",
|
||||
"xpack.apm.serviceOverview.embeddedMap.subtitle": "Carte affichant le nombre total de {currentMap} en fonction du pays et de la région",
|
||||
"xpack.apm.serviceOverview.mobileCallOutText": "Il s'agit d'un service mobile, qui est actuellement disponible en tant que version d'évaluation technique. Vous pouvez nous aider à améliorer l'expérience en nous envoyant des commentaires. {feedbackLink}.",
|
||||
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, one {1 environnement} many {# environnements} other {# environnements}}",
|
||||
"xpack.apm.settings.agentKeys.apiKeysDisabledErrorDescription": "Contactez votre administrateur système et reportez-vous à {link} pour activer les clés d'API.",
|
||||
"xpack.apm.settings.agentKeys.copyAgentKeyField.title": "Clé \"{name}\" créée",
|
||||
|
@ -9004,7 +9003,6 @@
|
|||
"xpack.apm.mobile.filters.device": "Appareil",
|
||||
"xpack.apm.mobile.filters.nct": "NCT",
|
||||
"xpack.apm.mobile.filters.osVersion": "Version du système d'exploitation",
|
||||
"xpack.apm.mobile.location.metrics.crashes": "La plupart des pannes",
|
||||
"xpack.apm.mobile.location.metrics.http.requests.title": "Le plus utilisé dans",
|
||||
"xpack.apm.mobile.location.metrics.launches": "La plupart des lancements",
|
||||
"xpack.apm.mobile.location.metrics.sessions": "La plupart des sessions",
|
||||
|
@ -9389,8 +9387,6 @@
|
|||
"xpack.apm.serviceOverview.latencyColumnP95Label": "Latence (95e)",
|
||||
"xpack.apm.serviceOverview.latencyColumnP99Label": "Latence (99e)",
|
||||
"xpack.apm.serviceOverview.loadingText": "Chargement…",
|
||||
"xpack.apm.serviceOverview.mobileCallOutLink": "Donner un retour",
|
||||
"xpack.apm.serviceOverview.mobileCallOutTitle": "APM mobile",
|
||||
"xpack.apm.serviceOverview.mostUsedTitle": "Le plus utilisé",
|
||||
"xpack.apm.serviceOverview.noResultsText": "Aucune instance trouvée",
|
||||
"xpack.apm.serviceOverview.throughtputChartTitle": "Rendement",
|
||||
|
|
|
@ -8254,7 +8254,6 @@
|
|||
"xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrences}件。",
|
||||
"xpack.apm.serviceOverview.embeddedMap.error.toastDescription": "id {embeddableFactoryId}の埋め込み可能ファクトリが見つかりました。",
|
||||
"xpack.apm.serviceOverview.embeddedMap.subtitle": "国と地域別に基づく{currentMap}の総数を示した地図",
|
||||
"xpack.apm.serviceOverview.mobileCallOutText": "これはモバイルサービスであり、現在はテクニカルプレビューとしてリリースされています。フィードバックを送信して、エクスペリエンスの改善にご協力ください。{feedbackLink}",
|
||||
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, other {#個の環境}}",
|
||||
"xpack.apm.settings.agentKeys.apiKeysDisabledErrorDescription": "システム管理者に連絡し、{link}を伝えてAPIキーを有効にしてください。",
|
||||
"xpack.apm.settings.agentKeys.copyAgentKeyField.title": "\"{name}\"キーを作成しました",
|
||||
|
@ -9019,7 +9018,6 @@
|
|||
"xpack.apm.mobile.filters.device": "デバイス",
|
||||
"xpack.apm.mobile.filters.nct": "NCT",
|
||||
"xpack.apm.mobile.filters.osVersion": "OSバージョン",
|
||||
"xpack.apm.mobile.location.metrics.crashes": "最も多いクラッシュ",
|
||||
"xpack.apm.mobile.location.metrics.http.requests.title": "最も使用されている",
|
||||
"xpack.apm.mobile.location.metrics.launches": "最も多い起動",
|
||||
"xpack.apm.mobile.location.metrics.sessions": "最も多いセッション",
|
||||
|
@ -9403,8 +9401,6 @@
|
|||
"xpack.apm.serviceOverview.latencyColumnP95Label": "レイテンシ(95 番目)",
|
||||
"xpack.apm.serviceOverview.latencyColumnP99Label": "レイテンシ(99 番目)",
|
||||
"xpack.apm.serviceOverview.loadingText": "読み込み中…",
|
||||
"xpack.apm.serviceOverview.mobileCallOutLink": "フィードバックを作成する",
|
||||
"xpack.apm.serviceOverview.mobileCallOutTitle": "モバイルAPM",
|
||||
"xpack.apm.serviceOverview.mostUsedTitle": "最も使用されている",
|
||||
"xpack.apm.serviceOverview.noResultsText": "インスタンスが見つかりません",
|
||||
"xpack.apm.serviceOverview.throughtputChartTitle": "スループット",
|
||||
|
|
|
@ -8253,7 +8253,6 @@
|
|||
"xpack.apm.serviceOveriew.errorsTableOccurrences": "{occurrences} 次",
|
||||
"xpack.apm.serviceOverview.embeddedMap.error.toastDescription": "未找到 ID 为“{embeddableFactoryId}”的可嵌入工厂。",
|
||||
"xpack.apm.serviceOverview.embeddedMap.subtitle": "根据国家和区域显示 {currentMap} 总数的地图",
|
||||
"xpack.apm.serviceOverview.mobileCallOutText": "这是一项移动服务,它当前以技术预览的形式发布。您可以通过提供反馈来帮助我们改进体验。{feedbackLink}。",
|
||||
"xpack.apm.servicesTable.environmentCount": "{environmentCount, plural, other {# 个环境}}",
|
||||
"xpack.apm.settings.agentKeys.apiKeysDisabledErrorDescription": "请联系您的系统管理员并参阅{link}以启用 API 密钥。",
|
||||
"xpack.apm.settings.agentKeys.copyAgentKeyField.title": "已创建“{name}”密钥",
|
||||
|
@ -9018,7 +9017,6 @@
|
|||
"xpack.apm.mobile.filters.device": "设备",
|
||||
"xpack.apm.mobile.filters.nct": "NCT",
|
||||
"xpack.apm.mobile.filters.osVersion": "操作系统版本",
|
||||
"xpack.apm.mobile.location.metrics.crashes": "大多数崩溃",
|
||||
"xpack.apm.mobile.location.metrics.http.requests.title": "最常用于",
|
||||
"xpack.apm.mobile.location.metrics.launches": "大多数启动",
|
||||
"xpack.apm.mobile.location.metrics.sessions": "大多数会话",
|
||||
|
@ -9403,8 +9401,6 @@
|
|||
"xpack.apm.serviceOverview.latencyColumnP95Label": "延迟(第 95 个)",
|
||||
"xpack.apm.serviceOverview.latencyColumnP99Label": "延迟(第 99 个)",
|
||||
"xpack.apm.serviceOverview.loadingText": "正在加载……",
|
||||
"xpack.apm.serviceOverview.mobileCallOutLink": "反馈",
|
||||
"xpack.apm.serviceOverview.mobileCallOutTitle": "移动 APM",
|
||||
"xpack.apm.serviceOverview.mostUsedTitle": "最常用",
|
||||
"xpack.apm.serviceOverview.noResultsText": "未找到实例",
|
||||
"xpack.apm.serviceOverview.throughtputChartTitle": "吞吐量",
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* 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 { apm, timerange } from '@kbn/apm-synthtrace-client';
|
||||
import {
|
||||
APIClientRequestParamsOf,
|
||||
APIReturnType,
|
||||
} from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
|
||||
import { RecursivePartial } from '@kbn/apm-plugin/typings/common';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
|
||||
type ErrorGroups =
|
||||
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/crashes/groups/main_statistics'>['errorGroups'];
|
||||
|
||||
export default function ApiTest({ getService }: FtrProviderContext) {
|
||||
const registry = getService('registry');
|
||||
const apmApiClient = getService('apmApiClient');
|
||||
const synthtraceEsClient = getService('synthtraceEsClient');
|
||||
|
||||
const serviceName = 'synth-swift';
|
||||
const start = new Date('2021-01-01T00:00:00.000Z').getTime();
|
||||
const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1;
|
||||
|
||||
async function callApi(
|
||||
overrides?: RecursivePartial<
|
||||
APIClientRequestParamsOf<'GET /internal/apm/mobile-services/{serviceName}/crashes/groups/main_statistics'>['params']
|
||||
>
|
||||
) {
|
||||
return await apmApiClient.readUser({
|
||||
endpoint: 'GET /internal/apm/mobile-services/{serviceName}/crashes/groups/main_statistics',
|
||||
params: {
|
||||
path: { serviceName, ...overrides?.path },
|
||||
query: {
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
environment: 'ENVIRONMENT_ALL',
|
||||
kuery: '',
|
||||
...overrides?.query,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
registry.when('when data is not loaded', { config: 'basic', archives: [] }, () => {
|
||||
it('handles empty state', async () => {
|
||||
const response = await callApi();
|
||||
expect(response.status).to.be(200);
|
||||
expect(response.body.errorGroups).to.empty();
|
||||
});
|
||||
});
|
||||
|
||||
registry.when('when data is loaded', { config: 'basic', archives: [] }, () => {
|
||||
describe('errors group', () => {
|
||||
const appleTransaction = {
|
||||
name: 'GET /apple 🍎 ',
|
||||
successRate: 75,
|
||||
failureRate: 25,
|
||||
};
|
||||
|
||||
const bananaTransaction = {
|
||||
name: 'GET /banana 🍌',
|
||||
successRate: 50,
|
||||
failureRate: 50,
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
const serviceInstance = apm
|
||||
.service({ name: serviceName, environment: 'production', agentName: 'swift' })
|
||||
.instance('instance-a');
|
||||
|
||||
await synthtraceEsClient.index([
|
||||
timerange(start, end)
|
||||
.interval('1m')
|
||||
.rate(appleTransaction.successRate)
|
||||
.generator((timestamp) =>
|
||||
serviceInstance
|
||||
.transaction({ transactionName: appleTransaction.name })
|
||||
.timestamp(timestamp)
|
||||
.duration(1000)
|
||||
.success()
|
||||
),
|
||||
timerange(start, end)
|
||||
.interval('1m')
|
||||
.rate(appleTransaction.failureRate)
|
||||
.generator((timestamp) =>
|
||||
serviceInstance
|
||||
.transaction({ transactionName: appleTransaction.name })
|
||||
.errors(
|
||||
serviceInstance
|
||||
.crash({
|
||||
message: 'crash 1',
|
||||
})
|
||||
.timestamp(timestamp)
|
||||
)
|
||||
.duration(1000)
|
||||
.timestamp(timestamp)
|
||||
.failure()
|
||||
),
|
||||
timerange(start, end)
|
||||
.interval('1m')
|
||||
.rate(bananaTransaction.successRate)
|
||||
.generator((timestamp) =>
|
||||
serviceInstance
|
||||
.transaction({ transactionName: bananaTransaction.name })
|
||||
.timestamp(timestamp)
|
||||
.duration(1000)
|
||||
.success()
|
||||
),
|
||||
timerange(start, end)
|
||||
.interval('1m')
|
||||
.rate(bananaTransaction.failureRate)
|
||||
.generator((timestamp) =>
|
||||
serviceInstance
|
||||
.transaction({ transactionName: bananaTransaction.name })
|
||||
.errors(
|
||||
serviceInstance
|
||||
.crash({
|
||||
message: 'crash 2',
|
||||
})
|
||||
.timestamp(timestamp)
|
||||
)
|
||||
.duration(1000)
|
||||
.timestamp(timestamp)
|
||||
.failure()
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
after(() => synthtraceEsClient.clean());
|
||||
|
||||
describe('returns the correct data', () => {
|
||||
let errorGroups: ErrorGroups;
|
||||
before(async () => {
|
||||
const response = await callApi();
|
||||
errorGroups = response.body.errorGroups;
|
||||
});
|
||||
it('returns correct number of crashes', () => {
|
||||
expect(errorGroups.length).to.equal(2);
|
||||
expect(errorGroups.map((error) => error.name).sort()).to.eql(['crash 1', 'crash 2']);
|
||||
});
|
||||
|
||||
it('returns correct occurrences', () => {
|
||||
const numberOfBuckets = 15;
|
||||
expect(errorGroups.map((error) => error.occurrences).sort()).to.eql([
|
||||
appleTransaction.failureRate * numberOfBuckets,
|
||||
bananaTransaction.failureRate * numberOfBuckets,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* 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 { first, last, sumBy } from 'lodash';
|
||||
import { isFiniteNumber } from '@kbn/apm-plugin/common/utils/is_finite_number';
|
||||
import {
|
||||
APIClientRequestParamsOf,
|
||||
APIReturnType,
|
||||
} from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
|
||||
import { RecursivePartial } from '@kbn/apm-plugin/typings/common';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
import { config, generateData } from './generate_data';
|
||||
|
||||
type ErrorsDistribution =
|
||||
APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/crashes/distribution'>;
|
||||
|
||||
export default function ApiTest({ getService }: FtrProviderContext) {
|
||||
const registry = getService('registry');
|
||||
const apmApiClient = getService('apmApiClient');
|
||||
const synthtraceEsClient = getService('synthtraceEsClient');
|
||||
|
||||
const serviceName = 'synth-swift';
|
||||
const start = new Date('2021-01-01T00:00:00.000Z').getTime();
|
||||
const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1;
|
||||
|
||||
async function callApi(
|
||||
overrides?: RecursivePartial<
|
||||
APIClientRequestParamsOf<'GET /internal/apm/mobile-services/{serviceName}/crashes/distribution'>['params']
|
||||
>
|
||||
) {
|
||||
const response = await apmApiClient.readUser({
|
||||
endpoint: 'GET /internal/apm/mobile-services/{serviceName}/crashes/distribution',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
...overrides?.path,
|
||||
},
|
||||
query: {
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
environment: 'ENVIRONMENT_ALL',
|
||||
kuery: '',
|
||||
...overrides?.query,
|
||||
},
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
registry.when('when data is not loaded', { config: 'basic', archives: [] }, () => {
|
||||
it('handles the empty state', async () => {
|
||||
const response = await callApi();
|
||||
expect(response.status).to.be(200);
|
||||
expect(response.body.currentPeriod.length).to.be(0);
|
||||
expect(response.body.previousPeriod.length).to.be(0);
|
||||
});
|
||||
});
|
||||
|
||||
registry.when('when data is loaded', { config: 'basic', archives: [] }, () => {
|
||||
describe('errors distribution', () => {
|
||||
const { appleTransaction, bananaTransaction } = config;
|
||||
before(async () => {
|
||||
await generateData({ serviceName, start, end, synthtraceEsClient });
|
||||
});
|
||||
|
||||
after(() => synthtraceEsClient.clean());
|
||||
|
||||
describe('without comparison', () => {
|
||||
let errorsDistribution: ErrorsDistribution;
|
||||
before(async () => {
|
||||
const response = await callApi();
|
||||
errorsDistribution = response.body;
|
||||
});
|
||||
|
||||
it('displays combined number of occurrences', () => {
|
||||
const countSum = sumBy(errorsDistribution.currentPeriod, 'y');
|
||||
const numberOfBuckets = 15;
|
||||
expect(countSum).to.equal(
|
||||
(appleTransaction.failureRate + bananaTransaction.failureRate) * numberOfBuckets
|
||||
);
|
||||
});
|
||||
|
||||
describe('displays correct start in errors distribution chart', () => {
|
||||
let errorsDistributionWithComparison: ErrorsDistribution;
|
||||
before(async () => {
|
||||
const responseWithComparison = await callApi({
|
||||
query: {
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
offset: '15m',
|
||||
},
|
||||
});
|
||||
errorsDistributionWithComparison = responseWithComparison.body;
|
||||
});
|
||||
it('has same start time when comparison is enabled', () => {
|
||||
expect(first(errorsDistribution.currentPeriod)?.x).to.equal(
|
||||
first(errorsDistributionWithComparison.currentPeriod)?.x
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('displays occurrences for type "apple transaction" only', () => {
|
||||
let errorsDistribution: ErrorsDistribution;
|
||||
before(async () => {
|
||||
const response = await callApi({
|
||||
query: { kuery: `error.exception.type:"${appleTransaction.name}"` },
|
||||
});
|
||||
errorsDistribution = response.body;
|
||||
});
|
||||
it('displays combined number of occurrences', () => {
|
||||
const countSum = sumBy(errorsDistribution.currentPeriod, 'y');
|
||||
const numberOfBuckets = 15;
|
||||
expect(countSum).to.equal(appleTransaction.failureRate * numberOfBuckets);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with comparison', () => {
|
||||
describe('when data is returned', () => {
|
||||
let errorsDistribution: ErrorsDistribution;
|
||||
before(async () => {
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
const response = await callApi({
|
||||
query: {
|
||||
start: new Date(end - fiveMinutes).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
offset: '5m',
|
||||
},
|
||||
});
|
||||
errorsDistribution = response.body;
|
||||
});
|
||||
it('returns some data', () => {
|
||||
const hasCurrentPeriodData = errorsDistribution.currentPeriod.some(({ y }) =>
|
||||
isFiniteNumber(y)
|
||||
);
|
||||
|
||||
const hasPreviousPeriodData = errorsDistribution.previousPeriod.some(({ y }) =>
|
||||
isFiniteNumber(y)
|
||||
);
|
||||
|
||||
expect(hasCurrentPeriodData).to.equal(true);
|
||||
expect(hasPreviousPeriodData).to.equal(true);
|
||||
});
|
||||
|
||||
it('has same start time for both periods', () => {
|
||||
expect(first(errorsDistribution.currentPeriod)?.x).to.equal(
|
||||
first(errorsDistribution.previousPeriod)?.x
|
||||
);
|
||||
});
|
||||
|
||||
it('has same end time for both periods', () => {
|
||||
expect(last(errorsDistribution.currentPeriod)?.x).to.equal(
|
||||
last(errorsDistribution.previousPeriod)?.x
|
||||
);
|
||||
});
|
||||
|
||||
it('returns same number of buckets for both periods', () => {
|
||||
expect(errorsDistribution.currentPeriod.length).to.equal(
|
||||
errorsDistribution.previousPeriod.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no data is returned', () => {
|
||||
let errorsDistribution: ErrorsDistribution;
|
||||
before(async () => {
|
||||
const response = await callApi({
|
||||
query: {
|
||||
start: '2021-01-03T00:00:00.000Z',
|
||||
end: '2021-01-03T00:15:00.000Z',
|
||||
offset: '1d',
|
||||
},
|
||||
});
|
||||
errorsDistribution = response.body;
|
||||
});
|
||||
|
||||
it('has same start time for both periods', () => {
|
||||
expect(first(errorsDistribution.currentPeriod)?.x).to.equal(
|
||||
first(errorsDistribution.previousPeriod)?.x
|
||||
);
|
||||
});
|
||||
|
||||
it('has same end time for both periods', () => {
|
||||
expect(last(errorsDistribution.currentPeriod)?.x).to.equal(
|
||||
last(errorsDistribution.previousPeriod)?.x
|
||||
);
|
||||
});
|
||||
|
||||
it('returns same number of buckets for both periods', () => {
|
||||
expect(errorsDistribution.currentPeriod.length).to.equal(
|
||||
errorsDistribution.previousPeriod.length
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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 { apm, timerange } from '@kbn/apm-synthtrace-client';
|
||||
import type { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
|
||||
|
||||
export const config = {
|
||||
appleTransaction: {
|
||||
name: 'GET /apple 🍎 ',
|
||||
successRate: 75,
|
||||
failureRate: 25,
|
||||
},
|
||||
bananaTransaction: {
|
||||
name: 'GET /banana 🍌',
|
||||
successRate: 50,
|
||||
failureRate: 50,
|
||||
},
|
||||
};
|
||||
|
||||
export async function generateData({
|
||||
synthtraceEsClient,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
synthtraceEsClient: ApmSynthtraceEsClient;
|
||||
serviceName: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}) {
|
||||
const servicesSwiftProdInstance = apm
|
||||
.service({ name: serviceName, environment: 'production', agentName: 'swift' })
|
||||
.instance('instance-a');
|
||||
|
||||
const interval = '1m';
|
||||
|
||||
const { bananaTransaction, appleTransaction } = config;
|
||||
|
||||
const documents = [appleTransaction, bananaTransaction].flatMap((transaction, index) => {
|
||||
return [
|
||||
timerange(start, end)
|
||||
.interval(interval)
|
||||
.rate(transaction.successRate)
|
||||
.generator((timestamp) =>
|
||||
servicesSwiftProdInstance
|
||||
.transaction({ transactionName: transaction.name })
|
||||
.timestamp(timestamp)
|
||||
.duration(1000)
|
||||
.success()
|
||||
),
|
||||
timerange(start, end)
|
||||
.interval(interval)
|
||||
.rate(transaction.failureRate)
|
||||
.generator((timestamp) =>
|
||||
servicesSwiftProdInstance
|
||||
.transaction({ transactionName: transaction.name })
|
||||
.errors(
|
||||
servicesSwiftProdInstance
|
||||
.crash({
|
||||
message: `Error ${index}`,
|
||||
type: transaction.name,
|
||||
})
|
||||
.timestamp(timestamp)
|
||||
)
|
||||
.duration(1000)
|
||||
.timestamp(timestamp)
|
||||
.failure()
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
await synthtraceEsClient.index(documents);
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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 { apm, timerange } from '@kbn/apm-synthtrace-client';
|
||||
import type { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
|
||||
|
||||
export const config = {
|
||||
appleTransaction: {
|
||||
name: 'GET /apple 🍎 ',
|
||||
successRate: 75,
|
||||
failureRate: 25,
|
||||
},
|
||||
bananaTransaction: {
|
||||
name: 'GET /banana 🍌',
|
||||
successRate: 50,
|
||||
failureRate: 50,
|
||||
},
|
||||
};
|
||||
|
||||
export async function generateData({
|
||||
synthtraceEsClient,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
synthtraceEsClient: ApmSynthtraceEsClient;
|
||||
serviceName: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}) {
|
||||
const serviceGoProdInstance = apm
|
||||
.service({ name: serviceName, environment: 'production', agentName: 'swift' })
|
||||
.instance('instance-a');
|
||||
|
||||
const interval = '1m';
|
||||
|
||||
const { bananaTransaction, appleTransaction } = config;
|
||||
|
||||
const documents = [appleTransaction, bananaTransaction].flatMap((transaction, index) => {
|
||||
return [
|
||||
timerange(start, end)
|
||||
.interval(interval)
|
||||
.rate(transaction.successRate)
|
||||
.generator((timestamp) =>
|
||||
serviceGoProdInstance
|
||||
.transaction({ transactionName: transaction.name })
|
||||
.timestamp(timestamp)
|
||||
.duration(1000)
|
||||
.success()
|
||||
),
|
||||
timerange(start, end)
|
||||
.interval(interval)
|
||||
.rate(transaction.failureRate)
|
||||
.generator((timestamp) =>
|
||||
serviceGoProdInstance
|
||||
.transaction({ transactionName: transaction.name })
|
||||
.errors(
|
||||
serviceGoProdInstance
|
||||
.error({ message: `Error ${index}`, type: transaction.name })
|
||||
.timestamp(timestamp)
|
||||
)
|
||||
.duration(1000)
|
||||
.timestamp(timestamp)
|
||||
.failure()
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
await synthtraceEsClient.index(documents);
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
* 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 { timerange } from '@kbn/apm-synthtrace-client';
|
||||
import { service } from '@kbn/apm-synthtrace-client/src/lib/apm/service';
|
||||
import { orderBy } from 'lodash';
|
||||
import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
|
||||
import { getErrorGroupingKey } from '@kbn/apm-synthtrace-client/src/lib/apm/instance';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
import { config, generateData } from './generate_data';
|
||||
|
||||
type ErrorGroupSamples =
|
||||
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/{groupId}/samples'>;
|
||||
|
||||
type ErrorSampleDetails =
|
||||
APIReturnType<'GET /internal/apm/services/{serviceName}/errors/{groupId}/error/{errorId}'>;
|
||||
|
||||
export default function ApiTest({ getService }: FtrProviderContext) {
|
||||
const registry = getService('registry');
|
||||
const apmApiClient = getService('apmApiClient');
|
||||
const synthtraceEsClient = getService('synthtraceEsClient');
|
||||
|
||||
const serviceName = 'synth-go';
|
||||
const start = new Date('2021-01-01T00:00:00.000Z').getTime();
|
||||
const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1;
|
||||
|
||||
async function callErrorGroupSamplesApi({ groupId }: { groupId: string }) {
|
||||
const response = await apmApiClient.readUser({
|
||||
endpoint: 'GET /internal/apm/services/{serviceName}/errors/{groupId}/samples',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
groupId,
|
||||
},
|
||||
query: {
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
environment: 'ENVIRONMENT_ALL',
|
||||
kuery: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async function callErrorSampleDetailsApi(errorId: string) {
|
||||
const response = await apmApiClient.readUser({
|
||||
endpoint: 'GET /internal/apm/services/{serviceName}/errors/{groupId}/error/{errorId}',
|
||||
params: {
|
||||
path: {
|
||||
serviceName,
|
||||
groupId: 'foo',
|
||||
errorId,
|
||||
},
|
||||
query: {
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(end).toISOString(),
|
||||
environment: 'ENVIRONMENT_ALL',
|
||||
kuery: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
registry.when('when data is not loaded', { config: 'basic', archives: [] }, () => {
|
||||
it('handles the empty state', async () => {
|
||||
const response = await callErrorGroupSamplesApi({ groupId: 'foo' });
|
||||
expect(response.status).to.be(200);
|
||||
expect(response.body.occurrencesCount).to.be(0);
|
||||
});
|
||||
});
|
||||
|
||||
registry.when('when samples data is loaded', { config: 'basic', archives: [] }, () => {
|
||||
const { bananaTransaction } = config;
|
||||
describe('error group id', () => {
|
||||
before(async () => {
|
||||
await generateData({ serviceName, start, end, synthtraceEsClient });
|
||||
});
|
||||
|
||||
after(() => synthtraceEsClient.clean());
|
||||
|
||||
describe('return correct data', () => {
|
||||
let errorsSamplesResponse: ErrorGroupSamples;
|
||||
before(async () => {
|
||||
const response = await callErrorGroupSamplesApi({
|
||||
groupId: '98b75903135eac35ad42419bd3b45cf8b4270c61cbd0ede0f7e8c8a9ac9fdb03',
|
||||
});
|
||||
errorsSamplesResponse = response.body;
|
||||
});
|
||||
|
||||
it('displays correct number of occurrences', () => {
|
||||
const numberOfBuckets = 15;
|
||||
expect(errorsSamplesResponse.occurrencesCount).to.equal(
|
||||
bananaTransaction.failureRate * numberOfBuckets
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
registry.when('when error sample data is loaded', { config: 'basic', archives: [] }, () => {
|
||||
describe('error sample id', () => {
|
||||
before(async () => {
|
||||
await generateData({ serviceName, start, end, synthtraceEsClient });
|
||||
});
|
||||
|
||||
after(() => synthtraceEsClient.clean());
|
||||
|
||||
describe('return correct data', () => {
|
||||
let errorSampleDetailsResponse: ErrorSampleDetails;
|
||||
before(async () => {
|
||||
const errorsSamplesResponse = await callErrorGroupSamplesApi({
|
||||
groupId: '98b75903135eac35ad42419bd3b45cf8b4270c61cbd0ede0f7e8c8a9ac9fdb03',
|
||||
});
|
||||
|
||||
const errorId = errorsSamplesResponse.body.errorSampleIds[0];
|
||||
|
||||
const response = await callErrorSampleDetailsApi(errorId);
|
||||
errorSampleDetailsResponse = response.body;
|
||||
});
|
||||
|
||||
it('displays correct error grouping_key', () => {
|
||||
expect(errorSampleDetailsResponse.error.error.grouping_key).to.equal(
|
||||
'98b75903135eac35ad42419bd3b45cf8b4270c61cbd0ede0f7e8c8a9ac9fdb03'
|
||||
);
|
||||
});
|
||||
|
||||
it('displays correct error message', () => {
|
||||
expect(errorSampleDetailsResponse.error.error.exception?.[0].message).to.equal('Error 1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with sampled and unsampled transactions', () => {
|
||||
let errorGroupSamplesResponse: ErrorGroupSamples;
|
||||
|
||||
before(async () => {
|
||||
const instance = service(serviceName, 'production', 'go').instance('a');
|
||||
const errorMessage = 'Error 1';
|
||||
const groupId = getErrorGroupingKey(errorMessage);
|
||||
|
||||
await synthtraceEsClient.index([
|
||||
timerange(start, end)
|
||||
.interval('15m')
|
||||
.rate(1)
|
||||
.generator((timestamp) => {
|
||||
return [
|
||||
instance
|
||||
.transaction('GET /api/foo')
|
||||
.duration(100)
|
||||
.timestamp(timestamp)
|
||||
.sample(false)
|
||||
.errors(
|
||||
instance.error({ message: errorMessage }).timestamp(timestamp),
|
||||
instance.error({ message: errorMessage }).timestamp(timestamp + 1)
|
||||
),
|
||||
instance
|
||||
.transaction('GET /api/foo')
|
||||
.duration(100)
|
||||
.timestamp(timestamp)
|
||||
.sample(true)
|
||||
.errors(instance.error({ message: errorMessage }).timestamp(timestamp)),
|
||||
];
|
||||
}),
|
||||
]);
|
||||
|
||||
errorGroupSamplesResponse = (await callErrorGroupSamplesApi({ groupId })).body;
|
||||
});
|
||||
|
||||
after(() => synthtraceEsClient.clean());
|
||||
|
||||
it('returns the errors in the correct order (sampled first, then unsampled)', () => {
|
||||
const idsOfErrors = errorGroupSamplesResponse.errorSampleIds.map((id) => parseInt(id, 10));
|
||||
|
||||
// this checks whether the order of indexing is different from the order that is returned
|
||||
// if it is not, scoring/sorting is broken
|
||||
expect(errorGroupSamplesResponse.errorSampleIds.length).to.be(3);
|
||||
expect(idsOfErrors).to.not.eql(orderBy(idsOfErrors));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue