diff --git a/x-pack/plugins/apm/e2e/cypress/integration/rum_dashboard.feature b/x-pack/plugins/apm/e2e/cypress/integration/rum_dashboard.feature index eabfaf096731b..bc807d596a272 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/rum_dashboard.feature +++ b/x-pack/plugins/apm/e2e/cypress/integration/rum_dashboard.feature @@ -5,3 +5,35 @@ Feature: RUM Dashboard When the user inspects the real user monitoring tab Then should redirect to rum dashboard And should have correct client metrics + + Scenario Outline: Rum page filters + When the user filters by "" + Then it filters the client metrics + Examples: + | filterName | + | os | + | location | + + Scenario: Page load distribution percentiles + Given a user browses the APM UI application for RUM Data + When the user inspects the real user monitoring tab + Then should redirect to rum dashboard + And should display percentile for page load chart + + Scenario: Page load distribution chart tooltip + Given a user browses the APM UI application for RUM Data + When the user inspects the real user monitoring tab + Then should redirect to rum dashboard + And should display tooltip on hover + + Scenario: Page load distribution chart legends + Given a user browses the APM UI application for RUM Data + When the user inspects the real user monitoring tab + Then should redirect to rum dashboard + And should display chart legend + + Scenario: Breakdown filter + Given a user click page load breakdown filter + When the user selected the breakdown + Then breakdown series should appear in chart + diff --git a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js index dd96a57ef8c45..acccd86f3e4d7 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js @@ -9,9 +9,28 @@ module.exports = { }, "RUM Dashboard": { "Client metrics": { - "1": "62", + "1": "62 ", "2": "0.07 sec", "3": "0.01 sec" + }, + "Rum page filters (example #1)": { + "1": "15 ", + "2": "0.07 sec", + "3": "0.01 sec" + }, + "Rum page filters (example #2)": { + "1": "35 ", + "2": "0.07 sec", + "3": "0.01 sec" + }, + "Page load distribution percentiles": { + "1": "50th", + "2": "75th", + "3": "90th", + "4": "95th" + }, + "Page load distribution chart legends": { + "1": "Overall" } } } diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/page_load_dist.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/page_load_dist.ts new file mode 100644 index 0000000000000..809b22490abd6 --- /dev/null +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/page_load_dist.ts @@ -0,0 +1,31 @@ +/* + * 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 { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'; + +/** The default time in ms to wait for a Cypress command to complete */ +export const DEFAULT_TIMEOUT = 60 * 1000; + +Given(`a user click page load breakdown filter`, () => { + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.be.visible'); + const breakDownBtn = cy.get('[data-cy=breakdown-popover_pageLoad]'); + breakDownBtn.click(); +}); + +When(`the user selected the breakdown`, () => { + cy.get('[data-cy=filter-breakdown-item_Browser]').click(); + // click outside popover to close it + cy.get('[data-cy=pageLoadDist]').click(); +}); + +Then(`breakdown series should appear in chart`, () => { + cy.get('.euiLoadingChart').should('not.be.visible'); + cy.get('div.echLegendItem__label[title=Chrome] ') + .invoke('text') + .should('eq', 'Chrome'); +}); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts similarity index 50% rename from x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum_dashboard.ts rename to x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts index 38eadbf513032..24961ceb3b3c2 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts @@ -5,7 +5,7 @@ */ import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'; -import { loginAndWaitForPage } from '../../integration/helpers'; +import { loginAndWaitForPage } from '../../../integration/helpers'; /** The default time in ms to wait for a Cypress command to complete */ export const DEFAULT_TIMEOUT = 60 * 1000; @@ -41,3 +41,46 @@ Then(`should have correct client metrics`, () => { cy.get(clientMetrics).eq(0).invoke('text').snapshot(); }); + +Then(`should display percentile for page load chart`, () => { + const pMarkers = '[data-cy=percentile-markers] span'; + + cy.get('.euiLoadingChart').should('be.visible'); + + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.be.visible'); + + cy.get(pMarkers).eq(0).invoke('text').snapshot(); + + cy.get(pMarkers).eq(1).invoke('text').snapshot(); + + cy.get(pMarkers).eq(2).invoke('text').snapshot(); + + cy.get(pMarkers).eq(3).invoke('text').snapshot(); +}); + +Then(`should display chart legend`, () => { + const chartLegend = 'div.echLegendItem__label'; + + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiLoadingChart').should('not.be.visible'); + + cy.get(chartLegend).eq(0).invoke('text').snapshot(); +}); + +Then(`should display tooltip on hover`, () => { + cy.get('.euiLoadingChart').should('not.be.visible'); + + const pMarkers = '[data-cy=percentile-markers] span.euiToolTipAnchor'; + + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiLoadingChart').should('not.be.visible'); + + const marker = cy.get(pMarkers).eq(0); + marker.invoke('show'); + marker.trigger('mouseover', { force: true }); + cy.get('span[data-cy=percentileTooltipTitle]').should('be.visible'); +}); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_filters.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_filters.ts new file mode 100644 index 0000000000000..439003351aedb --- /dev/null +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_filters.ts @@ -0,0 +1,37 @@ +/* + * 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'; + +When(/^the user filters by "([^"]*)"$/, (filterName) => { + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.be.visible'); + cy.get(`#local-filter-${filterName}`).click(); + + if (filterName === 'os') { + cy.get('button.euiSelectableListItem[title="Mac OS X"]').click(); + } else { + cy.get('button.euiSelectableListItem[title="DE"]').click(); + } + cy.get('[data-cy=applyFilter]').click(); +}); + +Then(`it filters the client metrics`, () => { + const clientMetrics = '[data-cy=client-metrics] .euiStat__title'; + + // wait for all loading to finish + cy.get('kbnLoadingIndicator').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.be.visible'); + + cy.get(clientMetrics).eq(2).invoke('text').snapshot(); + + cy.get(clientMetrics).eq(1).invoke('text').snapshot(); + + cy.get(clientMetrics).eq(0).invoke('text').snapshot(); + + cy.get('[data-cy=clearFilters]').click(); +}); diff --git a/x-pack/plugins/apm/e2e/ingest-data/replay.js b/x-pack/plugins/apm/e2e/ingest-data/replay.js index 3478039f39b50..483cc99df7470 100644 --- a/x-pack/plugins/apm/e2e/ingest-data/replay.js +++ b/x-pack/plugins/apm/e2e/ingest-data/replay.js @@ -35,6 +35,8 @@ const pLimit = require('p-limit'); const pRetry = require('p-retry'); const { argv } = require('yargs'); const ora = require('ora'); +const userAgents = require('./user_agents'); +const userIps = require('./rum_ips'); const APM_SERVER_URL = argv.serverUrl; const SECRET_TOKEN = argv.secretToken; @@ -66,7 +68,7 @@ function incrementSpinnerCount({ success }) { spinner.text = `Remaining: ${remaining}. Succeeded: ${requestProgress.succeeded}. Failed: ${requestProgress.failed}.`; } - +let iterIndex = 0; async function insertItem(item) { try { const url = `${APM_SERVER_URL}${item.url}`; @@ -74,6 +76,15 @@ async function insertItem(item) { 'content-type': 'application/x-ndjson', }; + if (item.url === '/intake/v2/rum/events') { + if (iterIndex === userAgents.length) { + iterIndex = 0; + } + headers['User-Agent'] = userAgents[iterIndex]; + headers['X-Forwarded-For'] = userIps[iterIndex]; + iterIndex++; + } + if (SECRET_TOKEN) { headers.Authorization = `Bearer ${SECRET_TOKEN}`; } @@ -113,7 +124,9 @@ async function init() { items.map(async (item) => { try { // retry 5 times with exponential backoff - await pRetry(() => limit(() => insertItem(item)), { retries: 5 }); + await pRetry(() => limit(() => insertItem(item)), { + retries: 5, + }); incrementSpinnerCount({ success: true }); } catch (e) { incrementSpinnerCount({ success: false }); diff --git a/x-pack/plugins/apm/e2e/ingest-data/rum_ips.js b/x-pack/plugins/apm/e2e/ingest-data/rum_ips.js new file mode 100644 index 0000000000000..59152cd90701b --- /dev/null +++ b/x-pack/plugins/apm/e2e/ingest-data/rum_ips.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const IPS = [ + '89.191.86.214', // check24.de + '167.40.79.24', // canada.ca + '151.101.130.217', // elastic.co + '185.143.68.17', + '151.101.130.217', + '185.143.68.17', + '185.143.68.17', + '151.101.130.217', + '185.143.68.17', +]; + +module.exports = IPS; diff --git a/x-pack/plugins/apm/e2e/ingest-data/user_agents.js b/x-pack/plugins/apm/e2e/ingest-data/user_agents.js new file mode 100644 index 0000000000000..923726c4736b6 --- /dev/null +++ b/x-pack/plugins/apm/e2e/ingest-data/user_agents.js @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/* eslint-disable no-console */ + +/* eslint-disable import/no-extraneous-dependencies */ + +const UserAgents = [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36', + 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/69.0.3497.105 Mobile/15E148 Safari/605.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36', + 'Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1', + 'Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36', +]; + +module.exports = UserAgents; diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx index c325a72375359..69699b72a96df 100644 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Home/index.tsx @@ -27,7 +27,7 @@ import { ServiceOverview } from '../ServiceOverview'; import { TraceOverview } from '../TraceOverview'; import { RumOverview } from '../RumDashboard'; import { RumOverviewLink } from '../../shared/Links/apm/RumOverviewLink'; -import { EndUserExperienceLabel } from '../RumDashboard/translations'; +import { I18LABELS } from '../RumDashboard/translations'; function getHomeTabs({ serviceMapEnabled = true, @@ -111,7 +111,7 @@ export function Home({ tab }: Props) {

{selectedTab.name === 'rum-overview' - ? EndUserExperienceLabel + ? I18LABELS.endUserExperience : 'APM'}

diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx new file mode 100644 index 0000000000000..332cf40a465f9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx @@ -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 from 'react'; +import { BreakdownGroup } from './BreakdownGroup'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; +import { + CLIENT_GEO_COUNTRY_ISO_CODE, + USER_AGENT_DEVICE, + USER_AGENT_NAME, + USER_AGENT_OS, +} from '../../../../../common/elasticsearch_fieldnames'; + +interface Props { + id: string; + selectedBreakdowns: BreakdownItem[]; + onBreakdownChange: (values: BreakdownItem[]) => void; +} + +export const BreakdownFilter = ({ + id, + selectedBreakdowns, + onBreakdownChange, +}: Props) => { + const categories: BreakdownItem[] = [ + { + name: 'Browser', + type: 'category', + count: 0, + selected: selectedBreakdowns.some(({ name }) => name === 'Browser'), + fieldName: USER_AGENT_NAME, + }, + { + name: 'OS', + type: 'category', + count: 0, + selected: selectedBreakdowns.some(({ name }) => name === 'OS'), + fieldName: USER_AGENT_OS, + }, + { + name: 'Device', + type: 'category', + count: 0, + selected: selectedBreakdowns.some(({ name }) => name === 'Device'), + fieldName: USER_AGENT_DEVICE, + }, + { + name: 'Location', + type: 'category', + count: 0, + selected: selectedBreakdowns.some(({ name }) => name === 'Location'), + fieldName: CLIENT_GEO_COUNTRY_ISO_CODE, + }, + ]; + + return ( + { + onBreakdownChange(selValues); + }} + /> + ); +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx new file mode 100644 index 0000000000000..007cdab0d2078 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx @@ -0,0 +1,99 @@ +/* + * 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 { + EuiPopover, + EuiFilterButton, + EuiFilterGroup, + EuiPopoverTitle, + EuiFilterSelectItem, +} from '@elastic/eui'; +import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; +import { I18LABELS } from '../translations'; + +export interface BreakdownGroupProps { + id: string; + disabled?: boolean; + items: BreakdownItem[]; + onChange: (values: BreakdownItem[]) => void; +} + +export const BreakdownGroup = ({ + id, + disabled, + onChange, + items, +}: BreakdownGroupProps) => { + const [isOpen, setIsOpen] = useState(false); + + const [activeItems, setActiveItems] = useState(items); + + useEffect(() => { + setActiveItems(items); + }, [items]); + + const getSelItems = () => activeItems.filter((item) => item.selected); + + const onFilterItemClick = useCallback( + (name: string) => (_event: MouseEvent) => { + setActiveItems((prevItems) => + prevItems.map((item) => ({ + ...item, + selected: name === item.name ? !item.selected : item.selected, + })) + ); + }, + [] + ); + + return ( + + 0} + numFilters={activeItems.length} + numActiveFilters={getSelItems().length} + hasActiveFilters={getSelItems().length !== 0} + iconType="arrowDown" + onClick={() => { + setIsOpen(!isOpen); + }} + size="s" + > + {I18LABELS.breakdown} + + } + closePopover={() => { + setIsOpen(false); + onChange(getSelItems()); + }} + data-cy={`breakdown-popover_${id}`} + id={id} + isOpen={isOpen} + ownFocus={true} + withTitle + zIndex={10000} + > + {I18LABELS.selectBreakdown} +
+ {activeItems.map(({ name, count, selected }) => ( + + {name} + + ))} +
+
+
+ ); +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx new file mode 100644 index 0000000000000..e17a8046b5c6a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -0,0 +1,139 @@ +/* + * 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, { useState } from 'react'; +import numeral from '@elastic/numeral'; +import { + Axis, + BrushEndListener, + Chart, + CurveType, + LineSeries, + ScaleType, + Settings, + TooltipValue, + TooltipValueFormatter, + DARK_THEME, + LIGHT_THEME, +} from '@elastic/charts'; +import { + EUI_CHARTS_THEME_DARK, + EUI_CHARTS_THEME_LIGHT, +} from '@elastic/eui/dist/eui_charts_theme'; +import { Position } from '@elastic/charts/dist/utils/commons'; +import styled from 'styled-components'; +import { PercentileAnnotations } from '../PageLoadDistribution/PercentileAnnotations'; +import { I18LABELS } from '../translations'; +import { ChartWrapper } from '../ChartWrapper'; +import { PercentileRange } from '../PageLoadDistribution'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; +import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; +import { BreakdownSeries } from '../PageLoadDistribution/BreakdownSeries'; + +interface PageLoadData { + pageLoadDistribution: Array<{ x: number; y: number }>; + percentiles: Record | undefined; + minDuration: number; + maxDuration: number; +} + +interface Props { + onPercentileChange: (min: number, max: number) => void; + data?: PageLoadData | null; + breakdowns: BreakdownItem[]; + percentileRange: PercentileRange; + loading: boolean; +} + +const PageLoadChart = styled(Chart)` + .echAnnotation { + pointer-events: initial; + } +`; + +export function PageLoadDistChart({ + onPercentileChange, + data, + breakdowns, + loading, + percentileRange, +}: Props) { + const [breakdownLoading, setBreakdownLoading] = useState(false); + const onBrushEnd: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [minX, maxX] = x; + onPercentileChange(minX, maxX); + }; + + const headerFormatter: TooltipValueFormatter = (tooltip: TooltipValue) => { + return ( +
+

+ {tooltip.value} {I18LABELS.seconds} +

+
+ ); + }; + + const tooltipProps = { + headerFormatter, + }; + + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + + {(!loading || data) && ( + + + + + numeral(d).format('0.0') + '%'} + /> + + {breakdowns.map(({ name, type }) => ( + { + setBreakdownLoading(bLoading); + }} + /> + ))} + + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx new file mode 100644 index 0000000000000..934a985dd735a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx @@ -0,0 +1,120 @@ +/* + * 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 numeral from '@elastic/numeral'; +import { + Axis, + BarSeries, + BrushEndListener, + Chart, + niceTimeFormatByDay, + ScaleType, + SeriesNameFn, + Settings, + timeFormatter, +} from '@elastic/charts'; +import { DARK_THEME, LIGHT_THEME } from '@elastic/charts'; + +import { + EUI_CHARTS_THEME_DARK, + EUI_CHARTS_THEME_LIGHT, +} from '@elastic/eui/dist/eui_charts_theme'; +import moment from 'moment'; +import { Position } from '@elastic/charts/dist/utils/commons'; +import { I18LABELS } from '../translations'; +import { history } from '../../../../utils/history'; +import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; +import { ChartWrapper } from '../ChartWrapper'; +import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; + +interface Props { + data?: Array>; + loading: boolean; +} + +export function PageViewsChart({ data, loading }: Props) { + const formatter = timeFormatter(niceTimeFormatByDay(2)); + + const onBrushEnd: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [minX, maxX] = x; + + const rangeFrom = moment(minX).toISOString(); + const rangeTo = moment(maxX).toISOString(); + + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + rangeFrom, + rangeTo, + }), + }); + }; + + let breakdownAccessors: Set = new Set(); + if (data && data.length > 0) { + data.forEach((item) => { + breakdownAccessors = new Set([ + ...Array.from(breakdownAccessors), + ...Object.keys(item).filter((key) => key !== 'x'), + ]); + }); + } + + const customSeriesNaming: SeriesNameFn = ({ yAccessor }) => { + if (yAccessor === 'y') { + return I18LABELS.overall; + } + + return yAccessor; + }; + + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + + {(!loading || data) && ( + + + + numeral(d).format('0.0 a')} + /> + + + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx index 8c0a7c6a91f67..776f74a169966 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx @@ -3,24 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -// @flow import * as React from 'react'; +import numeral from '@elastic/numeral'; import styled from 'styled-components'; -import { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiToolTip } from '@elastic/eui'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { BackEndLabel, FrontEndLabel, PageViewsLabel } from '../translations'; - -export const formatBigValue = (val?: number | null, fixed?: number): string => { - if (val && val >= 1000) { - const result = val / 1000; - if (fixed) { - return result.toFixed(fixed) + 'k'; - } - return result + 'k'; - } - return val + ''; -}; +import { I18LABELS } from '../translations'; const ClFlexGroup = styled(EuiFlexGroup)` flex-direction: row; @@ -30,7 +19,7 @@ const ClFlexGroup = styled(EuiFlexGroup)` } `; -export const ClientMetrics = () => { +export function ClientMetrics() { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; @@ -57,7 +46,7 @@ export const ClientMetrics = () => { @@ -65,18 +54,22 @@ export const ClientMetrics = () => { + <>{numeral(data?.pageViews?.value).format('0 a') ?? '-'} + + } + description={I18LABELS.pageViews} isLoading={status !== 'success'} /> ); -}; +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx new file mode 100644 index 0000000000000..0c47ad24128ef --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx @@ -0,0 +1,50 @@ +/* + * 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, { FC, useEffect } from 'react'; +import { CurveType, LineSeries, ScaleType } from '@elastic/charts'; +import { PercentileRange } from './index'; +import { useBreakdowns } from './use_breakdowns'; + +interface Props { + field: string; + value: string; + percentileRange: PercentileRange; + onLoadingChange: (loading: boolean) => void; +} + +export const BreakdownSeries: FC = ({ + field, + value, + percentileRange, + onLoadingChange, +}) => { + const { data, status } = useBreakdowns({ + field, + value, + percentileRange, + }); + + useEffect(() => { + onLoadingChange(status !== 'success'); + }, [status, onLoadingChange]); + + return ( + <> + {data?.map(({ data: seriesData, name }) => ( + + ))} + + ); +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx index 9c89b8bc161b7..38d53aebd1b7f 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/PercentileAnnotations.tsx @@ -13,6 +13,7 @@ import { } from '@elastic/charts'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import styled from 'styled-components'; +import { EuiToolTip } from '@elastic/eui'; interface Props { percentiles?: Record; @@ -21,15 +22,15 @@ interface Props { function generateAnnotationData( values?: Record ): LineAnnotationDatum[] { - return Object.entries(values ?? {}).map((value, index) => ({ - dataValue: value[1], + return Object.entries(values ?? {}).map((value) => ({ + dataValue: Math.round(value[1] / 1000), details: `${(+value[0]).toFixed(0)}`, })); } const PercentileMarker = styled.span` position: relative; - bottom: 140px; + bottom: 205px; `; export const PercentileAnnotations = ({ percentiles }: Props) => { @@ -43,6 +44,18 @@ export const PercentileAnnotations = ({ percentiles }: Props) => { }, }; + const PercentileTooltip = ({ + annotation, + }: { + annotation: LineAnnotationDatum; + }) => { + return ( + + {annotation.details}th Percentile + + ); + }; + return ( <> {dataValues.map((annotation, index) => ( @@ -52,7 +65,19 @@ export const PercentileAnnotations = ({ percentiles }: Props) => { domainType={AnnotationDomainTypes.XDomain} dataValues={[annotation]} style={style} - marker={{annotation.details}th} + hideTooltips={true} + marker={ + + } + content={ + Pages loaded: {Math.round(annotation.dataValue)} + } + > + <>{annotation.details}th + + + } /> ))} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index c7a0b64f6a8b8..c6b34c8b76698 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -6,48 +6,36 @@ import React, { useState } from 'react'; import { - EuiButton, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { - Axis, - Chart, - ScaleType, - LineSeries, - CurveType, - BrushEndListener, - Settings, - TooltipValueFormatter, - TooltipValue, -} from '@elastic/charts'; -import { Position } from '@elastic/charts/dist/utils/commons'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { useFetcher } from '../../../../hooks/useFetcher'; -import { ChartWrapper } from '../ChartWrapper'; -import { PercentileAnnotations } from './PercentileAnnotations'; -import { - PageLoadDistLabel, - PageLoadTimeLabel, - PercPageLoadedLabel, - ResetZoomLabel, -} from '../translations'; +import { I18LABELS } from '../translations'; +import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; +import { PageLoadDistChart } from '../Charts/PageLoadDistChart'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; + +export interface PercentileRange { + min?: number | null; + max?: number | null; +} export const PageLoadDistribution = () => { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; - const [percentileRange, setPercentileRange] = useState<{ - min: string | null; - max: string | null; - }>({ + const [percentileRange, setPercentileRange] = useState({ min: null, max: null, }); + const [breakdowns, setBreakdowns] = useState([]); + const { data, status } = useFetcher( (callApmApi) => { if (start && end) { @@ -60,8 +48,8 @@ export const PageLoadDistribution = () => { uiFilters: JSON.stringify(uiFilters), ...(percentileRange.min && percentileRange.max ? { - minPercentile: percentileRange.min, - maxPercentile: percentileRange.max, + minPercentile: String(percentileRange.min), + maxPercentile: String(percentileRange.max), } : {}), }, @@ -72,73 +60,51 @@ export const PageLoadDistribution = () => { [end, start, uiFilters, percentileRange.min, percentileRange.max] ); - const onBrushEnd: BrushEndListener = ({ x }) => { - if (!x) { - return; - } - const [minX, maxX] = x; - setPercentileRange({ min: String(minX), max: String(maxX) }); - }; - - const headerFormatter: TooltipValueFormatter = (tooltip: TooltipValue) => { - return ( -
-

{tooltip.value} seconds

-
- ); - }; - - const tooltipProps = { - headerFormatter, + const onPercentileChange = (min: number, max: number) => { + setPercentileRange({ min: min * 1000, max: max * 1000 }); }; return ( -
+
-

{PageLoadDistLabel}

+

{I18LABELS.pageLoadDistribution}

- { setPercentileRange({ min: null, max: null }); }} - fill={percentileRange.min !== null && percentileRange.max !== null} + disabled={ + percentileRange.min === null && percentileRange.max === null + } > - {ResetZoomLabel} - + {I18LABELS.resetZoom} + -
- - - - - - - Number(d).toFixed(1) + ' %'} - /> - + - - + + + +
); }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts new file mode 100644 index 0000000000000..814cf977c9569 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts @@ -0,0 +1,48 @@ +/* + * 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 { useFetcher } from '../../../../hooks/useFetcher'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { PercentileRange } from './index'; + +interface Props { + percentileRange?: PercentileRange; + field: string; + value: string; +} + +export const useBreakdowns = ({ percentileRange, field, value }: Props) => { + const { urlParams, uiFilters } = useUrlParams(); + + const { start, end } = urlParams; + + const { min: minP, max: maxP } = percentileRange ?? {}; + + return useFetcher( + (callApmApi) => { + if (start && end && field && value) { + return callApmApi({ + pathname: '/api/apm/rum-client/page-load-distribution/breakdown', + params: { + query: { + start, + end, + breakdown: value, + uiFilters: JSON.stringify(uiFilters), + ...(minP && maxP + ? { + minPercentile: String(minP), + maxPercentile: String(maxP), + } + : {}), + }, + }, + }); + } + }, + [end, start, uiFilters, field, value, minP, maxP] + ); +}; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx index cc41bd4352947..34347f3f95947 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx @@ -4,34 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import { EuiTitle } from '@elastic/eui'; -import { - Axis, - BarSeries, - BrushEndListener, - Chart, - niceTimeFormatByDay, - ScaleType, - Settings, - timeFormatter, -} from '@elastic/charts'; -import moment from 'moment'; -import { Position } from '@elastic/charts/dist/utils/commons'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React, { useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { useFetcher } from '../../../../hooks/useFetcher'; -import { ChartWrapper } from '../ChartWrapper'; -import { DateTimeLabel, PageViewsLabel } from '../translations'; -import { history } from '../../../../utils/history'; -import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; -import { formatBigValue } from '../ClientMetrics'; +import { I18LABELS } from '../translations'; +import { BreakdownFilter } from '../Breakdowns/BreakdownFilter'; +import { PageViewsChart } from '../Charts/PageViewsChart'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; export const PageViewsTrend = () => { const { urlParams, uiFilters } = useUrlParams(); const { start, end } = urlParams; + const [breakdowns, setBreakdowns] = useState([]); + const { data, status } = useFetcher( (callApmApi) => { if (start && end) { @@ -42,70 +30,41 @@ export const PageViewsTrend = () => { start, end, uiFilters: JSON.stringify(uiFilters), + ...(breakdowns.length > 0 + ? { + breakdowns: JSON.stringify(breakdowns), + } + : {}), }, }, }); } }, - [end, start, uiFilters] + [end, start, uiFilters, breakdowns] ); - const formatter = timeFormatter(niceTimeFormatByDay(2)); - - const onBrushEnd: BrushEndListener = ({ x }) => { - if (!x) { - return; - } - const [minX, maxX] = x; - - const rangeFrom = moment(minX).toISOString(); - const rangeTo = moment(maxX).toISOString(); - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - rangeFrom, - rangeTo, - }), - }); + const onBreakdownChange = (values: BreakdownItem[]) => { + setBreakdowns(values); }; return (
- -

{PageViewsLabel}

-
- - - - - formatBigValue(Number(d))} - /> - + + +

{I18LABELS.pageViews}

+
+
+ + -
-
+ + + +
); }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx index e3fa7374afb38..cd50f3b575113 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { ClientMetrics } from './ClientMetrics'; import { PageViewsTrend } from './PageViewsTrend'; import { PageLoadDistribution } from './PageLoadDistribution'; -import { getWhatIsGoingOnLabel } from './translations'; +import { I18LABELS } from './translations'; import { useUrlParams } from '../../../hooks/useUrlParams'; export function RumDashboard() { @@ -32,7 +32,7 @@ export function RumDashboard() { return ( <> -

{getWhatIsGoingOnLabel(environmentLabel)}

+

{I18LABELS.getWhatIsGoingOn(environmentLabel)}

@@ -41,7 +41,7 @@ export function RumDashboard() { -

Page load times

+

{I18LABELS.pageLoadTimes}

diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index c2aed41a55c7d..4da7b59ec7fa5 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -6,68 +6,55 @@ import { i18n } from '@kbn/i18n'; -export const EndUserExperienceLabel = i18n.translate( - 'xpack.apm.rum.dashboard.title', - { +export const I18LABELS = { + endUserExperience: i18n.translate('xpack.apm.rum.dashboard.title', { defaultMessage: 'End User Experience', - } -); - -export const getWhatIsGoingOnLabel = (environmentVal: string) => - i18n.translate('xpack.apm.rum.dashboard.environment.title', { - defaultMessage: `What's going on in {environmentVal}?`, - values: { environmentVal }, - }); - -export const BackEndLabel = i18n.translate('xpack.apm.rum.dashboard.backend', { - defaultMessage: 'Backend', -}); - -export const FrontEndLabel = i18n.translate( - 'xpack.apm.rum.dashboard.frontend', - { + }), + getWhatIsGoingOn: (environmentVal: string) => + i18n.translate('xpack.apm.rum.dashboard.environment.title', { + defaultMessage: `What's going on in {environmentVal}?`, + values: { environmentVal }, + }), + backEnd: i18n.translate('xpack.apm.rum.dashboard.backend', { + defaultMessage: 'Backend', + }), + frontEnd: i18n.translate('xpack.apm.rum.dashboard.frontend', { defaultMessage: 'Frontend', - } -); - -export const PageViewsLabel = i18n.translate( - 'xpack.apm.rum.dashboard.pageViews', - { + }), + pageViews: i18n.translate('xpack.apm.rum.dashboard.pageViews', { defaultMessage: 'Page views', - } -); - -export const DateTimeLabel = i18n.translate( - 'xpack.apm.rum.dashboard.dateTime.label', - { + }), + dateTime: i18n.translate('xpack.apm.rum.dashboard.dateTime.label', { defaultMessage: 'Date / Time', - } -); - -export const PercPageLoadedLabel = i18n.translate( - 'xpack.apm.rum.dashboard.pagesLoaded.label', - { + }), + percPageLoaded: i18n.translate('xpack.apm.rum.dashboard.pagesLoaded.label', { defaultMessage: 'Pages loaded', - } -); - -export const PageLoadTimeLabel = i18n.translate( - 'xpack.apm.rum.dashboard.pageLoadTime.label', - { + }), + pageLoadTime: i18n.translate('xpack.apm.rum.dashboard.pageLoadTime.label', { defaultMessage: 'Page load time (seconds)', - } -); - -export const PageLoadDistLabel = i18n.translate( - 'xpack.apm.rum.dashboard.pageLoadDistribution.label', - { - defaultMessage: 'Page load distribution', - } -); - -export const ResetZoomLabel = i18n.translate( - 'xpack.apm.rum.dashboard.resetZoom.label', - { + }), + pageLoadTimes: i18n.translate('xpack.apm.rum.dashboard.pageLoadTimes.label', { + defaultMessage: 'Page load times', + }), + pageLoadDistribution: i18n.translate( + 'xpack.apm.rum.dashboard.pageLoadDistribution.label', + { + defaultMessage: 'Page load distribution', + } + ), + resetZoom: i18n.translate('xpack.apm.rum.dashboard.resetZoom.label', { defaultMessage: 'Reset zoom', - } -); + }), + overall: i18n.translate('xpack.apm.rum.dashboard.overall.label', { + defaultMessage: 'Overall', + }), + selectBreakdown: i18n.translate('xpack.apm.rum.filterGroup.selectBreakdown', { + defaultMessage: 'Select breakdown', + }), + breakdown: i18n.translate('xpack.apm.rum.filterGroup.breakdown', { + defaultMessage: 'Breakdown', + }), + seconds: i18n.translate('xpack.apm.rum.filterGroup.seconds', { + defaultMessage: 'seconds', + }), +}; diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx index 46fd5d925699b..167574f9aa00d 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx @@ -140,6 +140,7 @@ const Filter = ({ { diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx index 4aae82518ab3c..020b7481c68ea 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx @@ -80,6 +80,7 @@ const LocalUIFilters = ({ iconType="cross" flush="left" onClick={clearValues} + data-cy="clearFilters" > {i18n.translate('xpack.apm.clearFilters', { defaultMessage: 'Clear filters', diff --git a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap index 7d8f31aaeca7f..c006d01637483 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap @@ -67,13 +67,7 @@ exports[`rum client dashboard queries fetches page load distribution 1`] = ` Object { "body": Object { "aggs": Object { - "durationMinMax": Object { - "min": Object { - "field": "transaction.duration.us", - "missing": 0, - }, - }, - "durationPercentiles": Object { + "durPercentiles": Object { "percentiles": Object { "field": "transaction.duration.us", "percents": Array [ @@ -83,13 +77,12 @@ Object { 95, 99, ], - "script": Object { - "lang": "painless", - "params": Object { - "timeUnit": 1000, - }, - "source": "doc['transaction.duration.us'].value / params.timeUnit", - }, + }, + }, + "minDuration": Object { + "min": Object { + "field": "transaction.duration.us", + "missing": 0, }, }, }, @@ -139,13 +132,7 @@ Object { "body": Object { "aggs": Object { "pageViews": Object { - "aggs": Object { - "trans_count": Object { - "value_count": Object { - "field": "transaction.type", - }, - }, - }, + "aggs": Object {}, "auto_date_histogram": Object { "buckets": 50, "field": "@timestamp", diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts index 3c563946e4052..43af18999547d 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts @@ -32,23 +32,16 @@ export async function getPageLoadDistribution({ bool: projection.body.query.bool, }, aggs: { - durationMinMax: { + minDuration: { min: { field: 'transaction.duration.us', missing: 0, }, }, - durationPercentiles: { + durPercentiles: { percentiles: { field: 'transaction.duration.us', percents: [50, 75, 90, 95, 99], - script: { - lang: 'painless', - source: "doc['transaction.duration.us'].value / params.timeUnit", - params: { - timeUnit: 1000, - }, - }, }, }, }, @@ -66,31 +59,32 @@ export async function getPageLoadDistribution({ return null; } - const minDuration = (aggregations?.durationMinMax.value ?? 0) / 1000; + const minDuration = aggregations?.minDuration.value ?? 0; const minPerc = minPercentile ? +minPercentile : minDuration; - const maxPercentileQuery = - aggregations?.durationPercentiles.values['99.0'] ?? 100; + const maxPercQuery = aggregations?.durPercentiles.values['99.0'] ?? 10000; - const maxPerc = maxPercentile ? +maxPercentile : maxPercentileQuery; + const maxPerc = maxPercentile ? +maxPercentile : maxPercQuery; const pageDist = await getPercentilesDistribution(setup, minPerc, maxPerc); return { pageLoadDistribution: pageDist, - percentiles: aggregations?.durationPercentiles.values, + percentiles: aggregations?.durPercentiles.values, + minDuration: minPerc, + maxDuration: maxPerc, }; } const getPercentilesDistribution = async ( setup: Setup & SetupTimeRange & SetupUIFilters, - minPercentiles: number, - maxPercentile: number + minDuration: number, + maxDuration: number ) => { - const stepValue = (maxPercentile - minPercentiles) / 50; + const stepValue = (maxDuration - minDuration) / 50; const stepValues = []; - for (let i = 1; i < 50; i++) { - stepValues.push((stepValue * i + minPercentiles).toFixed(2)); + for (let i = 1; i < 51; i++) { + stepValues.push((stepValue * i + minDuration).toFixed(2)); } const projection = getRumOverviewProjection({ @@ -109,13 +103,6 @@ const getPercentilesDistribution = async ( field: 'transaction.duration.us', values: stepValues, keyed: false, - script: { - lang: 'painless', - source: "doc['transaction.duration.us'].value / params.timeUnit", - params: { - timeUnit: 1000, - }, - }, }, }, }, @@ -126,14 +113,11 @@ const getPercentilesDistribution = async ( const { aggregations } = await client.search(params); - const pageDist = (aggregations?.loadDistribution.values ?? []) as Array<{ - key: number; - value: number; - }>; + const pageDist = aggregations?.loadDistribution.values ?? []; return pageDist.map(({ key, value }, index: number, arr) => { return { - x: key, + x: Math.round(key / 1000), y: index === 0 ? value : value - arr[index - 1].value, }; }); diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts index 126605206d299..30b2677d3c217 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts @@ -11,15 +11,32 @@ import { SetupTimeRange, SetupUIFilters, } from '../helpers/setup_request'; +import { AggregationInputMap } from '../../../typings/elasticsearch/aggregations'; +import { BreakdownItem } from '../../../typings/ui_filters'; export async function getPageViewTrends({ setup, + breakdowns, }: { setup: Setup & SetupTimeRange & SetupUIFilters; + breakdowns?: string; }) { const projection = getRumOverviewProjection({ setup, }); + const breakdownAggs: AggregationInputMap = {}; + if (breakdowns) { + const breakdownList: BreakdownItem[] = JSON.parse(breakdowns); + breakdownList.forEach(({ name, type, fieldName }) => { + breakdownAggs[name] = { + terms: { + field: fieldName, + size: 9, + missing: 'Other', + }, + }; + }); + } const params = mergeProjection(projection, { body: { @@ -33,13 +50,7 @@ export async function getPageViewTrends({ field: '@timestamp', buckets: 50, }, - aggs: { - trans_count: { - value_count: { - field: 'transaction.type', - }, - }, - }, + aggs: breakdownAggs, }, }, }, @@ -50,8 +61,27 @@ export async function getPageViewTrends({ const response = await client.search(params); const result = response.aggregations?.pageViews.buckets ?? []; - return result.map(({ key, trans_count }) => ({ - x: key, - y: trans_count.value, - })); + + return result.map((bucket) => { + const { key: xVal, doc_count: bCount } = bucket; + const res: Record = { + x: xVal, + y: bCount, + }; + + Object.keys(breakdownAggs).forEach((bKey) => { + const categoryBuckets = (bucket[bKey] as any).buckets; + categoryBuckets.forEach( + ({ key, doc_count: docCount }: { key: string; doc_count: number }) => { + if (key === 'Other') { + res[key + `(${bKey})`] = docCount; + } else { + res[key] = docCount; + } + } + ); + }); + + return res; + }); } diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts b/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts new file mode 100644 index 0000000000000..5ae6bd1540f7c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts @@ -0,0 +1,95 @@ +/* + * 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 { getRumOverviewProjection } from '../../../common/projections/rum_overview'; +import { mergeProjection } from '../../../common/projections/util/merge_projection'; +import { + Setup, + SetupTimeRange, + SetupUIFilters, +} from '../helpers/setup_request'; +import { + CLIENT_GEO_COUNTRY_ISO_CODE, + USER_AGENT_DEVICE, + USER_AGENT_NAME, + USER_AGENT_OS, +} from '../../../common/elasticsearch_fieldnames'; + +export const getBreakdownField = (breakdown: string) => { + switch (breakdown) { + case 'Location': + return CLIENT_GEO_COUNTRY_ISO_CODE; + case 'Device': + return USER_AGENT_DEVICE; + case 'OS': + return USER_AGENT_OS; + case 'Browser': + default: + return USER_AGENT_NAME; + } +}; + +export const getPageLoadDistBreakdown = async ( + setup: Setup & SetupTimeRange & SetupUIFilters, + minDuration: number, + maxDuration: number, + breakdown: string +) => { + const stepValue = (maxDuration - minDuration) / 50; + const stepValues = []; + + for (let i = 1; i < 51; i++) { + stepValues.push((stepValue * i + minDuration).toFixed(2)); + } + + const projection = getRumOverviewProjection({ + setup, + }); + + const params = mergeProjection(projection, { + body: { + size: 0, + query: { + bool: projection.body.query.bool, + }, + aggs: { + breakdowns: { + terms: { + field: getBreakdownField(breakdown), + size: 9, + }, + aggs: { + page_dist: { + percentile_ranks: { + field: 'transaction.duration.us', + values: stepValues, + keyed: false, + }, + }, + }, + }, + }, + }, + }); + + const { client } = setup; + + const { aggregations } = await client.search(params); + + const pageDistBreakdowns = aggregations?.breakdowns.buckets; + + return pageDistBreakdowns?.map(({ key, page_dist: pageDist }) => { + return { + name: String(key), + data: pageDist.values?.map(({ key: pKey, value }, index: number, arr) => { + return { + x: Math.round(pKey / 1000), + y: index === 0 ? value : value - arr[index - 1].value, + }; + }), + }; + }); +}; diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts index 25a559cb07a3d..7a3d9d94dec8e 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts @@ -14,8 +14,8 @@ import { TRANSACTION_URL, USER_AGENT_NAME, USER_AGENT_DEVICE, - CLIENT_GEO, USER_AGENT_OS, + CLIENT_GEO_COUNTRY_ISO_CODE, } from '../../../../common/elasticsearch_fieldnames'; const filtersByName = { @@ -77,7 +77,7 @@ const filtersByName = { title: i18n.translate('xpack.apm.localFilters.titles.location', { defaultMessage: 'Location', }), - fieldName: CLIENT_GEO, + fieldName: CLIENT_GEO_COUNTRY_ISO_CODE, }, os: { title: i18n.translate('xpack.apm.localFilters.titles.os', { diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 02be2e7e4dcdf..ed1c045616a27 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -75,6 +75,7 @@ import { rumClientMetricsRoute, rumPageViewsTrendRoute, rumPageLoadDistributionRoute, + rumPageLoadDistBreakdownRoute, } from './rum_client'; import { observabilityDashboardHasDataRoute, @@ -164,6 +165,7 @@ const createApmApi = () => { .add(rumOverviewLocalFiltersRoute) .add(rumPageViewsTrendRoute) .add(rumPageLoadDistributionRoute) + .add(rumPageLoadDistBreakdownRoute) .add(rumClientMetricsRoute) // Observability dashboard diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index 9b5f6529b1783..75651f646a50d 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -11,6 +11,7 @@ import { getClientMetrics } from '../lib/rum_client/get_client_metrics'; import { rangeRt, uiFiltersRt } from './default_api_types'; import { getPageViewTrends } from '../lib/rum_client/get_page_view_trends'; import { getPageLoadDistribution } from '../lib/rum_client/get_page_load_distribution'; +import { getPageLoadDistBreakdown } from '../lib/rum_client/get_pl_dist_breakdown'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -45,13 +46,48 @@ export const rumPageLoadDistributionRoute = createRoute(() => ({ }, })); +export const rumPageLoadDistBreakdownRoute = createRoute(() => ({ + path: '/api/apm/rum-client/page-load-distribution/breakdown', + params: { + query: t.intersection([ + uiFiltersRt, + rangeRt, + percentileRangeRt, + t.type({ breakdown: t.string }), + ]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const { + query: { minPercentile, maxPercentile, breakdown }, + } = context.params; + + return getPageLoadDistBreakdown( + setup, + Number(minPercentile), + Number(maxPercentile), + breakdown + ); + }, +})); + export const rumPageViewsTrendRoute = createRoute(() => ({ path: '/api/apm/rum-client/page-view-trends', params: { - query: t.intersection([uiFiltersRt, rangeRt]), + query: t.intersection([ + uiFiltersRt, + rangeRt, + t.partial({ breakdowns: t.string }), + ]), }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return getPageViewTrends({ setup }); + + const { + query: { breakdowns }, + } = context.params; + + return getPageViewTrends({ setup, breakdowns }); }, })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 6ee26caa4ef7c..a340aa24aebfb 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -35,6 +35,11 @@ type MetricsAggregationOptions = interface MetricsAggregationResponsePart { value: number | null; } +interface DateHistogramBucket { + doc_count: number; + key: number; + key_as_string: string; +} type GetCompositeKeys< TAggregationOptionsMap extends AggregationOptionsMap @@ -204,14 +209,8 @@ interface AggregationResponsePart< }; date_histogram: { buckets: Array< - { - doc_count: number; - key: number; - key_as_string: string; - } & BucketSubAggregationResponse< - TAggregationOptionsMap['aggs'], - TDocument - > + DateHistogramBucket & + BucketSubAggregationResponse >; }; avg: MetricsAggregationResponsePart; @@ -312,20 +311,18 @@ interface AggregationResponsePart< }; auto_date_histogram: { buckets: Array< - { - doc_count: number; - key: number; - key_as_string: string; - } & BucketSubAggregationResponse< - TAggregationOptionsMap['aggs'], - TDocument - > + DateHistogramBucket & + AggregationResponseMap >; interval: string; }; percentile_ranks: { - values: Record | Array<{ key: number; value: number }>; + values: TAggregationOptionsMap extends { + percentile_ranks: { keyed: false }; + } + ? Array<{ key: number; value: number }> + : Record; }; } diff --git a/x-pack/plugins/apm/typings/ui_filters.ts b/x-pack/plugins/apm/typings/ui_filters.ts index 3f03e80325b49..2a727dda7241d 100644 --- a/x-pack/plugins/apm/typings/ui_filters.ts +++ b/x-pack/plugins/apm/typings/ui_filters.ts @@ -11,3 +11,11 @@ export type UIFilters = { kuery?: string; environment?: string; } & { [key in LocalUIFilterName]?: string[] }; + +export interface BreakdownItem { + name: string; + count: number; + type: string; + fieldName: string; + selected?: boolean; +}