[Perfomance] Add is_initial_load meta (#206645)

closes https://github.com/elastic/observability-dev/issues/4185 

## Summary

This PR adds the `is_initial_load` parameter to the meta field to
distinguish whether the `onPageReady` trigger occurs during the initial
load or a page refresh.

Refactoring: 
- Removed the `target` field. as `context.pageName` now provides the
necessary information
- Refactor APM instrumentation to simplify it

Fixes: 
- https://github.com/elastic/observability-dev/issues/3464  


### ⚠️  Instrumentation 

The plugins need to call the following function: 

``` onPageRefreshStart()```


This method adds a performance marker `start::pageRefresh` to indicate when a page refresh begins. This marker is used along with an end marker `end::pageReady` to determine the total refresh duration.


 
https://github.com/user-attachments/assets/62587d18-b33e-437b-9774-d8e196dbf764

https://github.com/user-attachments/assets/e9c9a761-57bc-4743-9cc7-ea7634696ee3



### How to test
- Checkout the PR
- make sure you run `yarn kbn bootstrap`
- go to any page that has onPageReady function instrumented (ex services)  


### TODO
- Once approved, update docs

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Katerina 2025-02-25 20:48:51 +02:00 committed by GitHub
parent 471c413299
commit 9a1d70d5d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 394 additions and 254 deletions

View file

@ -114,6 +114,11 @@ describe('trackPerformanceMeasureEntries', () => {
anyKey: 'anyKey',
anyValue: 'anyValue',
},
meta: {
isInitialLoad: true,
queryRangeSecs: 86400,
queryOffsetSecs: 0,
},
},
},
]);
@ -124,7 +129,7 @@ describe('trackPerformanceMeasureEntries', () => {
duration: 1000,
eventName: 'kibana:plugin_render_time',
key1: 'key1',
meta: { target: '/' },
meta: { is_initial_load: true, query_range_secs: 86400, query_offset_secs: 0 },
value1: 'value1',
});
});
@ -152,7 +157,7 @@ describe('trackPerformanceMeasureEntries', () => {
expect(analyticsClientMock.reportEvent).toHaveBeenCalledWith('performance_metric', {
duration: 1000,
eventName: 'kibana:plugin_render_time',
meta: { target: '/', query_range_secs: 86400, query_offset_secs: 0 },
meta: { query_range_secs: 86400, query_offset_secs: 0 },
});
});
});

View file

@ -72,9 +72,9 @@ export function trackPerformanceMeasureEntries(analytics: AnalyticsClient, isDev
duration,
...customMetrics,
meta: {
target,
query_range_secs: meta?.queryRangeSecs,
query_offset_secs: meta?.queryOffsetSecs,
is_initial_load: meta?.isInitialLoad,
},
});
} catch (error) {

View file

@ -12,24 +12,25 @@ import {
getOffsetFromNowInSeconds,
getTimeDifferenceInSeconds,
} from '@kbn/timerange';
import { perfomanceMarkers } from '../../performance_markers';
import { EventData } from '../performance_context';
import { perfomanceMarkers } from '../../performance_markers';
interface PerformanceMeta {
queryRangeSecs: number;
queryOffsetSecs: number;
isInitialLoad?: boolean;
}
export function measureInteraction() {
export function measureInteraction(pathname: string) {
performance.mark(perfomanceMarkers.startPageChange);
const trackedRoutes: string[] = [];
return {
/**
* Marks the end of the page ready state and measures the performance between the start of the page change and the end of the page ready state.
* @param pathname - The pathname of the page.
* @param customMetrics - Custom metrics to be included in the performance measure.
*/
pageReady(pathname: string, eventData?: EventData) {
pageReady(eventData?: EventData) {
let performanceMeta: PerformanceMeta | undefined;
performance.mark(perfomanceMarkers.endPageReady);
@ -49,19 +50,48 @@ export function measureInteraction() {
};
}
if (!trackedRoutes.includes(pathname)) {
performance.measure(pathname, {
if (
performance.getEntriesByName(perfomanceMarkers.startPageChange).length > 0 &&
performance.getEntriesByName(perfomanceMarkers.endPageReady).length > 0
) {
performance.measure(`[ttfmp:initial] - ${pathname}`, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: eventData?.customMetrics,
meta: performanceMeta,
meta: { ...performanceMeta, isInitialLoad: true },
},
start: perfomanceMarkers.startPageChange,
end: perfomanceMarkers.endPageReady,
});
trackedRoutes.push(pathname);
// Clean up the marks once the measure is done
performance.clearMarks(perfomanceMarkers.startPageChange);
performance.clearMarks(perfomanceMarkers.endPageReady);
}
if (
performance.getEntriesByName(perfomanceMarkers.startPageRefresh).length > 0 &&
performance.getEntriesByName(perfomanceMarkers.endPageReady).length > 0
) {
performance.measure(`[ttfmp:refresh] - ${pathname}`, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: eventData?.customMetrics,
meta: { ...performanceMeta, isInitialLoad: false },
},
start: perfomanceMarkers.startPageRefresh,
end: perfomanceMarkers.endPageReady,
});
// // Clean up the marks once the measure is done
performance.clearMarks(perfomanceMarkers.startPageRefresh);
performance.clearMarks(perfomanceMarkers.endPageReady);
}
},
pageRefreshStart() {
performance.mark(perfomanceMarkers.startPageRefresh);
},
};
}

View file

@ -15,147 +15,196 @@ describe('measureInteraction', () => {
jest.restoreAllMocks();
});
beforeEach(() => {
jest.clearAllMocks();
performance.mark = jest.fn();
performance.measure = jest.fn();
});
describe('Initial load', () => {
beforeEach(() => {
jest.clearAllMocks();
performance.mark = jest.fn();
performance.measure = jest.fn();
it('should mark the start of the page change', () => {
measureInteraction();
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.startPageChange);
});
it('should mark the end of the page ready state and measure performance', () => {
const interaction = measureInteraction();
const pathname = '/test-path';
interaction.pageReady(pathname);
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(pathname, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
},
start: perfomanceMarkers.startPageChange,
end: perfomanceMarkers.endPageReady,
performance.getEntriesByName = jest
.fn()
.mockReturnValueOnce([{ name: 'start::pageChange' }])
.mockReturnValueOnce([{ name: 'end::pageReady' }])
.mockReturnValueOnce([]);
performance.clearMarks = jest.fn();
});
});
it('should include custom metrics and meta in the performance measure', () => {
const interaction = measureInteraction();
const pathname = '/test-path';
const eventData = {
customMetrics: { key1: 'foo-metric', value1: 100 },
meta: { rangeFrom: 'now-15m', rangeTo: 'now' },
};
it('should mark the start of the page change', () => {
const pathname = '/test-path';
measureInteraction(pathname);
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.startPageChange);
});
interaction.pageReady(pathname, eventData);
it('should mark the end of the page ready state and measure performance', () => {
const pathname = '/test-path';
const interaction = measureInteraction(pathname);
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(pathname, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: eventData.customMetrics,
meta: {
queryRangeSecs: 900,
queryOffsetSecs: 0,
interaction.pageReady();
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(`[ttfmp:initial] - ${pathname}`, {
detail: {
customMetrics: undefined,
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
meta: {
isInitialLoad: true,
},
},
},
end: 'end::pageReady',
start: 'start::pageChange',
start: perfomanceMarkers.startPageChange,
end: perfomanceMarkers.endPageReady,
});
});
});
it('should handle absolute date format correctly', () => {
const interaction = measureInteraction();
const pathname = '/test-path';
jest.spyOn(global.Date, 'now').mockReturnValue(1733704200000); // 2024-12-09T00:30:00Z
it('should include custom metrics and meta in the performance measure', () => {
const pathname = '/test-path';
const interaction = measureInteraction(pathname);
const eventData = {
customMetrics: { key1: 'foo-metric', value1: 100 },
meta: { rangeFrom: 'now-15m', rangeTo: 'now' },
};
const eventData = {
meta: { rangeFrom: '2024-12-09T00:00:00Z', rangeTo: '2024-12-09T00:30:00Z' },
};
interaction.pageReady(eventData);
interaction.pageReady(pathname, eventData);
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(pathname, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: undefined,
meta: {
queryRangeSecs: 1800,
queryOffsetSecs: 0,
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(`[ttfmp:initial] - ${pathname}`, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: eventData.customMetrics,
meta: {
queryRangeSecs: 900,
queryOffsetSecs: 0,
isInitialLoad: true,
},
},
},
end: 'end::pageReady',
start: 'start::pageChange',
end: 'end::pageReady',
start: 'start::pageChange',
});
});
});
it('should handle negative offset when rangeTo is in the past', () => {
const interaction = measureInteraction();
const pathname = '/test-path';
jest.spyOn(global.Date, 'now').mockReturnValue(1733704200000); // 2024-12-09T00:30:00Z
it('should handle absolute date format correctly', () => {
const pathname = '/test-path';
const interaction = measureInteraction(pathname);
jest.spyOn(global.Date, 'now').mockReturnValue(1733704200000); // 2024-12-09T00:30:00Z
const eventData = {
meta: { rangeFrom: '2024-12-08T00:00:00Z', rangeTo: '2024-12-09T00:00:00Z' },
};
const eventData = {
meta: { rangeFrom: '2024-12-09T00:00:00Z', rangeTo: '2024-12-09T00:30:00Z' },
};
interaction.pageReady(pathname, eventData);
interaction.pageReady(eventData);
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(pathname, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: undefined,
meta: {
queryRangeSecs: 86400,
queryOffsetSecs: -1800,
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(`[ttfmp:initial] - ${pathname}`, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: undefined,
meta: {
queryRangeSecs: 1800,
queryOffsetSecs: 0,
isInitialLoad: true,
},
},
},
end: 'end::pageReady',
start: 'start::pageChange',
end: 'end::pageReady',
start: 'start::pageChange',
});
});
});
it('should handle positive offset when rangeTo is in the future', () => {
const interaction = measureInteraction();
const pathname = '/test-path';
jest.spyOn(global.Date, 'now').mockReturnValue(1733704200000); // 2024-12-09T00:30:00Z
it('should handle negative offset when rangeTo is in the past', () => {
const pathname = '/test-path';
const interaction = measureInteraction(pathname);
jest.spyOn(global.Date, 'now').mockReturnValue(1733704200000); // 2024-12-09T00:30:00Z
const eventData = {
meta: { rangeFrom: '2024-12-08T01:00:00Z', rangeTo: '2024-12-09T01:00:00Z' },
};
const eventData = {
meta: { rangeFrom: '2024-12-08T00:00:00Z', rangeTo: '2024-12-09T00:00:00Z' },
};
interaction.pageReady(pathname, eventData);
interaction.pageReady(eventData);
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(pathname, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: undefined,
meta: {
queryRangeSecs: 86400,
queryOffsetSecs: 1800,
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(`[ttfmp:initial] - ${pathname}`, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: undefined,
meta: {
queryRangeSecs: 86400,
queryOffsetSecs: -1800,
isInitialLoad: true,
},
},
},
end: 'end::pageReady',
start: 'start::pageChange',
end: 'end::pageReady',
start: 'start::pageChange',
});
});
it('should handle positive offset when rangeTo is in the future', () => {
const pathname = '/test-path';
const interaction = measureInteraction(pathname);
jest.spyOn(global.Date, 'now').mockReturnValue(1733704200000); // 2024-12-09T00:30:00Z
const eventData = {
meta: { rangeFrom: '2024-12-08T01:00:00Z', rangeTo: '2024-12-09T01:00:00Z' },
};
interaction.pageReady(eventData);
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(`[ttfmp:initial] - ${pathname}`, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: undefined,
meta: {
queryRangeSecs: 86400,
queryOffsetSecs: 1800,
isInitialLoad: true,
},
},
end: 'end::pageReady',
start: 'start::pageChange',
});
expect(performance.clearMarks).toHaveBeenCalledWith(perfomanceMarkers.startPageChange);
expect(performance.clearMarks).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
});
});
it('should not measure the same route twice', () => {
const interaction = measureInteraction();
const pathname = '/test-path';
describe('Refresh', () => {
beforeEach(() => {
performance.getEntriesByName = jest
.fn()
.mockReturnValue([{ name: 'start::pageRefresh' }])
.mockReturnValue([{ name: 'end::pageReady' }]);
});
it('should set isInitialLoad to false on refresh calls', () => {
const pathname = '/test-path';
const interaction = measureInteraction(pathname);
interaction.pageReady(pathname);
interaction.pageReady(pathname);
jest.spyOn(global.Date, 'now').mockReturnValue(1733704200000); // 2024-12-09T00:30:00Z
expect(performance.measure).toHaveBeenCalledTimes(1);
const eventData = {
meta: { rangeFrom: '2024-12-08T01:00:00Z', rangeTo: '2024-12-09T01:00:00Z' },
};
interaction.pageReady(eventData);
expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.endPageReady);
expect(performance.measure).toHaveBeenCalledWith(`[ttfmp:refresh] - ${pathname}`, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: undefined,
meta: {
queryRangeSecs: 86400,
queryOffsetSecs: 1800,
isInitialLoad: false,
},
},
end: 'end::pageReady',
start: 'start::pageRefresh',
});
});
});
});

View file

@ -28,7 +28,8 @@ export interface EventData {
export function PerformanceContextProvider({ children }: { children: React.ReactElement }) {
const [isRendered, setIsRendered] = useState(false);
const location = useLocation();
const interaction = measureInteraction();
const interaction = useMemo(() => measureInteraction(location.pathname), [location.pathname]);
React.useEffect(() => {
afterFrame(() => {
@ -44,11 +45,14 @@ export function PerformanceContextProvider({ children }: { children: React.React
() => ({
onPageReady(eventData) {
if (isRendered) {
interaction.pageReady(location.pathname, eventData);
interaction.pageReady(eventData);
}
},
onPageRefreshStart() {
interaction.pageRefreshStart();
},
}),
[isRendered, location.pathname, interaction]
[isRendered, interaction]
);
return <PerformanceContext.Provider value={api}>{children}</PerformanceContext.Provider>;

View file

@ -15,6 +15,19 @@ export interface PerformanceApi {
* @param eventData - Data to send with the performance measure, conforming the structure of a {@link EventData}.
*/
onPageReady(eventData?: EventData): void;
/**
* Marks the start of a page refresh event for performance tracking.
* This method adds a performance marker start::pageRefresh to indicate when a page refresh begins.
*
* Usage:
* ```ts
* onPageRefreshStart();
* ```
*
* The marker set by this function can later be used in performance measurements
* along with an end marker end::pageReady to determine the total refresh duration.
*/
onPageRefreshStart(): void;
}
export const PerformanceContext = createContext<PerformanceApi | undefined>(undefined);

View file

@ -11,4 +11,5 @@
export const perfomanceMarkers = {
startPageChange: 'start::pageChange',
endPageReady: 'end::pageReady',
startPageRefresh: 'start::pageRefresh',
};

View file

@ -8,10 +8,10 @@
import { EuiBadge, EuiIconTip, EuiToolTip, RIGHT_ALIGNMENT } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from '@emotion/styled';
import React, { useEffect, useMemo, useState, useRef } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { apmEnableTableSearchBar } from '@kbn/observability-plugin/common';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { FETCH_STATUS, isPending } from '../../../../hooks/use_fetcher';
import { isPending, isSuccess } from '../../../../hooks/use_fetcher';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { asBigNumber } from '../../../../../common/utils/formatters';
import { useAnyOfApmParams } from '../../../../hooks/use_apm_params';
@ -85,7 +85,6 @@ export function ErrorGroupList({
const { offset, rangeFrom, rangeTo } = query;
const [renderedItems, setRenderedItems] = useState<ErrorGroupItem[]>([]);
const hasTableLoaded = useRef(false);
const [sorting, setSorting] = useState<TableOptions<ErrorGroupItem>['sort']>(defaultSorting);
const {
@ -103,11 +102,7 @@ export function ErrorGroupList({
useEffect(() => {
// this component is used both for the service overview tab and the errors tab,
// onLoadTable will be defined if it's the service overview tab
if (
mainStatisticsStatus === FETCH_STATUS.SUCCESS &&
detailedStatisticsStatus === FETCH_STATUS.SUCCESS &&
!hasTableLoaded.current
) {
if (isSuccess(mainStatisticsStatus) && isSuccess(detailedStatisticsStatus)) {
if (onLoadTable) {
onLoadTable();
} else {
@ -118,15 +113,14 @@ export function ErrorGroupList({
},
});
}
hasTableLoaded.current = true;
}
}, [
mainStatisticsStatus,
detailedStatisticsStatus,
onLoadTable,
rangeFrom,
rangeTo,
onPageReady,
onLoadTable,
]);
const columns = useMemo(() => {

View file

@ -7,7 +7,7 @@
import type { EuiFlexGroupProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { chartHeight } from '..';
import type { AgentName } from '../../../../../typings/es_schemas/ui/fields/agent';
@ -36,6 +36,12 @@ import { useLocalStorage } from '../../../../hooks/use_local_storage';
const latencyChartHeight = 200;
export interface TablesLoadedState {
transactions: boolean;
dependencies: boolean;
errors: boolean;
}
export function ApmOverview() {
const router = useApmRouter();
const { serviceName, fallbackToTransactions, agentName, serverlessType } = useApmServiceContext();
@ -45,7 +51,7 @@ export function ApmOverview() {
} = useApmParams('/services/{serviceName}/overview');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const [haveTablesLoaded, setHaveTablesLoaded] = useState({
const [haveTablesLoaded, setHaveTablesLoaded] = useState<TablesLoadedState>({
transactions: false,
dependencies: false,
errors: false,
@ -81,9 +87,12 @@ export function ApmOverview() {
false
);
const onLoadTable = (key: string) => {
const handleOnLoadTable = (key: keyof TablesLoadedState) =>
setHaveTablesLoaded((currentValues) => ({ ...currentValues, [key]: true }));
};
const onTransactionsTableLoad = useCallback(() => handleOnLoadTable('transactions'), []);
const onErrorsTableLoad = useCallback(() => handleOnLoadTable('errors'), []);
const onDependenciesTableLoad = useCallback(() => handleOnLoadTable('dependencies'), []);
return (
<>
@ -121,7 +130,7 @@ export function ApmOverview() {
kuery={kuery}
environment={environment}
fixedHeight={true}
onLoadTable={() => onLoadTable('transactions')}
onLoadTable={onTransactionsTableLoad}
start={start}
end={end}
showPerPageOptions={false}
@ -147,7 +156,7 @@ export function ApmOverview() {
<EuiPanel hasBorder={true}>
<ServiceOverviewErrorsTable
serviceName={serviceName}
onLoadTable={() => onLoadTable('errors')}
onLoadTable={onErrorsTableLoad}
/>
</EuiPanel>
</EuiFlexItem>
@ -178,7 +187,7 @@ export function ApmOverview() {
<EuiFlexItem grow={7}>
<EuiPanel hasBorder={true}>
<ServiceOverviewDependenciesTable
onLoadTable={() => onLoadTable('dependencies')}
onLoadTable={onDependenciesTableLoad}
fixedHeight={true}
showPerPageOptions={false}
link={

View file

@ -9,25 +9,26 @@ import { EuiIconTip } from '@elastic/eui';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import type { ReactNode } from 'react';
import React, { useEffect, useRef } from 'react';
import { FETCH_STATUS, useUiTracker } from '@kbn/observability-shared-plugin/public';
import React, { useEffect } from 'react';
import { useUiTracker } from '@kbn/observability-shared-plugin/public';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { isTimeComparison } from '../../../shared/time_comparison/get_comparison_options';
import { getNodeName, NodeType } from '../../../../../common/connections';
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useApmParams } from '../../../../hooks/use_apm_params';
import { useFetcher } from '../../../../hooks/use_fetcher';
import { useFetcher, isSuccess } from '../../../../hooks/use_fetcher';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { DependencyLink } from '../../../shared/links/dependency_link';
import { DependenciesTable } from '../../../shared/dependencies_table';
import { ServiceLink } from '../../../shared/links/apm/service_link';
import type { TablesLoadedState } from '../apm_overview';
interface ServiceOverviewDependenciesTableProps {
fixedHeight?: boolean;
link?: ReactNode;
showPerPageOptions?: boolean;
showSparkPlots?: boolean;
onLoadTable?: () => void;
onLoadTable?: (key: keyof TablesLoadedState) => void;
}
export function ServiceOverviewDependenciesTable({
@ -55,7 +56,6 @@ export function ServiceOverviewDependenciesTable({
const { serviceName, transactionType } = useApmServiceContext();
const { onPageReady } = usePerformanceContext();
const trackEvent = useUiTracker();
const hasTableLoaded = useRef(false);
const { data, status } = useFetcher(
(callApmApi) => {
if (!start || !end) {
@ -79,11 +79,11 @@ export function ServiceOverviewDependenciesTable({
);
useEffect(() => {
// this component is used both for the service overview tab and the transactions tab,
// this component is used both for the service overview tab and the dependency tab,
// onLoadTable will be defined if it's the service overview tab
if (status === FETCH_STATUS.SUCCESS && !hasTableLoaded.current) {
if (isSuccess(status)) {
if (onLoadTable) {
onLoadTable();
onLoadTable('dependencies');
} else {
onPageReady({
meta: {
@ -92,9 +92,8 @@ export function ServiceOverviewDependenciesTable({
},
});
}
hasTableLoaded.current = true;
}
}, [status, onLoadTable, onPageReady, rangeFrom, rangeTo]);
}, [status, onPageReady, rangeFrom, rangeTo, onLoadTable]);
const dependencies =
data?.serviceDependencies.map((dependency) => {

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState, useEffect } from 'react';
import React, { useMemo, useCallback } from 'react';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useTimeRange } from '../../../hooks/use_time_range';
@ -17,39 +17,35 @@ export function TraceExplorerAggregatedCriticalPath() {
} = useApmParams('/traces/explorer/critical_path');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const [hasLoadedTable, setHasLoadedTable] = useState(false);
const { onPageReady } = usePerformanceContext();
const {
data: { traceSamples },
status: samplesFetchStatus,
} = useTraceExplorerSamples();
const { onPageReady } = usePerformanceContext();
const traceIds = useMemo(() => {
return traceSamples.map((sample) => sample.traceId);
}, [traceSamples]);
useEffect(() => {
if (hasLoadedTable) {
onPageReady({
meta: {
rangeFrom,
rangeTo,
},
customMetrics: {
key1: 'traceIds',
value1: traceIds.length,
},
});
}
}, [hasLoadedTable, onPageReady, rangeFrom, rangeTo, traceIds]);
const handleOnLoadTable = useCallback(() => {
onPageReady({
meta: {
rangeFrom: start,
rangeTo: end,
},
customMetrics: {
key1: 'traceIds',
value1: traceIds.length,
},
});
}, [start, end, traceIds, onPageReady]);
return (
<CriticalPathFlamegraph
onLoadTable={() => setHasLoadedTable(true)}
start={start}
end={end}
traceIds={traceIds}
traceIdsFetchStatus={samplesFetchStatus}
onLoadTable={handleOnLoadTable}
/>
);
}

View file

@ -7,7 +7,8 @@
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import React, { useMemo, useCallback } from 'react';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { useAnyOfApmParams } from '../../../hooks/use_apm_params';
import { useTimeRange } from '../../../hooks/use_time_range';
import { CriticalPathFlamegraph } from '../../shared/critical_path_flamegraph';
@ -23,12 +24,27 @@ function TransactionDetailAggregatedCriticalPath({ traceSamplesFetchResult }: Ta
'/mobile-services/{serviceName}/transactions/view'
);
const { onPageReady } = usePerformanceContext();
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const traceIds = useMemo(() => {
return traceSamplesFetchResult.data?.traceSamples.map((sample) => sample.traceId) ?? [];
}, [traceSamplesFetchResult.data]);
const handleOnLoadTable = useCallback(() => {
onPageReady({
meta: {
rangeFrom: start,
rangeTo: end,
},
customMetrics: {
key1: 'traceIds',
value1: traceIds.length,
},
});
}, [start, end, traceIds, onPageReady]);
return (
<CriticalPathFlamegraph
start={start}
@ -37,6 +53,7 @@ function TransactionDetailAggregatedCriticalPath({ traceSamplesFetchResult }: Ta
traceIds={traceIds}
serviceName={serviceName}
transactionName={transactionName}
onLoadTable={handleOnLoadTable}
/>
);
}

View file

@ -6,9 +6,10 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { usePerformanceContext } from '@kbn/ebt-tools';
import React, { useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { isServerlessAgentName } from '../../../../common/agent_name';
import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context';
import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
@ -37,21 +38,9 @@ export function TransactionOverview() {
} = useApmParams('/services/{serviceName}/transactions');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const [hasLoadedTable, setHasLoadedTable] = useState(false);
const { onPageReady } = usePerformanceContext();
const { transactionType, fallbackToTransactions, serverlessType, serviceName } =
useApmServiceContext();
useEffect(() => {
if (hasLoadedTable) {
onPageReady({
meta: {
rangeFrom,
rangeTo,
},
});
}
}, [hasLoadedTable, onPageReady, rangeFrom, rangeTo]);
const { onPageReady } = usePerformanceContext();
const history = useHistory();
@ -80,6 +69,15 @@ export function TransactionOverview() {
const hasLogsOnlySignal =
serviceEntitySummary?.dataStreamTypes && isLogsOnlySignal(serviceEntitySummary.dataStreamTypes);
const handleOnLoadTable = useCallback(() => {
onPageReady({
meta: {
rangeFrom: start,
rangeTo: end,
},
});
}, [start, end, onPageReady]);
if (hasLogsOnlySignal) {
return <ServiceTabEmptyState id="transactionOverview" />;
}
@ -122,12 +120,12 @@ export function TransactionOverview() {
hideViewTransactionsLink
numberOfTransactionsPerPage={10}
showMaxTransactionGroupsExceededWarning
onLoadTable={() => setHasLoadedTable(true)}
environment={environment}
kuery={kuery}
start={start}
end={end}
saveTableOptionsToUrl
onLoadTable={handleOnLoadTable}
/>
</EuiPanel>
</>

View file

@ -10,9 +10,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, euiPaletteColorBlind } fr
import { css } from '@emotion/css';
import { useChartThemes } from '@kbn/observability-shared-plugin/public';
import { uniqueId } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import type { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { isSuccess } from '../../../hooks/use_fetcher';
import { useFetcher, isPending } from '../../../hooks/use_fetcher';
import { CriticalPathFlamegraphTooltip } from './critical_path_flamegraph_tooltip';
import { criticalPathToFlamegraph } from './critical_path_to_flamegraph';
@ -41,7 +42,6 @@ export function CriticalPathFlamegraph(
// of the search.
const timerange = useRef({ start, end });
timerange.current = { start, end };
const [hasTableLoaded, setHasTableLoaded] = useState(false);
const { data: { criticalPath } = { criticalPath: null }, status: criticalPathFetchStatus } =
useFetcher(
@ -66,22 +66,10 @@ export function CriticalPathFlamegraph(
);
useEffect(() => {
if (
criticalPathFetchStatus === FETCH_STATUS.SUCCESS &&
traceIdsFetchStatus === FETCH_STATUS.SUCCESS &&
onLoadTable &&
!hasTableLoaded
) {
onLoadTable();
setHasTableLoaded(true);
if (isSuccess(criticalPathFetchStatus) && isSuccess(traceIdsFetchStatus)) {
onLoadTable?.();
}
}, [
criticalPathFetchStatus,
onLoadTable,
hasTableLoaded,
traceIdsFetchStatus,
setHasTableLoaded,
]);
}, [start, end, criticalPathFetchStatus, traceIdsFetchStatus, traceIds, onLoadTable]);
const chartThemes = useChartThemes();

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { getByTestId, fireEvent, getByText, act } from '@testing-library/react';
import { getByTestId, fireEvent, getByText, act, waitFor } from '@testing-library/react';
import type { MemoryHistory } from 'history';
import { PerformanceContextProvider } from '@kbn/ebt-tools';
import { createMemoryHistory } from 'history';
import React from 'react';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
@ -79,7 +80,9 @@ function setup({
<UrlParamsProvider>
<ApmTimeRangeMetadataContextProvider>
<ApmServiceContextProvider>
<SearchBar showTransactionTypeSelector />
<PerformanceContextProvider>
<SearchBar showTransactionTypeSelector />
</PerformanceContextProvider>
</ApmServiceContextProvider>
</ApmTimeRangeMetadataContextProvider>
</UrlParamsProvider>
@ -96,7 +99,7 @@ describe('when transactionType is selected and multiple transaction types are gi
jest.spyOn(history, 'replace');
});
it('renders a radio group with transaction types', () => {
it('renders a radio group with transaction types', async () => {
const { container } = setup({
history,
serviceTransactionTypes: ['firstType', 'secondType'],
@ -108,14 +111,16 @@ describe('when transactionType is selected and multiple transaction types are gi
});
// transaction type selector
const dropdown = getByTestId(container, 'headerFilterTransactionType');
await waitFor(() => {
const dropdown = getByTestId(container, 'headerFilterTransactionType');
// both options should be listed
expect(getByText(dropdown, 'firstType')).toBeInTheDocument();
expect(getByText(dropdown, 'secondType')).toBeInTheDocument();
// both options should be listed
expect(getByText(dropdown, 'firstType')).toBeInTheDocument();
expect(getByText(dropdown, 'secondType')).toBeInTheDocument();
// second option should be selected
expect(dropdown).toHaveValue('secondType');
// second option should be selected
expect(dropdown).toHaveValue('secondType');
});
});
it('should update the URL when a transaction type is selected', async () => {

View file

@ -20,7 +20,7 @@ import { useAnyOfApmParams } from '../../../hooks/use_apm_params';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useBreakpoints } from '../../../hooks/use_breakpoints';
import { useStateDebounced } from '../../../hooks/use_debounce';
import { FETCH_STATUS, isPending, useFetcher } from '../../../hooks/use_fetcher';
import { FETCH_STATUS, isPending, isSuccess, useFetcher } from '../../../hooks/use_fetcher';
import { usePreferredDataSourceAndBucketSize } from '../../../hooks/use_preferred_data_source_and_bucket_size';
import type { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { TransactionOverviewLink } from '../links/apm/transaction_overview_link';
@ -91,7 +91,6 @@ export function TransactionsTable({
const shouldShowSparkPlots = showSparkPlots ?? !isLarge;
const { transactionType, serviceName } = useApmServiceContext();
const [searchQuery, setSearchQueryDebounced] = useStateDebounced('');
const [hasTableLoaded, setHasTableLoaded] = useState(false);
const [renderedItems, setRenderedItems] = useState<ApiResponse['transactionGroups']>([]);
const { mainStatistics, mainStatisticsStatus, detailedStatistics, detailedStatisticsStatus } =
@ -110,16 +109,10 @@ export function TransactionsTable({
});
useEffect(() => {
if (
mainStatisticsStatus === FETCH_STATUS.SUCCESS &&
detailedStatisticsStatus === FETCH_STATUS.SUCCESS &&
onLoadTable &&
!hasTableLoaded
) {
onLoadTable();
setHasTableLoaded(true);
if (isSuccess(mainStatisticsStatus) && isSuccess(detailedStatisticsStatus)) {
onLoadTable?.();
}
}, [mainStatisticsStatus, detailedStatisticsStatus, onLoadTable, hasTableLoaded]);
}, [mainStatisticsStatus, detailedStatisticsStatus, onLoadTable, end, start]);
const columns = useMemo(() => {
return getColumns({

View file

@ -13,6 +13,7 @@ import {
toElasticsearchQuery,
} from '@kbn/es-query';
import { useHistory, useLocation } from 'react-router-dom';
import { usePerformanceContext } from '@kbn/ebt-tools';
import deepEqual from 'fast-deep-equal';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import qs from 'query-string';
@ -141,6 +142,7 @@ export function UnifiedSearchBar({
const { kuery, serviceName, environment, groupId, refreshPausedFromUrl, refreshIntervalFromUrl } =
useSearchBarParams(value);
const { onPageRefreshStart } = usePerformanceContext();
const timePickerTimeDefaults = core.uiSettings.get<TimePickerTimeDefaults>(
UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS
);
@ -204,6 +206,7 @@ export function UnifiedSearchBar({
const onRefresh = () => {
clearCache();
incrementTimeRangeId();
onPageRefreshStart();
};
const onRefreshChange = ({ isPaused, refreshInterval }: Partial<OnRefreshChangeProps>) => {

View file

@ -6,6 +6,8 @@
*/
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { PerformanceContextProvider } from '@kbn/ebt-tools';
import type { MemoryHistory } from 'history';
import { createMemoryHistory } from 'history';
import React from 'react';
@ -25,7 +27,7 @@ jest.mock('react-router-dom', () => ({
useLocation: jest.fn(),
}));
function setup({ urlParams, history }: { urlParams: UrlParams; history: MemoryHistory }) {
async function setup({ urlParams, history }: { urlParams: UrlParams; history: MemoryHistory }) {
history.replace({
pathname: '/services',
search: fromQuery(urlParams),
@ -73,7 +75,9 @@ function setup({ urlParams, history }: { urlParams: UrlParams; history: MemoryHi
} as unknown as ApmPluginContextValue
}
>
<UnifiedSearchBar />
<PerformanceContextProvider>
<UnifiedSearchBar />
</PerformanceContextProvider>
</MockApmPluginContextWrapper>
);
@ -89,6 +93,7 @@ function setup({ urlParams, history }: { urlParams: UrlParams; history: MemoryHi
describe('when kuery is already present in the url, the search bar must reflect the same', () => {
let history: MemoryHistory;
beforeEach(() => {
history = createMemoryHistory();
jest.spyOn(history, 'push');
@ -103,12 +108,12 @@ describe('when kuery is already present in the url, the search bar must reflect
const search = '?method=json';
const pathname = '/services';
(useLocation as jest.Mock).mockImplementationOnce(() => ({
(useLocation as jest.Mock).mockReturnValue(() => ({
search,
pathname,
}));
it('sets the searchbar value based on URL', () => {
it('sets the searchbar value based on URL', async () => {
const expectedQuery = {
query: 'service.name:"opbeans-android"',
language: 'kuery',
@ -137,13 +142,15 @@ describe('when kuery is already present in the url, the search bar must reflect
};
jest.spyOn(useApmParamsHook, 'useApmParams').mockReturnValue({ query: urlParams, path: {} });
const { setQuerySpy, setTimeSpy, setRefreshIntervalSpy } = setup({
const { setQuerySpy, setTimeSpy, setRefreshIntervalSpy } = await setup({
history,
urlParams,
});
expect(setQuerySpy).toBeCalledWith(expectedQuery);
expect(setTimeSpy).toBeCalledWith(expectedTimeRange);
expect(setRefreshIntervalSpy).toBeCalledWith(refreshInterval);
await waitFor(() => {
expect(setQuerySpy).toBeCalledWith(expectedQuery);
expect(setTimeSpy).toBeCalledWith(expectedTimeRange);
expect(setRefreshIntervalSpy).toBeCalledWith(refreshInterval);
});
});
});

View file

@ -208,6 +208,8 @@ export function MockApmPluginContextWrapper({
createCallApmApi(contextValue.core);
}
performance.mark = jest.fn();
const contextHistory = useHistory();
const usedHistory = useMemo(() => {

View file

@ -8,6 +8,7 @@
import React, { useCallback } from 'react';
import type { TimeRange } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { useEuiTheme, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/react';
import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana';
@ -24,6 +25,7 @@ export const UnifiedSearchBar = () => {
const { metricsView } = useMetricsDataViewContext();
const { searchCriteria, onLimitChange, onPanelFiltersChange, onSubmit } =
useUnifiedSearchContext();
const { onPageRefreshStart } = usePerformanceContext();
const { SearchBar } = unifiedSearch.ui;
@ -32,9 +34,10 @@ export const UnifiedSearchBar = () => {
// This makes sure `onSubmit` is only called when the submit button is clicked
if (isUpdate === false) {
onSubmit(payload);
onPageRefreshStart();
}
},
[onSubmit]
[onSubmit, onPageRefreshStart]
);
return (

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import { renderHook, act } from '@testing-library/react';
import { renderHook, act, waitFor } from '@testing-library/react';
import { PerformanceContextProvider } from '@kbn/ebt-tools';
import { useLocation } from 'react-router-dom';
import { useMetricsExplorerState } from './use_metric_explorer_state';
import { MetricsExplorerOptionsContainer } from './use_metrics_explorer_options';
import React from 'react';
@ -16,6 +18,11 @@ jest.mock('../../../../hooks/use_kibana_timefilter_time', () => ({
useSyncKibanaTimeFilterTime: () => [() => {}],
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('../../../../alerting/use_alert_prefill', () => ({
useAlertPrefillContext: () => ({
metricThresholdPrefill: {
@ -27,7 +34,9 @@ jest.mock('../../../../alerting/use_alert_prefill', () => ({
const renderUseMetricsExplorerStateHook = () =>
renderHook(() => useMetricsExplorerState(), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<MetricsExplorerOptionsContainer>{children}</MetricsExplorerOptionsContainer>
<PerformanceContextProvider>
<MetricsExplorerOptionsContainer>{children}</MetricsExplorerOptionsContainer>
</PerformanceContextProvider>
),
});
@ -73,6 +82,12 @@ describe('useMetricsExplorerState', () => {
});
delete STORE.MetricsExplorerOptions;
delete STORE.MetricsExplorerTimeRange;
const pathname = '/hosts';
(useLocation as jest.Mock).mockReturnValue(() => ({
pathname,
}));
performance.mark = jest.fn();
});
afterEach(() => {
@ -88,9 +103,11 @@ describe('useMetricsExplorerState', () => {
},
});
const { result } = renderUseMetricsExplorerStateHook();
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.error).toBe(null);
expect(result.current.isLoading).toBe(false);
await waitFor(() => {
expect(result.current.data!.pages[0]).toEqual(resp);
expect(result.current.error).toBe(null);
expect(result.current.isLoading).toBe(false);
});
});
describe('handleRefresh', () => {

View file

@ -7,6 +7,7 @@
import DateMath from '@kbn/datemath';
import { useCallback, useEffect } from 'react';
import { usePerformanceContext } from '@kbn/ebt-tools';
import type {
MetricsExplorerChartOptions,
MetricsExplorerOptions,
@ -35,6 +36,7 @@ export const useMetricsExplorerState = ({ enabled }: { enabled: boolean } = { en
timestamps,
setTimestamps,
} = useMetricsExplorerOptionsContainerContext();
const { onPageRefreshStart } = usePerformanceContext();
const refreshTimestamps = useCallback(() => {
const fromTimestamp = DateMath.parse(timeRange.from)!.valueOf();
@ -45,7 +47,8 @@ export const useMetricsExplorerState = ({ enabled }: { enabled: boolean } = { en
fromTimestamp,
toTimestamp,
});
}, [setTimestamps, timeRange]);
onPageRefreshStart();
}, [setTimestamps, timeRange, onPageRefreshStart]);
const { data, error, fetchNextPage, isLoading } = useMetricsExplorerData({
options,

View file

@ -6,7 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useTrackPageview, FeatureFeedbackButton } from '@kbn/observability-shared-plugin/public';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { OnboardingFlow } from '../../../components/shared/templates/no_data_config';
@ -65,7 +65,7 @@ const MetricsExplorerContent = () => {
const { currentView } = useMetricsExplorerViews();
const { kibanaVersion, isCloudEnv, isServerlessEnv } = useKibanaEnvironmentContext();
const prevDataRef = useRef(data);
const { onPageReady } = usePerformanceContext();
useTrackPageview({ app: 'infra_metrics', path: 'metrics_explorer' });
@ -96,14 +96,18 @@ const MetricsExplorerContent = () => {
currentTimerange: timeRange,
};
if (!isLoading) {
onPageReady({
meta: {
rangeFrom: timeRange.from,
rangeTo: timeRange.to,
},
});
}
useEffect(() => {
if (!isLoading && data && prevDataRef.current !== data) {
onPageReady({
meta: {
rangeFrom: timeRange.from,
rangeTo: timeRange.to,
},
});
prevDataRef.current = data;
}
}, [isLoading, data, timeRange.from, timeRange.to, onPageReady]);
return (
<InfraPageTemplate