mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
4b6d77fa5d
commit
9276a16db7
31 changed files with 966 additions and 50 deletions
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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' };
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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[];
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -18,6 +18,7 @@ export async function getPageViewTrends({
|
|||
}: {
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
breakdowns?: string;
|
||||
urlQuery?: string;
|
||||
}) {
|
||||
const projection = getRumPageLoadTransactionsProjection({
|
||||
setup,
|
||||
|
|
|
@ -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, {
|
||||
|
|
67
x-pack/plugins/apm/server/lib/rum_client/get_url_search.ts
Normal file
67
x-pack/plugins/apm/server/lib/rum_client/get_url_search.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
|
@ -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, {
|
||||
|
|
|
@ -22,8 +22,10 @@ import {
|
|||
|
||||
export async function getWebCoreVitals({
|
||||
setup,
|
||||
urlQuery,
|
||||
}: {
|
||||
setup: Setup & SetupTimeRange & SetupUIFilters;
|
||||
urlQuery?: string;
|
||||
}) {
|
||||
const projection = getRumPageLoadTransactionsProjection({
|
||||
setup,
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue