[CSM] Url search (#77516)

Co-authored-by: Justin Kambic <justin.kambic@elastic.co>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Shahzad 2020-09-23 12:51:39 +02:00 committed by GitHub
parent 4b6d77fa5d
commit 9276a16db7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 966 additions and 50 deletions

3
.github/CODEOWNERS vendored
View file

@ -65,11 +65,12 @@
# Client Side Monitoring (lives in APM directories but owned by Uptime)
/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm @elastic/uptime
/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature @elastic/uptime
/x-pack/plugins/apm/public/application/csmApp.tsx @elastic/uptime
/x-pack/plugins/apm/public/components/app/RumDashboard @elastic/uptime
/x-pack/plugins/apm/server/lib/rum_client @elastic/uptime
/x-pack/plugins/apm/server/routes/rum_client.ts @elastic/uptime
/x-pack/plugins/apm/server/projections/rum_overview.ts @elastic/uptime
/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts @elastic/uptime
# Beats
/x-pack/plugins/beats_management/ @elastic/beats

View file

@ -27,3 +27,11 @@ Feature: CSM Dashboard
Given a user clicks the page load breakdown filter
When the user selected the breakdown
Then breakdown series should appear in chart
Scenario: Search by url filter focus
When a user clicks inside url search field
Then it displays top pages in the suggestion popover
Scenario: Search by url filter
When a user enters a query in url search field
Then it should filter results based on query

View file

@ -0,0 +1,65 @@
/*
* 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 { DEFAULT_TIMEOUT } from './csm_dashboard';
When(`a user clicks inside url search field`, () => {
// wait for all loading to finish
cy.get('kbnLoadingIndicator').should('not.be.visible');
cy.get('.euiStat__title-isLoading').should('not.be.visible');
cy.get('span[data-cy=csmUrlFilter]', DEFAULT_TIMEOUT).within(() => {
cy.get('input.euiFieldSearch').click();
});
});
Then(`it displays top pages in the suggestion popover`, () => {
cy.get('kbnLoadingIndicator').should('not.be.visible');
cy.get('div.euiPopover__panel-isOpen', DEFAULT_TIMEOUT).within(() => {
const listOfUrls = cy.get('li.euiSelectableListItem');
listOfUrls.should('have.length', 5);
const actualUrlsText = [
'http://opbeans-node:3000/dashboardPage views: 17Page load duration: 109 ms ',
'http://opbeans-node:3000/ordersPage views: 14Page load duration: 72 ms',
];
cy.get('li.euiSelectableListItem')
.eq(0)
.should('have.text', actualUrlsText[0]);
cy.get('li.euiSelectableListItem')
.eq(1)
.should('have.text', actualUrlsText[1]);
});
});
When(`a user enters a query in url search field`, () => {
cy.get('kbnLoadingIndicator').should('not.be.visible');
cy.get('[data-cy=csmUrlFilter]').within(() => {
cy.get('input.euiSelectableSearch').type('cus');
});
cy.get('kbnLoadingIndicator').should('not.be.visible');
});
Then(`it should filter results based on query`, () => {
cy.get('kbnLoadingIndicator').should('not.be.visible');
cy.get('div.euiPopover__panel-isOpen', DEFAULT_TIMEOUT).within(() => {
const listOfUrls = cy.get('li.euiSelectableListItem');
listOfUrls.should('have.length', 1);
const actualUrlsText = [
'http://opbeans-node:3000/customersPage views: 10Page load duration: 76 ms ',
];
cy.get('li.euiSelectableListItem')
.eq(0)
.should('have.text', actualUrlsText[0]);
});
});

View file

@ -22,7 +22,7 @@ const ClFlexGroup = styled(EuiFlexGroup)`
export function ClientMetrics() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end } = urlParams;
const { start, end, searchTerm } = urlParams;
const { data, status } = useFetcher(
(callApmApi) => {
@ -31,13 +31,18 @@ export function ClientMetrics() {
return callApmApi({
pathname: '/api/apm/rum/client-metrics',
params: {
query: { start, end, uiFilters: JSON.stringify(uiFilters) },
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
},
},
});
}
return Promise.resolve(null);
},
[start, end, uiFilters]
[start, end, uiFilters, searchTerm]
);
const STAT_STYLE = { width: '240px' };

View file

@ -22,7 +22,7 @@ export interface PercentileRange {
export function PageLoadDistribution() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end } = urlParams;
const { start, end, searchTerm } = urlParams;
const [percentileRange, setPercentileRange] = useState<PercentileRange>({
min: null,
@ -41,6 +41,7 @@ export function PageLoadDistribution() {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
...(percentileRange.min && percentileRange.max
? {
minPercentile: String(percentileRange.min),
@ -53,7 +54,14 @@ export function PageLoadDistribution() {
}
return Promise.resolve(null);
},
[end, start, uiFilters, percentileRange.min, percentileRange.max]
[
end,
start,
uiFilters,
percentileRange.min,
percentileRange.max,
searchTerm,
]
);
const onPercentileChange = (min: number, max: number) => {

View file

@ -17,7 +17,7 @@ interface Props {
export const useBreakdowns = ({ percentileRange, field, value }: Props) => {
const { urlParams, uiFilters } = useUrlParams();
const { start, end } = urlParams;
const { start, end, searchTerm } = urlParams;
const { min: minP, max: maxP } = percentileRange ?? {};
@ -32,6 +32,7 @@ export const useBreakdowns = ({ percentileRange, field, value }: Props) => {
end,
breakdown: value,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
...(minP && maxP
? {
minPercentile: String(minP),
@ -43,6 +44,6 @@ export const useBreakdowns = ({ percentileRange, field, value }: Props) => {
});
}
},
[end, start, uiFilters, field, value, minP, maxP]
[end, start, uiFilters, field, value, minP, maxP, searchTerm]
);
};

View file

@ -16,7 +16,7 @@ import { BreakdownItem } from '../../../../../typings/ui_filters';
export function PageViewsTrend() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end } = urlParams;
const { start, end, searchTerm } = urlParams;
const [breakdown, setBreakdown] = useState<BreakdownItem | null>(null);
@ -30,6 +30,7 @@ export function PageViewsTrend() {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
...(breakdown
? {
breakdowns: JSON.stringify(breakdown),
@ -41,7 +42,7 @@ export function PageViewsTrend() {
}
return Promise.resolve(undefined);
},
[end, start, uiFilters, breakdown]
[end, start, uiFilters, breakdown, searchTerm]
);
return (

View file

@ -13,8 +13,8 @@ import {
import { i18n } from '@kbn/i18n';
import React, { useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { fromQuery, toQuery } from '../../Links/url_helpers';
import { useUrlParams } from '../../../../../hooks/useUrlParams';
import { fromQuery, toQuery } from '../../../../shared/Links/url_helpers';
interface Props {
serviceNames: string[];

View file

@ -0,0 +1,68 @@
/*
* 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, { ReactNode } from 'react';
import classNames from 'classnames';
import { EuiHighlight, EuiSelectableOption } from '@elastic/eui';
import styled from 'styled-components';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
const StyledSpan = styled.span`
color: ${euiLightVars.euiColorSecondaryText};
font-weight: 500;
:not(:last-of-type)::after {
content: '•';
margin: 0 4px;
}
`;
const StyledListSpan = styled.span`
display: block;
margin-top: 4px;
font-size: 12px;
`;
export type UrlOption<T = { [key: string]: any }> = {
meta?: string[];
} & EuiSelectableOption<T>;
export const formatOptions = (options: EuiSelectableOption[]) => {
return options.map((item: EuiSelectableOption) => ({
title: item.label,
...item,
className: classNames(
'euiSelectableTemplateSitewide__listItem',
item.className
),
}));
};
export function selectableRenderOptions(
option: UrlOption,
searchValue: string
) {
return (
<>
<EuiHighlight
className="euiSelectableTemplateSitewide__listItemTitle"
search={searchValue}
>
{option.label}
</EuiHighlight>
{renderOptionMeta(option.meta)}
</>
);
}
function renderOptionMeta(meta?: string[]): ReactNode {
if (!meta || meta.length < 1) return;
return (
<StyledListSpan>
{meta.map((item: string) => (
<StyledSpan key={item}>{item}</StyledSpan>
))}
</StyledListSpan>
);
}

View file

@ -0,0 +1,164 @@
/*
* 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, { FormEvent, useRef, useState } from 'react';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPopover,
EuiPopoverTitle,
EuiSelectable,
EuiSelectableMessage,
} from '@elastic/eui';
import {
formatOptions,
selectableRenderOptions,
UrlOption,
} from './RenderOption';
import { I18LABELS } from '../../translations';
interface Props {
data: {
items: UrlOption[];
total?: number;
};
loading: boolean;
onInputChange: (e: FormEvent<HTMLInputElement>) => void;
onTermChange: () => void;
onChange: (updatedOptions: UrlOption[]) => void;
searchValue: string;
onClose: () => void;
}
export function SelectableUrlList({
data,
loading,
onInputChange,
onTermChange,
onChange,
searchValue,
onClose,
}: Props) {
const [popoverIsOpen, setPopoverIsOpen] = useState(false);
const [popoverRef, setPopoverRef] = useState<HTMLElement | null>(null);
const [searchRef, setSearchRef] = useState<HTMLInputElement | null>(null);
const titleRef = useRef<HTMLDivElement>(null);
const searchOnFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setPopoverIsOpen(true);
};
const onSearchInput = (e: React.FormEvent<HTMLInputElement>) => {
onInputChange(e);
setPopoverIsOpen(true);
};
const searchOnBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (
!popoverRef?.contains(e.relatedTarget as HTMLElement) &&
!popoverRef?.contains(titleRef.current as HTMLDivElement)
) {
setPopoverIsOpen(false);
}
};
const formattedOptions = formatOptions(data.items ?? []);
const closePopover = () => {
setPopoverIsOpen(false);
onClose();
if (searchRef) {
searchRef.blur();
}
};
const loadingMessage = (
<EuiSelectableMessage style={{ minHeight: 300 }}>
<EuiLoadingSpinner size="l" />
<br />
<p>{I18LABELS.loadingResults}</p>
</EuiSelectableMessage>
);
const emptyMessage = (
<EuiSelectableMessage style={{ minHeight: 300 }}>
<p>{I18LABELS.noResults}</p>
</EuiSelectableMessage>
);
const titleText = searchValue
? I18LABELS.getSearchResultsLabel(data?.total ?? 0)
: I18LABELS.topPages;
function PopOverTitle() {
return (
<EuiPopoverTitle>
<EuiFlexGroup ref={titleRef}>
<EuiFlexItem style={{ justifyContent: 'center' }}>
{loading ? <EuiLoadingSpinner /> : titleText}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
disabled={!searchValue}
onClick={() => {
onTermChange();
setPopoverIsOpen(false);
}}
>
{I18LABELS.matchThisQuery}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
);
}
return (
<EuiSelectable
searchable
onChange={onChange}
isLoading={loading}
options={formattedOptions}
renderOption={selectableRenderOptions}
singleSelection={false}
searchProps={{
placeholder: I18LABELS.searchByUrl,
isClearable: true,
onFocus: searchOnFocus,
onBlur: searchOnBlur,
onInput: onSearchInput,
inputRef: setSearchRef,
}}
listProps={{
rowHeight: 68,
showIcons: true,
}}
loadingMessage={loadingMessage}
emptyMessage={emptyMessage}
noMatchesMessage={emptyMessage}
>
{(list, search) => (
<EuiPopover
panelPaddingSize="none"
isOpen={popoverIsOpen}
display={'block'}
panelRef={setPopoverRef}
button={search}
closePopover={closePopover}
>
<div style={{ width: 600, maxWidth: '100%' }}>
<PopOverTitle />
{list}
</div>
</EuiPopover>
)}
</EuiSelectable>
);
}

View file

@ -0,0 +1,132 @@
/*
* 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 { EuiTitle } from '@elastic/eui';
import useDebounce from 'react-use/lib/useDebounce';
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { useUrlParams } from '../../../../../hooks/useUrlParams';
import { useFetcher } from '../../../../../hooks/useFetcher';
import { I18LABELS } from '../../translations';
import { fromQuery, toQuery } from '../../../../shared/Links/url_helpers';
import { formatToSec } from '../../UXMetrics/KeyUXMetrics';
import { SelectableUrlList } from './SelectableUrlList';
import { UrlOption } from './RenderOption';
interface Props {
onChange: (value: string[]) => void;
}
export function URLSearch({ onChange: onFilterChange }: Props) {
const history = useHistory();
const { urlParams, uiFilters } = useUrlParams();
const { start, end, serviceName } = urlParams;
const [searchValue, setSearchValue] = useState('');
const [debouncedValue, setDebouncedValue] = useState('');
useDebounce(
() => {
setSearchValue(debouncedValue);
},
250,
[debouncedValue]
);
const updateSearchTerm = useCallback(
(searchTermN: string) => {
const newLocation = {
...history.location,
search: fromQuery({
...toQuery(history.location.search),
searchTerm: searchTermN,
}),
};
history.push(newLocation);
},
[history]
);
const [checkedUrls, setCheckedUrls] = useState<string[]>([]);
const { data, status } = useFetcher(
(callApmApi) => {
if (start && end && serviceName) {
const { transactionUrl, ...restFilters } = uiFilters;
return callApmApi({
pathname: '/api/apm/rum-client/url-search',
params: {
query: {
start,
end,
uiFilters: JSON.stringify(restFilters),
urlQuery: searchValue,
},
},
});
}
return Promise.resolve(null);
},
[start, end, serviceName, uiFilters, searchValue]
);
useEffect(() => {
setCheckedUrls(uiFilters.transactionUrl || []);
}, [uiFilters]);
const onChange = (updatedOptions: UrlOption[]) => {
const clickedItems = updatedOptions.filter(
(option) => option.checked === 'on'
);
setCheckedUrls(clickedItems.map((item) => item.url));
};
const items: UrlOption[] = (data?.items ?? []).map((item) => ({
label: item.url,
key: item.url,
meta: [
I18LABELS.pageViews + ': ' + item.count,
I18LABELS.pageLoadDuration + ': ' + formatToSec(item.pld),
],
url: item.url,
checked: checkedUrls?.includes(item.url) ? 'on' : undefined,
}));
const onInputChange = (e: FormEvent<HTMLInputElement>) => {
setDebouncedValue(e.currentTarget.value);
};
const isLoading = status !== 'success';
const onTermChange = () => {
updateSearchTerm(searchValue);
};
const onClose = () => {
onFilterChange(checkedUrls);
};
return (
<>
<EuiTitle size="xxs" textTransform="uppercase">
<h4>{I18LABELS.url}</h4>
</EuiTitle>
<SelectableUrlList
loading={isLoading}
onInputChange={onInputChange}
onTermChange={onTermChange}
data={{ items, total: data?.total ?? 0 }}
onChange={onChange}
onClose={onClose}
searchValue={searchValue}
/>
</>
);
}

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFlexGrid, EuiFlexItem, EuiBadge } from '@elastic/eui';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { px, truncate, unit } from '../../../../style/variables';
const BadgeText = styled.div`
display: inline-block;
${truncate(px(unit * 12))};
vertical-align: middle;
`;
interface Props {
value: string[];
onRemove: (val: string) => void;
}
const formatUrlValue = (val: string) => {
const maxUrlToDisplay = 30;
const urlLength = val.length;
if (urlLength < maxUrlToDisplay) {
return val;
}
const urlObj = new URL(val);
if (urlObj.pathname === '/') {
return val;
}
const domainVal = urlObj.hostname;
const extraLength = urlLength - maxUrlToDisplay;
const extraDomain = domainVal.substring(0, extraLength);
if (urlObj.pathname.length + 7 > maxUrlToDisplay) {
return val.replace(domainVal, '..');
}
return val.replace(extraDomain, '..');
};
const removeFilterLabel = i18n.translate(
'xpack.apm.uifilter.badge.removeFilter',
{ defaultMessage: 'Remove filter' }
);
export function UrlList({ onRemove, value }: Props) {
return (
<EuiFlexGrid gutterSize="s">
{value.map((val) => (
<EuiFlexItem key={val} grow={false}>
<EuiBadge
color="hollow"
onClick={() => {
onRemove(val);
}}
onClickAriaLabel={removeFilterLabel}
iconOnClick={() => {
onRemove(val);
}}
iconOnClickAriaLabel={removeFilterLabel}
iconType="cross"
iconSide="right"
>
<BadgeText>{formatUrlValue(val)}</BadgeText>
</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGrid>
);
}

View file

@ -0,0 +1,102 @@
/*
* 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 React, { useCallback, useMemo } 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 { 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';
const removeSearchTermLabel = i18n.translate(
'xpack.apm.uiFilter.url.removeSearchTerm',
{ defaultMessage: 'Clear url query' }
);
export function URLFilter() {
const history = useHistory();
const {
urlParams: { searchTerm },
} = useUrlParams();
const localUIFiltersConfig = useMemo(() => {
const config: React.ComponentProps<typeof LocalUIFilters> = {
filterNames: ['transactionUrl'],
projection: Projection.rumOverview,
};
return config;
}, []);
const { filters, setFilterValue } = useLocalUIFilters({
...localUIFiltersConfig,
});
const updateSearchTerm = useCallback(
(searchTermN?: string) => {
const newLocation = {
...history.location,
search: fromQuery({
...toQuery(history.location.search),
searchTerm: searchTermN,
}),
};
history.push(newLocation);
},
[history]
);
const { name, value: filterValue } = filters[0];
return (
<span data-cy="csmUrlFilter">
<EuiSpacer size="s" />
<URLSearch
onChange={(value) => {
setFilterValue('transactionUrl', value);
}}
/>
<EuiSpacer size="s" />
{searchTerm && (
<>
<EuiBadge
onClick={() => {
updateSearchTerm();
}}
onClickAriaLabel={removeSearchTermLabel}
iconOnClick={() => {
updateSearchTerm();
}}
iconOnClickAriaLabel={removeSearchTermLabel}
iconType="cross"
iconSide="right"
>
*{searchTerm}*
</EuiBadge>
<EuiSpacer size="s" />
</>
)}
{filterValue.length > 0 && (
<UrlList
onRemove={(val) => {
setFilterValue(
name,
filterValue.filter((v) => val !== v)
);
}}
value={filterValue}
/>
)}
<EuiSpacer size="m" />
</span>
);
}

View file

@ -38,7 +38,7 @@ interface Props {
export function KeyUXMetrics({ data, loading }: Props) {
const { urlParams, uiFilters } = useUrlParams();
const { start, end, serviceName } = urlParams;
const { start, end, serviceName, searchTerm } = urlParams;
const { data: longTaskData, status } = useFetcher(
(callApmApi) => {
@ -46,13 +46,18 @@ export function KeyUXMetrics({ data, loading }: Props) {
return callApmApi({
pathname: '/api/apm/rum-client/long-task-metrics',
params: {
query: { start, end, uiFilters: JSON.stringify(uiFilters) },
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
},
},
});
}
return Promise.resolve(null);
},
[start, end, serviceName, uiFilters]
[start, end, serviceName, uiFilters, searchTerm]
);
// Note: FCP value is in ms unit

View file

@ -33,7 +33,7 @@ export interface UXMetrics {
export function UXMetrics() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end } = urlParams;
const { start, end, searchTerm } = urlParams;
const { data, status } = useFetcher(
(callApmApi) => {
@ -42,13 +42,18 @@ export function UXMetrics() {
return callApmApi({
pathname: '/api/apm/rum-client/web-core-vitals',
params: {
query: { start, end, uiFilters: JSON.stringify(uiFilters) },
query: {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
},
},
});
}
return Promise.resolve(null);
},
[start, end, uiFilters]
[start, end, uiFilters, searchTerm]
);
return (

View file

@ -14,7 +14,7 @@ import { useUrlParams } from '../../../../hooks/useUrlParams';
export function VisitorBreakdown() {
const { urlParams, uiFilters } = useUrlParams();
const { start, end } = urlParams;
const { start, end, searchTerm } = urlParams;
const { data, status } = useFetcher(
(callApmApi) => {
@ -26,13 +26,14 @@ export function VisitorBreakdown() {
start,
end,
uiFilters: JSON.stringify(uiFilters),
urlQuery: searchTerm,
},
},
});
}
return Promise.resolve(null);
},
[end, start, uiFilters]
[end, start, uiFilters, searchTerm]
);
return (

View file

@ -12,14 +12,15 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { useTrackPageview } from '../../../../../observability/public';
import { LocalUIFilters } from '../../shared/LocalUIFilters';
import { Projection } from '../../../../common/projections';
import { RumDashboard } from './RumDashboard';
import { ServiceNameFilter } from '../../shared/LocalUIFilters/ServiceNameFilter';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { useFetcher } from '../../../hooks/useFetcher';
import { RUM_AGENTS } from '../../../../common/agent_name';
import { EnvironmentFilter } from '../../shared/EnvironmentFilter';
import { URLFilter } from './URLFilter';
import { LocalUIFilters } from '../../shared/LocalUIFilters';
import { ServiceNameFilter } from './URLFilter/ServiceNameFilter';
export function RumOverview() {
useTrackPageview({ app: 'apm', path: 'rum_overview' });
@ -27,7 +28,7 @@ export function RumOverview() {
const localUIFiltersConfig = useMemo(() => {
const config: React.ComponentProps<typeof LocalUIFilters> = {
filterNames: ['transactionUrl', 'location', 'device', 'os', 'browser'],
filterNames: ['location', 'device', 'os', 'browser'],
projection: Projection.rumOverview,
};
@ -63,6 +64,7 @@ export function RumOverview() {
<EuiFlexItem grow={1}>
<EnvironmentFilter />
<EuiSpacer />
<LocalUIFilters {...localUIFiltersConfig} showCount={true}>
<>
<ServiceNameFilter
@ -70,6 +72,7 @@ export function RumOverview() {
serviceNames={data ?? []}
/>
<EuiSpacer size="xl" />
<URLFilter />
<EuiHorizontalRule margin="none" />{' '}
</>
</LocalUIFilters>

View file

@ -79,6 +79,32 @@ export const I18LABELS = {
defaultMessage: 'Page load duration by region',
}
),
searchByUrl: i18n.translate('xpack.apm.rum.filters.searchByUrl', {
defaultMessage: 'Search by url',
}),
getSearchResultsLabel: (total: number) =>
i18n.translate('xpack.apm.rum.filters.searchResults', {
defaultMessage: '{total} Search results',
values: { total },
}),
topPages: i18n.translate('xpack.apm.rum.filters.topPages', {
defaultMessage: 'Top pages',
}),
select: i18n.translate('xpack.apm.rum.filters.select', {
defaultMessage: 'Select',
}),
url: i18n.translate('xpack.apm.rum.filters.url', {
defaultMessage: 'Url',
}),
matchThisQuery: i18n.translate('xpack.apm.rum.filters.url.matchThisQuery', {
defaultMessage: 'Match this query',
}),
loadingResults: i18n.translate('xpack.apm.rum.filters.url.loadingResults', {
defaultMessage: 'Loading results',
}),
noResults: i18n.translate('xpack.apm.rum.filters.url.noResults', {
defaultMessage: 'No results available',
}),
};
export const VisitorBreakdownLabel = i18n.translate(

View file

@ -19,11 +19,14 @@ import {
export async function getClientMetrics({
setup,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
urlQuery,
});
const params = mergeProjection(projection, {

View file

@ -14,12 +14,17 @@ import {
SetupTimeRange,
SetupUIFilters,
} from '../helpers/setup_request';
import { SPAN_DURATION } from '../../../common/elasticsearch_fieldnames';
import {
SPAN_DURATION,
TRANSACTION_ID,
} from '../../../common/elasticsearch_fieldnames';
export async function getLongTaskMetrics({
setup,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
}) {
const projection = getRumLongTasksProjection({
setup,
@ -28,9 +33,6 @@ export async function getLongTaskMetrics({
const params = mergeProjection(projection, {
body: {
size: 0,
query: {
bool: projection.body.query.bool,
},
aggs: {
transIds: {
terms: {
@ -59,10 +61,13 @@ export async function getLongTaskMetrics({
const response = await apmEventClient.search(params);
const { transIds } = response.aggregations ?? {};
const validTransactions: string[] = await filterPageLoadTransactions(
const validTransactions: string[] = await filterPageLoadTransactions({
setup,
(transIds?.buckets ?? []).map((bucket) => bucket.key as string)
);
urlQuery,
transactionIds: (transIds?.buckets ?? []).map(
(bucket) => bucket.key as string
),
});
let noOfLongTasks = 0;
let sumOfLongTasks = 0;
let longestLongTask = 0;
@ -83,12 +88,18 @@ export async function getLongTaskMetrics({
};
}
async function filterPageLoadTransactions(
setup: Setup & SetupTimeRange & SetupUIFilters,
transactionIds: string[]
) {
async function filterPageLoadTransactions({
setup,
urlQuery,
transactionIds,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
transactionIds: string[];
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
urlQuery,
});
const params = mergeProjection(projection, {
@ -99,14 +110,14 @@ async function filterPageLoadTransactions(
must: [
{
terms: {
'transaction.id': transactionIds,
[TRANSACTION_ID]: transactionIds,
},
},
],
filter: [...projection.body.query.bool.filter],
},
},
_source: ['transaction.id'],
_source: [TRANSACTION_ID],
},
});

View file

@ -40,13 +40,16 @@ export async function getPageLoadDistribution({
setup,
minPercentile,
maxPercentile,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
minPercentile?: string;
maxPercentile?: string;
urlQuery?: string;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
urlQuery,
});
const params = mergeProjection(projection, {

View file

@ -18,6 +18,7 @@ export async function getPageViewTrends({
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
breakdowns?: string;
urlQuery?: string;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,

View file

@ -44,11 +44,13 @@ export const getPageLoadDistBreakdown = async ({
minDuration,
maxDuration,
breakdown,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
minDuration: number;
maxDuration: number;
breakdown: string;
urlQuery?: string;
}) => {
// convert secs to micros
const stepValues = getPLDChartSteps({
@ -58,6 +60,7 @@ export const getPageLoadDistBreakdown = async ({
const projection = getRumPageLoadTransactionsProjection({
setup,
urlQuery,
});
const params = mergeProjection(projection, {

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { mergeProjection } from '../../projections/util/merge_projection';
import {
Setup,
SetupTimeRange,
SetupUIFilters,
} from '../helpers/setup_request';
import { getRumPageLoadTransactionsProjection } from '../../projections/rum_page_load_transactions';
export async function getUrlSearch({
setup,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
urlQuery,
});
const params = mergeProjection(projection, {
body: {
size: 0,
aggs: {
totalUrls: {
cardinality: {
field: 'url.full',
},
},
urls: {
terms: {
field: 'url.full',
size: 10,
},
aggs: {
medianPLD: {
percentiles: {
field: 'transaction.duration.us',
percents: [50],
},
},
},
},
},
},
});
const { apmEventClient } = setup;
const response = await apmEventClient.search(params);
const { urls, totalUrls } = response.aggregations ?? {};
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,
})),
};
}

View file

@ -19,11 +19,14 @@ import {
export async function getVisitorBreakdown({
setup,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,
urlQuery,
});
const params = mergeProjection(projection, {

View file

@ -22,8 +22,10 @@ import {
export async function getWebCoreVitals({
setup,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
}) {
const projection = getRumPageLoadTransactionsProjection({
setup,

View file

@ -19,8 +19,10 @@ import { TRANSACTION_PAGE_LOAD } from '../../common/transaction_types';
export function getRumPageLoadTransactionsProjection({
setup,
urlQuery,
}: {
setup: Setup & SetupTimeRange & SetupUIFilters;
urlQuery?: string;
}) {
const { start, end, uiFiltersES } = setup;
@ -35,6 +37,17 @@ export function getRumPageLoadTransactionsProjection({
field: 'transaction.marks.navigationTiming.fetchStart',
},
},
...(urlQuery
? [
{
wildcard: {
'url.full': {
value: `*${urlQuery}*`,
},
},
},
]
: []),
...uiFiltersES,
],
};

View file

@ -77,6 +77,7 @@ import {
rumServicesRoute,
rumVisitorsBreakdownRoute,
rumWebCoreVitals,
rumUrlSearch,
rumLongTaskMetrics,
} from './rum_client';
import {
@ -173,6 +174,7 @@ const createApmApi = () => {
.add(rumServicesRoute)
.add(rumVisitorsBreakdownRoute)
.add(rumWebCoreVitals)
.add(rumUrlSearch)
.add(rumLongTaskMetrics)
// Observability dashboard

View file

@ -16,37 +16,54 @@ import { getRumServices } from '../lib/rum_client/get_rum_services';
import { getVisitorBreakdown } from '../lib/rum_client/get_visitor_breakdown';
import { getWebCoreVitals } from '../lib/rum_client/get_web_core_vitals';
import { getLongTaskMetrics } from '../lib/rum_client/get_long_task_metrics';
import { getUrlSearch } from '../lib/rum_client/get_url_search';
export const percentileRangeRt = t.partial({
minPercentile: t.string,
maxPercentile: t.string,
});
const urlQueryRt = t.partial({ urlQuery: t.string });
export const rumClientMetricsRoute = createRoute(() => ({
path: '/api/apm/rum/client-metrics',
params: {
query: t.intersection([uiFiltersRt, rangeRt]),
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
return getClientMetrics({ setup });
const {
query: { urlQuery },
} = context.params;
return getClientMetrics({ setup, urlQuery });
},
}));
export const rumPageLoadDistributionRoute = createRoute(() => ({
path: '/api/apm/rum-client/page-load-distribution',
params: {
query: t.intersection([uiFiltersRt, rangeRt, percentileRangeRt]),
query: t.intersection([
uiFiltersRt,
rangeRt,
percentileRangeRt,
urlQueryRt,
]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const {
query: { minPercentile, maxPercentile },
query: { minPercentile, maxPercentile, urlQuery },
} = context.params;
return getPageLoadDistribution({ setup, minPercentile, maxPercentile });
return getPageLoadDistribution({
setup,
minPercentile,
maxPercentile,
urlQuery,
});
},
}));
@ -57,6 +74,7 @@ export const rumPageLoadDistBreakdownRoute = createRoute(() => ({
uiFiltersRt,
rangeRt,
percentileRangeRt,
urlQueryRt,
t.type({ breakdown: t.string }),
]),
},
@ -64,7 +82,7 @@ export const rumPageLoadDistBreakdownRoute = createRoute(() => ({
const setup = await setupRequest(context, request);
const {
query: { minPercentile, maxPercentile, breakdown },
query: { minPercentile, maxPercentile, breakdown, urlQuery },
} = context.params;
return getPageLoadDistBreakdown({
@ -72,6 +90,7 @@ export const rumPageLoadDistBreakdownRoute = createRoute(() => ({
minDuration: Number(minPercentile),
maxDuration: Number(maxPercentile),
breakdown,
urlQuery,
});
},
}));
@ -82,6 +101,7 @@ export const rumPageViewsTrendRoute = createRoute(() => ({
query: t.intersection([
uiFiltersRt,
rangeRt,
urlQueryRt,
t.partial({ breakdowns: t.string }),
]),
},
@ -89,10 +109,10 @@ export const rumPageViewsTrendRoute = createRoute(() => ({
const setup = await setupRequest(context, request);
const {
query: { breakdowns },
query: { breakdowns, urlQuery },
} = context.params;
return getPageViewTrends({ setup, breakdowns });
return getPageViewTrends({ setup, breakdowns, urlQuery });
},
}));
@ -111,35 +131,63 @@ export const rumServicesRoute = createRoute(() => ({
export const rumVisitorsBreakdownRoute = createRoute(() => ({
path: '/api/apm/rum-client/visitor-breakdown',
params: {
query: t.intersection([uiFiltersRt, rangeRt]),
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
return getVisitorBreakdown({ setup });
const {
query: { urlQuery },
} = context.params;
return getVisitorBreakdown({ setup, urlQuery });
},
}));
export const rumWebCoreVitals = createRoute(() => ({
path: '/api/apm/rum-client/web-core-vitals',
params: {
query: t.intersection([uiFiltersRt, rangeRt]),
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
return getWebCoreVitals({ setup });
const {
query: { urlQuery },
} = context.params;
return getWebCoreVitals({ setup, urlQuery });
},
}));
export const rumLongTaskMetrics = createRoute(() => ({
path: '/api/apm/rum-client/long-task-metrics',
params: {
query: t.intersection([uiFiltersRt, rangeRt]),
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
return getLongTaskMetrics({ setup });
const {
query: { urlQuery },
} = context.params;
return getLongTaskMetrics({ setup, urlQuery });
},
}));
export const rumUrlSearch = createRoute(() => ({
path: '/api/apm/rum-client/url-search',
params: {
query: t.intersection([uiFiltersRt, rangeRt, urlQueryRt]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
const {
query: { urlQuery },
} = context.params;
return getUrlSearch({ setup, urlQuery });
},
}));

View file

@ -0,0 +1,90 @@
/*
* 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 expect from '@kbn/expect';
import { expectSnapshot } from '../../../common/match_snapshot';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
export default function rumServicesApiTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('CSM url search api', () => {
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'
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"items": Array [],
"total": 0,
}
`);
});
});
describe('when there is data', () => {
before(async () => {
await esArchiver.load('8.0.0');
await esArchiver.load('rum_8.0.0');
});
after(async () => {
await esArchiver.unload('8.0.0');
await esArchiver.unload('rum_8.0.0');
});
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'
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"items": Array [
Object {
"count": 5,
"pld": 4924000,
"url": "http://localhost:5601/nfw/app/csm?rangeFrom=now-15m&rangeTo=now&serviceName=kibana-frontend-8_0_0",
},
Object {
"count": 1,
"pld": 2760000,
"url": "http://localhost:5601/nfw/app/home",
},
],
"total": 2,
}
`);
});
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'
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"items": Array [
Object {
"count": 5,
"pld": 4924000,
"url": "http://localhost:5601/nfw/app/csm?rangeFrom=now-15m&rangeTo=now&serviceName=kibana-frontend-8_0_0",
},
],
"total": 1,
}
`);
});
});
});
}

View file

@ -35,6 +35,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr
loadTestFile(require.resolve('./csm/csm_services.ts'));
loadTestFile(require.resolve('./csm/web_core_vitals.ts'));
loadTestFile(require.resolve('./csm/long_task_metrics.ts'));
loadTestFile(require.resolve('./csm/url_search.ts'));
loadTestFile(require.resolve('./csm/page_views.ts'));
});
});