[UX] Add percentile selector (#78562)

This commit is contained in:
Shahzad 2020-09-30 10:24:38 +02:00 committed by GitHub
parent 80c004cb01
commit a31dd64778
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 504 additions and 242 deletions

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import {
AGENT_NAME,
CLIENT_GEO_COUNTRY_ISO_CODE,
CONTAINER_ID,
HOST_NAME,
POD_NAME,
SERVICE_NAME,
SERVICE_VERSION,
TRANSACTION_RESULT,
TRANSACTION_URL,
USER_AGENT_DEVICE,
USER_AGENT_NAME,
USER_AGENT_OS,
} from './elasticsearch_fieldnames';
export const filtersByName = {
host: {
title: i18n.translate('xpack.apm.localFilters.titles.host', {
defaultMessage: 'Host',
}),
fieldName: HOST_NAME,
},
agentName: {
title: i18n.translate('xpack.apm.localFilters.titles.agentName', {
defaultMessage: 'Agent name',
}),
fieldName: AGENT_NAME,
},
containerId: {
title: i18n.translate('xpack.apm.localFilters.titles.containerId', {
defaultMessage: 'Container ID',
}),
fieldName: CONTAINER_ID,
},
podName: {
title: i18n.translate('xpack.apm.localFilters.titles.podName', {
defaultMessage: 'Kubernetes pod',
}),
fieldName: POD_NAME,
},
transactionResult: {
title: i18n.translate('xpack.apm.localFilters.titles.transactionResult', {
defaultMessage: 'Transaction result',
}),
fieldName: TRANSACTION_RESULT,
},
serviceVersion: {
title: i18n.translate('xpack.apm.localFilters.titles.serviceVersion', {
defaultMessage: 'Service version',
}),
fieldName: SERVICE_VERSION,
},
transactionUrl: {
title: i18n.translate('xpack.apm.localFilters.titles.transactionUrl', {
defaultMessage: 'Url',
}),
fieldName: TRANSACTION_URL,
},
browser: {
title: i18n.translate('xpack.apm.localFilters.titles.browser', {
defaultMessage: 'Browser',
}),
fieldName: USER_AGENT_NAME,
},
device: {
title: i18n.translate('xpack.apm.localFilters.titles.device', {
defaultMessage: 'Device',
}),
fieldName: USER_AGENT_DEVICE,
},
location: {
title: i18n.translate('xpack.apm.localFilters.titles.location', {
defaultMessage: 'Location',
}),
fieldName: CLIENT_GEO_COUNTRY_ISO_CODE,
},
os: {
title: i18n.translate('xpack.apm.localFilters.titles.os', {
defaultMessage: 'OS',
}),
fieldName: USER_AGENT_OS,
},
serviceName: {
title: i18n.translate('xpack.apm.localFilters.titles.serviceName', {
defaultMessage: 'Service name',
}),
fieldName: SERVICE_NAME,
},
};
export type LocalUIFilterName = keyof typeof filtersByName;

View file

@ -9,6 +9,10 @@ Feature: CSM Dashboard
When a user browses the APM UI application for RUM Data
Then should have correct client metrics
Scenario: Percentile select
When the user changes the selected percentile
Then it displays client metric related to that percentile
Scenario Outline: CSM page filters
When the user filters by "<filterName>"
Then it filters the client metrics "<filterName>"

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { When, Then } from 'cypress-cucumber-preprocessor/steps';
import { verifyClientMetrics } from './client_metrics_helper';
import { getDataTestSubj } from './utils';
When('the user changes the selected percentile', () => {
// wait for all loading to finish
cy.get('kbnLoadingIndicator').should('not.be.visible');
getDataTestSubj('uxPercentileSelect').click();
getDataTestSubj('p95Percentile').click();
});
Then(`it displays client metric related to that percentile`, () => {
const metrics = ['14 ms', '0.13 s', '55 '];
verifyClientMetrics(metrics, false);
// reset to median
getDataTestSubj('uxPercentileSelect').click();
getDataTestSubj('p50Percentile').click();
});

View file

@ -24,7 +24,7 @@ Then(`it displays top pages in the suggestion popover`, () => {
listOfUrls.should('have.length', 5);
const actualUrlsText = [
'http://opbeans-node:3000/dashboardPage views: 17Page load duration: 109 ms ',
'http://opbeans-node:3000/dashboardPage views: 17Page load duration: 109 ms',
'http://opbeans-node:3000/ordersPage views: 14Page load duration: 72 ms',
];

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DEFAULT_TIMEOUT } from './csm_dashboard';
export function getDataTestSubj(dataTestSubj: string) {
// wait for all loading to finish
cy.get('kbnLoadingIndicator').should('not.be.visible');
return cy.get(`[data-test-subj=${dataTestSubj}]`, DEFAULT_TIMEOUT);
}

View file

@ -9,8 +9,8 @@ import styled from 'styled-components';
import { useContext, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiToolTip } from '@elastic/eui';
import { useFetcher } from '../../../../hooks/useFetcher';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { I18LABELS } from '../translations';
import { useUxQuery } from '../hooks/useUxQuery';
import { CsmSharedContext } from '../CsmSharedContext';
const ClFlexGroup = styled(EuiFlexGroup)`
@ -22,29 +22,23 @@ const ClFlexGroup = styled(EuiFlexGroup)`
`;
export function ClientMetrics() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end, searchTerm } = urlParams;
const uxQuery = useUxQuery();
const { data, status } = useFetcher(
(callApmApi) => {
const { serviceName } = uiFilters;
if (start && end && serviceName) {
if (uxQuery) {
return callApmApi({
pathname: '/api/apm/rum/client-metrics',
params: {
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
...uxQuery,
},
},
});
}
return Promise.resolve(null);
},
[start, end, uiFilters, searchTerm]
[uxQuery]
);
const { setSharedData } = useContext(CsmSharedContext);

View file

@ -33,7 +33,9 @@ export function PageLoadDistribution() {
const { data, status } = useFetcher(
(callApmApi) => {
if (start && end) {
const { serviceName } = uiFilters;
if (start && end && serviceName) {
return callApmApi({
pathname: '/api/apm/rum-client/page-load-distribution',
params: {

View file

@ -22,7 +22,9 @@ export function PageViewsTrend() {
const { data, status } = useFetcher(
(callApmApi) => {
if (start && end) {
const { serviceName } = uiFilters;
if (start && end && serviceName) {
return callApmApi({
pathname: '/api/apm/rum-client/page-view-trends',
params: {

View file

@ -9,6 +9,7 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { RumOverview } from '../RumDashboard';
import { RumHeader } from './RumHeader';
import { UserPercentile } from './UserPercentile';
import { CsmSharedContextProvider } from './CsmSharedContext';
export const UX_LABEL = i18n.translate('xpack.apm.ux.title', {
@ -21,11 +22,14 @@ export function RumHome() {
<CsmSharedContextProvider>
<RumHeader>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexItem grow={true}>
<EuiTitle size="l">
<h1>{UX_LABEL}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UserPercentile />
</EuiFlexItem>
</EuiFlexGroup>
</RumHeader>
<RumOverview />

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FormEvent, useRef, useState } from 'react';
import React, { FormEvent, SetStateAction, useRef, useState } from 'react';
import {
EuiButtonEmpty,
EuiFlexGroup,
@ -33,6 +33,8 @@ interface Props {
onChange: (updatedOptions: UrlOption[]) => void;
searchValue: string;
onClose: () => void;
popoverIsOpen: boolean;
setPopoverIsOpen: React.Dispatch<SetStateAction<boolean>>;
}
export function SelectableUrlList({
@ -43,8 +45,9 @@ export function SelectableUrlList({
onChange,
searchValue,
onClose,
popoverIsOpen,
setPopoverIsOpen,
}: Props) {
const [popoverIsOpen, setPopoverIsOpen] = useState(false);
const [popoverRef, setPopoverRef] = useState<HTMLElement | null>(null);
const [searchRef, setSearchRef] = useState<HTMLInputElement | null>(null);

View file

@ -15,6 +15,7 @@ import { fromQuery, toQuery } from '../../../../shared/Links/url_helpers';
import { formatToSec } from '../../UXMetrics/KeyUXMetrics';
import { SelectableUrlList } from './SelectableUrlList';
import { UrlOption } from './RenderOption';
import { useUxQuery } from '../../hooks/useUxQuery';
interface Props {
onChange: (value: string[]) => void;
@ -23,9 +24,10 @@ interface Props {
export function URLSearch({ onChange: onFilterChange }: Props) {
const history = useHistory();
const { urlParams, uiFilters } = useUrlParams();
const { uiFilters } = useUrlParams();
const [popoverIsOpen, setPopoverIsOpen] = useState(false);
const { start, end, serviceName } = urlParams;
const [searchValue, setSearchValue] = useState('');
const [debouncedValue, setDebouncedValue] = useState('');
@ -54,17 +56,18 @@ export function URLSearch({ onChange: onFilterChange }: Props) {
const [checkedUrls, setCheckedUrls] = useState<string[]>([]);
const uxQuery = useUxQuery();
const { data, status } = useFetcher(
(callApmApi) => {
if (start && end && serviceName) {
if (uxQuery && popoverIsOpen) {
const { transactionUrl, ...restFilters } = uiFilters;
return callApmApi({
pathname: '/api/apm/rum-client/url-search',
params: {
query: {
start,
end,
...uxQuery,
uiFilters: JSON.stringify(restFilters),
urlQuery: searchValue,
},
@ -73,7 +76,8 @@ export function URLSearch({ onChange: onFilterChange }: Props) {
}
return Promise.resolve(null);
},
[start, end, serviceName, uiFilters, searchValue]
// eslint-disable-next-line react-hooks/exhaustive-deps
[uxQuery, searchValue, popoverIsOpen]
);
useEffect(() => {
@ -110,7 +114,9 @@ export function URLSearch({ onChange: onFilterChange }: Props) {
};
const onClose = () => {
onFilterChange(checkedUrls);
if (uiFilters.transactionUrl || checkedUrls.length > 0) {
onFilterChange(checkedUrls);
}
};
return (
@ -126,6 +132,8 @@ export function URLSearch({ onChange: onFilterChange }: Props) {
onChange={onChange}
onClose={onClose}
searchValue={searchValue}
popoverIsOpen={popoverIsOpen}
setPopoverIsOpen={setPopoverIsOpen}
/>
</>
);

View file

@ -5,16 +5,16 @@
*/
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { EuiSpacer, EuiBadge } from '@elastic/eui';
import { useHistory } from 'react-router-dom';
import { Projection } from '../../../../../common/projections';
import { useLocalUIFilters } from '../../../../hooks/useLocalUIFilters';
import { omit } from 'lodash';
import { URLSearch } from './URLSearch';
import { LocalUIFilters } from '../../../shared/LocalUIFilters';
import { UrlList } from './UrlList';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { fromQuery, toQuery } from '../../../shared/Links/url_helpers';
import { removeUndefinedProps } from '../../../../context/UrlParamsContext/helpers';
import { LocalUIFilterName } from '../../../../../common/ui_filter';
const removeSearchTermLabel = i18n.translate(
'xpack.apm.uiFilter.url.removeSearchTerm',
@ -28,18 +28,19 @@ export function URLFilter() {
urlParams: { searchTerm },
} = useUrlParams();
const localUIFiltersConfig = useMemo(() => {
const config: React.ComponentProps<typeof LocalUIFilters> = {
filterNames: ['transactionUrl'],
projection: Projection.rumOverview,
};
const setFilterValue = (name: LocalUIFilterName, value: string[]) => {
const search = omit(toQuery(history.location.search), name);
return config;
}, []);
const { filters, setFilterValue } = useLocalUIFilters({
...localUIFiltersConfig,
});
history.push({
...history.location,
search: fromQuery(
removeUndefinedProps({
...search,
[name]: value.length ? value.join(',') : undefined,
})
),
});
};
const updateSearchTerm = useCallback(
(searchTermN?: string) => {
@ -55,7 +56,12 @@ export function URLFilter() {
[history]
);
const { name, value: filterValue } = filters[0];
const name = 'transactionUrl';
const { uiFilters } = useUrlParams();
const { transactionUrl } = uiFilters;
const filterValue = transactionUrl ?? [];
return (
<span data-cy="csmUrlFilter">

View file

@ -14,8 +14,8 @@ import {
SUM_LONG_TASKS,
TBT_LABEL,
} from '../CoreVitals/translations';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { useFetcher } from '../../../../hooks/useFetcher';
import { useUxQuery } from '../hooks/useUxQuery';
export function formatToSec(
value?: number | string,
@ -36,28 +36,23 @@ interface Props {
}
export function KeyUXMetrics({ data, loading }: Props) {
const { urlParams, uiFilters } = useUrlParams();
const { start, end, serviceName, searchTerm } = urlParams;
const uxQuery = useUxQuery();
const { data: longTaskData, status } = useFetcher(
(callApmApi) => {
if (start && end && serviceName) {
if (uxQuery) {
return callApmApi({
pathname: '/api/apm/rum-client/long-task-metrics',
params: {
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
...uxQuery,
},
},
});
}
return Promise.resolve(null);
},
[start, end, serviceName, uiFilters, searchTerm]
[uxQuery]
);
// Note: FCP value is in ms unit

View file

@ -21,8 +21,8 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { I18LABELS } from '../translations';
import { CoreVitals } from '../CoreVitals';
import { KeyUXMetrics } from './KeyUXMetrics';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { useFetcher } from '../../../../hooks/useFetcher';
import { useUxQuery } from '../hooks/useUxQuery';
export interface UXMetrics {
cls: string;
@ -36,29 +36,21 @@ export interface UXMetrics {
}
export function UXMetrics() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end, searchTerm } = urlParams;
const uxQuery = useUxQuery();
const { data, status } = useFetcher(
(callApmApi) => {
const { serviceName } = uiFilters;
if (start && end && serviceName) {
if (uxQuery) {
return callApmApi({
pathname: '/api/apm/rum-client/web-core-vitals',
params: {
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
},
query: uxQuery,
},
});
}
return Promise.resolve(null);
},
[start, end, uiFilters, searchTerm]
[uxQuery]
);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useEffect } from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { fromQuery, toQuery } from '../../../shared/Links/url_helpers';
import { I18LABELS } from '../translations';
const DEFAULT_P = 50;
const StyledSpan = styled.span`
font-weight: 600;
`;
export function UserPercentile() {
const history = useHistory();
const {
urlParams: { percentile },
} = useUrlParams();
const updatePercentile = useCallback(
(percentileN?: number) => {
const newLocation = {
...history.location,
search: fromQuery({
...toQuery(history.location.search),
percentile: percentileN,
}),
};
history.push(newLocation);
},
[history]
);
useEffect(() => {
if (!percentile) {
updatePercentile(DEFAULT_P);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const options = [
{
value: '50',
inputDisplay: I18LABELS.percentile50thMedian,
dropdownDisplay: I18LABELS.percentile50thMedian,
'data-test-subj': 'p50Percentile',
},
{
value: '75',
inputDisplay: <StyledSpan>{I18LABELS.percentile75th}</StyledSpan>,
dropdownDisplay: I18LABELS.percentile75th,
'data-test-subj': 'p75Percentile',
},
{
value: '90',
inputDisplay: <StyledSpan>{I18LABELS.percentile90th}</StyledSpan>,
dropdownDisplay: I18LABELS.percentile90th,
'data-test-subj': 'p90Percentile',
},
{
value: '95',
inputDisplay: <StyledSpan>{I18LABELS.percentile95th}</StyledSpan>,
dropdownDisplay: I18LABELS.percentile95th,
'data-test-subj': 'p95Percentile',
},
{
value: '99',
inputDisplay: <StyledSpan>{I18LABELS.percentile99th}</StyledSpan>,
dropdownDisplay: I18LABELS.percentile99th,
'data-test-subj': 'p99Percentile',
},
];
const onChange = (val: string) => {
updatePercentile(Number(val));
};
return (
<EuiSuperSelect
prepend={I18LABELS.percentile}
data-test-subj="uxPercentileSelect"
style={{ width: 150 }}
options={options}
valueOfSelected={String(percentile ?? DEFAULT_P)}
onChange={(value) => onChange(value)}
/>
);
}

View file

@ -18,7 +18,9 @@ export function VisitorBreakdown() {
const { data, status } = useFetcher(
(callApmApi) => {
if (start && end) {
const { serviceName } = uiFilters;
if (start && end && serviceName) {
return callApmApi({
pathname: '/api/apm/rum-client/visitor-breakdown',
params: {

View file

@ -162,10 +162,10 @@ export function EmbeddedMapComponent() {
// We can only render after embeddable has already initialized
useEffect(() => {
if (embeddableRoot.current && embeddable) {
if (embeddableRoot.current && embeddable && serviceName) {
embeddable.render(embeddableRoot.current);
}
}, [embeddable, embeddableRoot]);
}, [embeddable, embeddableRoot, serviceName]);
return (
<EmbeddedPanel>

View file

@ -4,12 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { FieldFilter as Filter } from '../../../../../../../../src/plugins/data/common';
import {
CLIENT_GEO_COUNTRY_ISO_CODE,
SERVICE_NAME,
TRANSACTION_URL,
USER_AGENT_DEVICE,
USER_AGENT_NAME,
USER_AGENT_OS,
@ -17,6 +18,21 @@ import {
import { APM_STATIC_INDEX_PATTERN_ID } from '../../../../../../../../src/plugins/apm_oss/public';
const getWildcardFilter = (field: string, value: string): Filter => {
return {
meta: {
index: APM_STATIC_INDEX_PATTERN_ID,
alias: null,
negate: false,
disabled: false,
type: 'term',
key: field,
params: { query: value },
},
query: { wildcard: { [field]: { value: `*${value}*` } } },
};
};
const getMatchFilter = (field: string, value: string): Filter => {
return {
meta: {
@ -28,7 +44,7 @@ const getMatchFilter = (field: string, value: string): Filter => {
key: field,
params: { query: value },
},
query: { match_phrase: { [field]: value } },
query: { term: { [field]: value } },
};
};
@ -52,14 +68,13 @@ const getMultiMatchFilter = (field: string, values: string[]): Filter => {
},
};
};
export const useMapFilters = (): Filter[] => {
const { urlParams, uiFilters } = useUrlParams();
const { serviceName } = urlParams;
const { serviceName, searchTerm } = urlParams;
const { browser, device, os, location } = uiFilters;
const [mapFilters, setMapFilters] = useState<Filter[]>([]);
const { browser, device, os, location, transactionUrl } = uiFilters;
const existFilter: Filter = {
meta: {
@ -76,7 +91,7 @@ export const useMapFilters = (): Filter[] => {
},
};
useEffect(() => {
return useMemo(() => {
const filters = [existFilter];
if (serviceName) {
filters.push(getMatchFilter(SERVICE_NAME, serviceName));
@ -93,10 +108,15 @@ export const useMapFilters = (): Filter[] => {
if (location) {
filters.push(getMultiMatchFilter(CLIENT_GEO_COUNTRY_ISO_CODE, location));
}
if (transactionUrl) {
filters.push(getMultiMatchFilter(TRANSACTION_URL, transactionUrl));
}
if (searchTerm) {
filters.push(getWildcardFilter(TRANSACTION_URL, searchTerm));
}
return filters;
setMapFilters(filters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serviceName, browser, device, os, location]);
return mapFilters;
}, [serviceName, browser, device, os, location, searchTerm]);
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useMemo } from 'react';
import { useUrlParams } from '../../../../hooks/useUrlParams';
export function useUxQuery() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end, searchTerm, percentile } = urlParams;
const queryParams = useMemo(() => {
const { serviceName } = uiFilters;
if (start && end && serviceName) {
return {
start,
end,
percentile: String(percentile),
urlQuery: searchTerm || undefined,
uiFilters: JSON.stringify(uiFilters),
};
}
return null;
}, [start, end, searchTerm, percentile, uiFilters]);
return queryParams;
}

View file

@ -23,8 +23,8 @@ import { LocalUIFilters } from '../../shared/LocalUIFilters';
import { ServiceNameFilter } from './URLFilter/ServiceNameFilter';
export function RumOverview() {
useTrackPageview({ app: 'apm', path: 'rum_overview' });
useTrackPageview({ app: 'apm', path: 'rum_overview', delay: 15000 });
useTrackPageview({ app: 'ux', path: 'home' });
useTrackPageview({ app: 'ux', path: 'home', delay: 15000 });
const localUIFiltersConfig = useMemo(() => {
const config: React.ComponentProps<typeof LocalUIFilters> = {

View file

@ -132,6 +132,24 @@ export const I18LABELS = {
defaultMessage: 'Impacted page loads',
}
),
percentile: i18n.translate('xpack.apm.ux.percentile.label', {
defaultMessage: 'Percentile',
}),
percentile50thMedian: i18n.translate('xpack.apm.ux.percentile.50thMedian', {
defaultMessage: '50th (Median)',
}),
percentile75th: i18n.translate('xpack.apm.ux.percentile.75th', {
defaultMessage: '75th',
}),
percentile90th: i18n.translate('xpack.apm.ux.percentile.90th', {
defaultMessage: '90th',
}),
percentile95th: i18n.translate('xpack.apm.ux.percentile.95th', {
defaultMessage: '95th',
}),
percentile99th: i18n.translate('xpack.apm.ux.percentile.99th', {
defaultMessage: '99th',
}),
};
export const VisitorBreakdownLabel = i18n.translate(

View file

@ -5,9 +5,8 @@
*/
import { parse, stringify } from 'query-string';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { LocalUIFilterName } from '../../../../server/lib/ui_filters/local_ui_filters/config';
import { url } from '../../../../../../../src/plugins/kibana_utils/public';
import { LocalUIFilterName } from '../../../../common/ui_filter';
export function toQuery(search?: string): APMQueryParamsRaw {
return search ? parse(search.slice(1), { sort: false }) : {};
@ -41,6 +40,7 @@ export type APMQueryParams = {
refreshPaused?: string | boolean;
refreshInterval?: string | number;
searchTerm?: string;
percentile?: 50 | 75 | 90 | 95 | 99;
} & { [key in LocalUIFilterName]?: string };
// forces every value of T[K] to be type: string

View file

@ -13,11 +13,10 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { LocalUIFilterName } from '../../../../server/lib/ui_filters/local_ui_filters/config';
import { Filter } from './Filter';
import { useLocalUIFilters } from '../../../hooks/useLocalUIFilters';
import { Projection } from '../../../../common/projections';
import { LocalUIFilterName } from '../../../../common/ui_filter';
interface Props {
projection: Projection;

View file

@ -19,11 +19,12 @@ import { resolveUrlParams } from './resolveUrlParams';
import { UIFilters } from '../../../typings/ui_filters';
import {
localUIFilterNames,
LocalUIFilterName,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../server/lib/ui_filters/local_ui_filters/config';
import { pickKeys } from '../../../common/utils/pick_keys';
import { useDeepObjectIdentity } from '../../hooks/useDeepObjectIdentity';
import { LocalUIFilterName } from '../../../common/ui_filter';
interface TimeRange {
rangeFrom: string;

View file

@ -47,6 +47,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) {
rangeTo,
environment,
searchTerm,
percentile,
} = query;
const localUIFilters = pickKeys(query, ...localUIFilterNames);
@ -75,6 +76,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) {
transactionName,
transactionType,
searchTerm: toString(searchTerm),
percentile: toNumber(percentile),
// ui filters
environment,

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { LocalUIFilterName } from '../../../server/lib/ui_filters/local_ui_filters/config';
import { LocalUIFilterName } from '../../../common/ui_filter';
export type IUrlParams = {
detailTab?: string;
@ -28,4 +27,5 @@ export type IUrlParams = {
page?: number;
pageSize?: number;
searchTerm?: string;
percentile?: number;
} & Partial<Record<LocalUIFilterName, string>>;

View file

@ -11,7 +11,6 @@ import { pickKeys } from '../../common/utils/pick_keys';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { LocalUIFiltersAPIResponse } from '../../server/lib/ui_filters/local_ui_filters';
import {
LocalUIFilterName,
localUIFilters,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../server/lib/ui_filters/local_ui_filters/config';
@ -20,6 +19,7 @@ import { removeUndefinedProps } from '../context/UrlParamsContext/helpers';
import { useCallApi } from './useCallApi';
import { useFetcher } from './useFetcher';
import { useUrlParams } from './useUrlParams';
import { LocalUIFilterName } from '../../common/ui_filter';
const getInitialData = (
filterNames: LocalUIFilterName[]

View file

@ -20,9 +20,11 @@ import {
export async function getClientMetrics({
setup,
urlQuery,
percentile = 50,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
percentile?: number;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
@ -41,7 +43,7 @@ export async function getClientMetrics({
backEnd: {
percentiles: {
field: TRANSACTION_TIME_TO_FIRST_BYTE,
percents: [50],
percents: [percentile],
hdr: {
number_of_significant_value_digits: 3,
},
@ -50,7 +52,7 @@ export async function getClientMetrics({
domInteractive: {
percentiles: {
field: TRANSACTION_DOM_INTERACTIVE,
percents: [50],
percents: [percentile],
hdr: {
number_of_significant_value_digits: 3,
},
@ -65,13 +67,15 @@ export async function getClientMetrics({
const response = await apmEventClient.search(params);
const { backEnd, domInteractive, pageViews } = response.aggregations!;
const pkey = percentile.toFixed(1);
// Divide by 1000 to convert ms into seconds
return {
pageViews,
backEnd: { value: (backEnd.values['50.0'] || 0) / 1000 },
backEnd: { value: (backEnd.values[pkey] || 0) / 1000 },
frontEnd: {
value:
((domInteractive.values['50.0'] || 0) - (backEnd.values['50.0'] || 0)) /
((domInteractive.values[pkey] || 0) - (backEnd.values[pkey] || 0)) /
1000,
},
};

View file

@ -15,6 +15,7 @@ import { BreakdownItem } from '../../../typings/ui_filters';
export async function getPageViewTrends({
setup,
breakdowns,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
breakdowns?: string;
@ -22,6 +23,7 @@ export async function getPageViewTrends({
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
urlQuery,
});
let breakdownItem: BreakdownItem | null = null;
if (breakdowns) {

View file

@ -11,13 +11,19 @@ import {
SetupUIFilters,
} from '../helpers/setup_request';
import { getRumPageLoadTransactionsProjection } from '../../projections/rum_page_load_transactions';
import {
TRANSACTION_DURATION,
TRANSACTION_URL,
} from '../../../common/elasticsearch_fieldnames';
export async function getUrlSearch({
setup,
urlQuery,
percentile,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
percentile: number;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
@ -30,19 +36,19 @@ export async function getUrlSearch({
aggs: {
totalUrls: {
cardinality: {
field: 'url.full',
field: TRANSACTION_URL,
},
},
urls: {
terms: {
field: 'url.full',
field: TRANSACTION_URL,
size: 10,
},
aggs: {
medianPLD: {
percentiles: {
field: 'transaction.duration.us',
percents: [50],
field: TRANSACTION_DURATION,
percents: [percentile],
},
},
},
@ -56,12 +62,14 @@ export async function getUrlSearch({
const response = await apmEventClient.search(params);
const { urls, totalUrls } = response.aggregations ?? {};
const pkey = percentile.toFixed(1);
return {
total: totalUrls?.value || 0,
items: (urls?.buckets ?? []).map((bucket) => ({
url: bucket.key as string,
count: bucket.doc_count,
pld: bucket.medianPLD.values['50.0'] ?? 0,
pld: bucket.medianPLD.values[pkey] ?? 0,
})),
};
}

View file

@ -23,12 +23,15 @@ import {
export async function getWebCoreVitals({
setup,
urlQuery,
percentile = 50,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
percentile?: number;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
urlQuery,
});
const params = mergeProjection(projection, {
@ -50,31 +53,31 @@ export async function getWebCoreVitals({
lcp: {
percentiles: {
field: LCP_FIELD,
percents: [50],
percents: [percentile],
},
},
fid: {
percentiles: {
field: FID_FIELD,
percents: [50],
percents: [percentile],
},
},
cls: {
percentiles: {
field: CLS_FIELD,
percents: [50],
percents: [percentile],
},
},
tbt: {
percentiles: {
field: TBT_FIELD,
percents: [50],
percents: [percentile],
},
},
fcp: {
percentiles: {
field: FCP_FIELD,
percents: [50],
percents: [percentile],
},
},
lcpRanks: {
@ -124,12 +127,15 @@ export async function getWebCoreVitals({
{ value: 0, key: 0 },
];
const pkey = percentile.toFixed(1);
// Divide by 1000 to convert ms into seconds
return {
cls: String(cls?.values['50.0']?.toFixed(2) || 0),
fid: fid?.values['50.0'] ?? 0,
lcp: lcp?.values['50.0'] ?? 0,
tbt: tbt?.values['50.0'] ?? 0,
fcp: fcp?.values['50.0'] ?? 0,
cls: String(cls?.values[pkey]?.toFixed(2) || 0),
fid: fid?.values[pkey] ?? 0,
lcp: lcp?.values[pkey] ?? 0,
tbt: tbt?.values[pkey] ?? 0,
fcp: fcp?.values[pkey] ?? 0,
lcpRanks: getRanksPercentages(lcpRanks?.values ?? defaultRanks),
fidRanks: getRanksPercentages(fidRanks?.values ?? defaultRanks),

View file

@ -3,98 +3,8 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import {
CONTAINER_ID,
POD_NAME,
AGENT_NAME,
HOST_NAME,
TRANSACTION_RESULT,
SERVICE_VERSION,
TRANSACTION_URL,
USER_AGENT_NAME,
USER_AGENT_DEVICE,
USER_AGENT_OS,
CLIENT_GEO_COUNTRY_ISO_CODE,
SERVICE_NAME,
} from '../../../../common/elasticsearch_fieldnames';
const filtersByName = {
host: {
title: i18n.translate('xpack.apm.localFilters.titles.host', {
defaultMessage: 'Host',
}),
fieldName: HOST_NAME,
},
agentName: {
title: i18n.translate('xpack.apm.localFilters.titles.agentName', {
defaultMessage: 'Agent name',
}),
fieldName: AGENT_NAME,
},
containerId: {
title: i18n.translate('xpack.apm.localFilters.titles.containerId', {
defaultMessage: 'Container ID',
}),
fieldName: CONTAINER_ID,
},
podName: {
title: i18n.translate('xpack.apm.localFilters.titles.podName', {
defaultMessage: 'Kubernetes pod',
}),
fieldName: POD_NAME,
},
transactionResult: {
title: i18n.translate('xpack.apm.localFilters.titles.transactionResult', {
defaultMessage: 'Transaction result',
}),
fieldName: TRANSACTION_RESULT,
},
serviceVersion: {
title: i18n.translate('xpack.apm.localFilters.titles.serviceVersion', {
defaultMessage: 'Service version',
}),
fieldName: SERVICE_VERSION,
},
transactionUrl: {
title: i18n.translate('xpack.apm.localFilters.titles.transactionUrl', {
defaultMessage: 'Url',
}),
fieldName: TRANSACTION_URL,
},
browser: {
title: i18n.translate('xpack.apm.localFilters.titles.browser', {
defaultMessage: 'Browser',
}),
fieldName: USER_AGENT_NAME,
},
device: {
title: i18n.translate('xpack.apm.localFilters.titles.device', {
defaultMessage: 'Device',
}),
fieldName: USER_AGENT_DEVICE,
},
location: {
title: i18n.translate('xpack.apm.localFilters.titles.location', {
defaultMessage: 'Location',
}),
fieldName: CLIENT_GEO_COUNTRY_ISO_CODE,
},
os: {
title: i18n.translate('xpack.apm.localFilters.titles.os', {
defaultMessage: 'OS',
}),
fieldName: USER_AGENT_OS,
},
serviceName: {
title: i18n.translate('xpack.apm.localFilters.titles.serviceName', {
defaultMessage: 'Service name',
}),
fieldName: SERVICE_NAME,
},
};
export type LocalUIFilterName = keyof typeof filtersByName;
import { filtersByName, LocalUIFilterName } from '../../../../common/ui_filter';
export interface LocalUIFilter {
name: LocalUIFilterName;

View file

@ -9,7 +9,8 @@ import { mergeProjection } from '../../../projections/util/merge_projection';
import { Projection } from '../../../projections/typings';
import { UIFilters } from '../../../../typings/ui_filters';
import { getUiFiltersES } from '../../helpers/convert_ui_filters/get_ui_filters_es';
import { localUIFilters, LocalUIFilterName } from './config';
import { localUIFilters } from './config';
import { LocalUIFilterName } from '../../../../common/ui_filter';
export const getLocalFilterQuery = ({
uiFilters,

View file

@ -9,7 +9,8 @@ import { Projection } from '../../../projections/typings';
import { PromiseReturnType } from '../../../../../observability/typings/common';
import { getLocalFilterQuery } from './get_local_filter_query';
import { Setup } from '../../helpers/setup_request';
import { localUIFilters, LocalUIFilterName } from './config';
import { localUIFilters } from './config';
import { LocalUIFilterName } from '../../../../common/ui_filter';
export type LocalUIFiltersAPIResponse = PromiseReturnType<
typeof getLocalUIFilters

View file

@ -24,33 +24,36 @@ export const percentileRangeRt = t.partial({
maxPercentile: t.string,
});
const urlQueryRt = t.partial({ urlQuery: t.string });
const uxQueryRt = t.intersection([
uiFiltersRt,
rangeRt,
t.partial({ urlQuery: t.string, percentile: t.string }),
]);
export const rumClientMetricsRoute = createRoute(() => ({
path: '/api/apm/rum/client-metrics',
params: {
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
query: uxQueryRt,
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const {
query: { urlQuery },
query: { urlQuery, percentile },
} = context.params;
return getClientMetrics({ setup, urlQuery });
return getClientMetrics({
setup,
urlQuery,
percentile: percentile ? Number(percentile) : undefined,
});
},
}));
export const rumPageLoadDistributionRoute = createRoute(() => ({
path: '/api/apm/rum-client/page-load-distribution',
params: {
query: t.intersection([
uiFiltersRt,
rangeRt,
percentileRangeRt,
urlQueryRt,
]),
query: t.intersection([uxQueryRt, percentileRangeRt]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
@ -72,10 +75,8 @@ export const rumPageLoadDistBreakdownRoute = createRoute(() => ({
path: '/api/apm/rum-client/page-load-distribution/breakdown',
params: {
query: t.intersection([
uiFiltersRt,
rangeRt,
uxQueryRt,
percentileRangeRt,
urlQueryRt,
t.type({ breakdown: t.string }),
]),
},
@ -99,12 +100,7 @@ export const rumPageLoadDistBreakdownRoute = createRoute(() => ({
export const rumPageViewsTrendRoute = createRoute(() => ({
path: '/api/apm/rum-client/page-view-trends',
params: {
query: t.intersection([
uiFiltersRt,
rangeRt,
urlQueryRt,
t.partial({ breakdowns: t.string }),
]),
query: t.intersection([uxQueryRt, t.partial({ breakdowns: t.string })]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
@ -113,7 +109,11 @@ export const rumPageViewsTrendRoute = createRoute(() => ({
query: { breakdowns, urlQuery },
} = context.params;
return getPageViewTrends({ setup, breakdowns, urlQuery });
return getPageViewTrends({
setup,
breakdowns,
urlQuery,
});
},
}));
@ -132,7 +132,7 @@ export const rumServicesRoute = createRoute(() => ({
export const rumVisitorsBreakdownRoute = createRoute(() => ({
path: '/api/apm/rum-client/visitor-breakdown',
params: {
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
query: uxQueryRt,
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
@ -141,30 +141,37 @@ export const rumVisitorsBreakdownRoute = createRoute(() => ({
query: { urlQuery },
} = context.params;
return getVisitorBreakdown({ setup, urlQuery });
return getVisitorBreakdown({
setup,
urlQuery,
});
},
}));
export const rumWebCoreVitals = createRoute(() => ({
path: '/api/apm/rum-client/web-core-vitals',
params: {
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
query: uxQueryRt,
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const {
query: { urlQuery },
query: { urlQuery, percentile },
} = context.params;
return getWebCoreVitals({ setup, urlQuery });
return getWebCoreVitals({
setup,
urlQuery,
percentile: percentile ? Number(percentile) : undefined,
});
},
}));
export const rumLongTaskMetrics = createRoute(() => ({
path: '/api/apm/rum-client/long-task-metrics',
params: {
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
query: uxQueryRt,
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
@ -173,23 +180,26 @@ export const rumLongTaskMetrics = createRoute(() => ({
query: { urlQuery },
} = context.params;
return getLongTaskMetrics({ setup, urlQuery });
return getLongTaskMetrics({
setup,
urlQuery,
});
},
}));
export const rumUrlSearch = createRoute(() => ({
path: '/api/apm/rum-client/url-search',
params: {
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
query: uxQueryRt,
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const {
query: { urlQuery },
query: { urlQuery, percentile },
} = context.params;
return getUrlSearch({ setup, urlQuery });
return getUrlSearch({ setup, urlQuery, percentile: Number(percentile) });
},
}));

View file

@ -14,10 +14,7 @@ import {
} from '../lib/helpers/setup_request';
import { getEnvironments } from '../lib/ui_filters/get_environments';
import { Projection } from '../projections/typings';
import {
localUIFilterNames,
LocalUIFilterName,
} from '../lib/ui_filters/local_ui_filters/config';
import { localUIFilterNames } from '../lib/ui_filters/local_ui_filters/config';
import { getUiFiltersES } from '../lib/helpers/convert_ui_filters/get_ui_filters_es';
import { getLocalUIFilters } from '../lib/ui_filters/local_ui_filters';
import { getServicesProjection } from '../projections/services';
@ -32,6 +29,7 @@ import { getServiceNodesProjection } from '../projections/service_nodes';
import { getRumPageLoadTransactionsProjection } from '../projections/rum_page_load_transactions';
import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions';
import { APMRequestHandlerContext } from './typings';
import { LocalUIFilterName } from '../../common/ui_filter';
export const uiFiltersEnvironmentsRoute = createRoute(() => ({
path: '/api/apm/ui_filters/environments',

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { LocalUIFilterName } from '../server/lib/ui_filters/local_ui_filters/config';
import { LocalUIFilterName } from '../common/ui_filter';
export type UIFilters = {
kuery?: string;

View file

@ -34,7 +34,7 @@ export type HasData = () => Promise<boolean>;
export type ObservabilityFetchDataPlugins = Exclude<
ObservabilityApp,
'observability' | 'stack_monitoring'
'observability' | 'stack_monitoring' | 'ux'
>;
export interface DataHandler<

View file

@ -10,7 +10,8 @@ export type ObservabilityApp =
| 'apm'
| 'uptime'
| 'observability'
| 'stack_monitoring';
| 'stack_monitoring'
| 'ux';
export type PromiseReturnType<Func> = Func extends (...args: any[]) => Promise<infer Value>
? Value

View file

@ -16,7 +16,7 @@ export default function rumServicesApiTests({ getService }: FtrProviderContext)
describe('when there is no data', () => {
it('returns empty list', async () => {
const response = await supertest.get(
'/api/apm/rum-client/url-search?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22elastic-co-rum-test%22%5D%7D'
'/api/apm/rum-client/url-search?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22elastic-co-rum-test%22%5D%7D&percentile=50'
);
expect(response.status).to.be(200);
@ -41,7 +41,7 @@ export default function rumServicesApiTests({ getService }: FtrProviderContext)
it('returns top urls when no query', async () => {
const response = await supertest.get(
'/api/apm/rum-client/url-search?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D'
'/api/apm/rum-client/url-search?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D&percentile=50'
);
expect(response.status).to.be(200);
@ -67,7 +67,7 @@ export default function rumServicesApiTests({ getService }: FtrProviderContext)
it('returns specific results against query', async () => {
const response = await supertest.get(
'/api/apm/rum-client/url-search?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D&urlQuery=csm'
'/api/apm/rum-client/url-search?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D&urlQuery=csm&percentile=50'
);
expect(response.status).to.be(200);

View file

@ -16,7 +16,7 @@ export default function rumServicesApiTests({ getService }: FtrProviderContext)
describe('when there is no data', () => {
it('returns empty list', async () => {
const response = await supertest.get(
'/api/apm/rum-client/web-core-vitals?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22elastic-co-rum-test%22%5D%7D'
'/api/apm/rum-client/web-core-vitals?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22elastic-co-rum-test%22%5D%7D&percentile=50'
);
expect(response.status).to.be(200);
@ -45,7 +45,7 @@ export default function rumServicesApiTests({ getService }: FtrProviderContext)
it('returns web core vitals values', async () => {
const response = await supertest.get(
'/api/apm/rum-client/web-core-vitals?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D'
'/api/apm/rum-client/web-core-vitals?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D&percentile=50'
);
expect(response.status).to.be(200);