diff --git a/dev_docs/tutorials/performance/adding_custom_performance_metrics.mdx b/dev_docs/tutorials/performance/adding_custom_performance_metrics.mdx index 7ebd918f4a331..b68e91b3fc6ca 100644 --- a/dev_docs/tutorials/performance/adding_custom_performance_metrics.mdx +++ b/dev_docs/tutorials/performance/adding_custom_performance_metrics.mdx @@ -330,8 +330,9 @@ This will be indexed as: "duration": 736, // Event duration as specified when reporting it "meta": { "target": '/home', - "query_range_secs": 900 - "query_offset_secs": 0 // now + "query_range_secs": 900, // 15 minutes + "query_from_offset_secs": -900 // From 15 minutes ago + "query_to_offset_secs": 0 // To now }, "context": { // Context holds information identifying the deployment, version, application and page that generated the event "version": "8.16.0-SNAPSHOT", diff --git a/src/core/packages/analytics/browser-internal/src/track_performance_measure_entries.test.ts b/src/core/packages/analytics/browser-internal/src/track_performance_measure_entries.test.ts index ab4de0e45fc99..1a2d26db0dc8e 100644 --- a/src/core/packages/analytics/browser-internal/src/track_performance_measure_entries.test.ts +++ b/src/core/packages/analytics/browser-internal/src/track_performance_measure_entries.test.ts @@ -114,6 +114,12 @@ describe('trackPerformanceMeasureEntries', () => { anyKey: 'anyKey', anyValue: 'anyValue', }, + meta: { + isInitialLoad: true, + queryRangeSecs: 86400, + queryFromOffsetSecs: -86400, + queryToOffsetSecs: 0, + }, }, }, ]); @@ -124,7 +130,12 @@ describe('trackPerformanceMeasureEntries', () => { duration: 1000, eventName: 'kibana:plugin_render_time', key1: 'key1', - meta: { target: '/' }, + meta: { + is_initial_load: true, + query_range_secs: 86400, + query_from_offset_secs: -86400, + query_to_offset_secs: 0, + }, value1: 'value1', }); }); @@ -141,7 +152,40 @@ describe('trackPerformanceMeasureEntries', () => { type: 'kibana:performance', meta: { queryRangeSecs: 86400, - queryOffsetSecs: 0, + queryFromOffsetSecs: -86400, + queryToOffsetSecs: 0, + }, + }, + }, + ]); + trackPerformanceMeasureEntries(analyticsClientMock, true); + + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledWith('performance_metric', { + duration: 1000, + eventName: 'kibana:plugin_render_time', + meta: { + query_range_secs: 86400, + query_from_offset_secs: -86400, + query_to_offset_secs: 0, + }, + }); + }); + + test('reports an analytics event with description metadata', () => { + setupMockPerformanceObserver([ + { + name: '/', + entryType: 'measure', + startTime: 100, + duration: 1000, + detail: { + eventName: 'kibana:plugin_render_time', + type: 'kibana:performance', + meta: { + isInitialLoad: false, + description: + '[ttfmp_dependencies] onPageReady is called when the most important content is rendered', }, }, }, @@ -152,7 +196,52 @@ 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: { + is_initial_load: false, + query_range_secs: undefined, + query_from_offset_secs: undefined, + query_to_offset_secs: undefined, + description: + '[ttfmp_dependencies] onPageReady is called when the most important content is rendered', + }, }); }); + + test('reports an analytics event with truncated description metadata', () => { + setupMockPerformanceObserver([ + { + name: '/', + entryType: 'measure', + startTime: 100, + duration: 1000, + detail: { + eventName: 'kibana:plugin_render_time', + type: 'kibana:performance', + meta: { + isInitialLoad: false, + description: + '[ttfmp_dependencies] This is a very long long long long long long long long description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque non risus in nunc tincidunt tincidunt. Proin vehicula, nunc at feugiat cursus, justo nulla fermentum lorem, non ultricies metus libero nec purus. Sed ut perspiciatis unde omnis iste natus.', + }, + }, + }, + ]); + trackPerformanceMeasureEntries(analyticsClientMock, true); + const truncatedDescription = + '[ttfmp_dependencies] This is a very long long long long long long long long description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque non risus in nunc tincidunt tincidunt. Proin vehicula, nunc at feugiat cursus, justo nulla fermentum l'; + + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledWith('performance_metric', { + duration: 1000, + eventName: 'kibana:plugin_render_time', + meta: { + is_initial_load: false, + query_range_secs: undefined, + query_from_offset_secs: undefined, + query_to_offset_secs: undefined, + description: truncatedDescription, + }, + }); + + expect(truncatedDescription.length).toBe(256); + }); }); diff --git a/src/core/packages/analytics/browser-internal/src/track_performance_measure_entries.ts b/src/core/packages/analytics/browser-internal/src/track_performance_measure_entries.ts index fc46230f85c2c..b1e235a596e11 100644 --- a/src/core/packages/analytics/browser-internal/src/track_performance_measure_entries.ts +++ b/src/core/packages/analytics/browser-internal/src/track_performance_measure_entries.ts @@ -11,6 +11,7 @@ import type { AnalyticsClient } from '@elastic/ebt/client'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; const MAX_CUSTOM_METRICS = 9; +const MAX_DESCRIPTION_LENGTH = 256; // The keys and values for the custom metrics are limited to 9 pairs const ALLOWED_CUSTOM_METRICS_KEYS_VALUES = Array.from({ length: MAX_CUSTOM_METRICS }, (_, i) => [ `key${i + 1}`, @@ -28,6 +29,8 @@ export function trackPerformanceMeasureEntries(analytics: AnalyticsClient, isDev const target = entry?.name; const duration = entry.duration; const meta = entry.detail?.meta; + const description = meta?.description; + const customMetrics = Object.keys(entry.detail?.customMetrics ?? {}).reduce( (acc, metric) => { if (ALLOWED_CUSTOM_METRICS_KEYS_VALUES.includes(metric)) { @@ -55,6 +58,13 @@ export function trackPerformanceMeasureEntries(analytics: AnalyticsClient, isDev ); } + if (description?.length > MAX_DESCRIPTION_LENGTH) { + // eslint-disable-next-line no-console + console.warn( + `The description for the measure: ${target} is too long. The maximum length is ${MAX_DESCRIPTION_LENGTH}. Strings longer than ${MAX_DESCRIPTION_LENGTH} will not be indexed or stored` + ); + } + // eslint-disable-next-line no-console console.log(`The measure ${target} completed in ${duration / 1000}s`); } @@ -72,9 +82,11 @@ export function trackPerformanceMeasureEntries(analytics: AnalyticsClient, isDev duration, ...customMetrics, meta: { - target, query_range_secs: meta?.queryRangeSecs, - query_offset_secs: meta?.queryOffsetSecs, + query_from_offset_secs: meta?.queryFromOffsetSecs, + query_to_offset_secs: meta?.queryToOffsetSecs, + description: description?.slice(0, MAX_DESCRIPTION_LENGTH), + is_initial_load: meta?.isInitialLoad, }, }); } catch (error) { diff --git a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/measure_interaction/index.ts b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/measure_interaction/index.ts index 790314a8f0892..26f43aa7e66f8 100644 --- a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/measure_interaction/index.ts +++ b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/measure_interaction/index.ts @@ -12,28 +12,32 @@ import { getOffsetFromNowInSeconds, getTimeDifferenceInSeconds, } from '@kbn/timerange'; -import { perfomanceMarkers } from '../../performance_markers'; import { EventData } from '../performance_context'; +import { perfomanceMarkers } from '../../performance_markers'; +import { DescriptionWithPrefix } from '../types'; interface PerformanceMeta { - queryRangeSecs: number; - queryOffsetSecs: number; + queryRangeSecs?: number; + queryFromOffsetSecs?: number; + queryToOffsetSecs?: number; + isInitialLoad?: boolean; + description?: DescriptionWithPrefix; } -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) { - let performanceMeta: PerformanceMeta | undefined; + pageReady(eventData?: EventData) { + const performanceMeta: PerformanceMeta = {}; performance.mark(perfomanceMarkers.endPageReady); - if (eventData?.meta) { + if (eventData?.meta?.rangeFrom && eventData?.meta?.rangeTo) { const { rangeFrom, rangeTo } = eventData.meta; // Convert the date range to epoch timestamps (in milliseconds) @@ -42,26 +46,59 @@ export function measureInteraction() { to: rangeTo, }); - performanceMeta = { - queryRangeSecs: getTimeDifferenceInSeconds(dateRangesInEpoch), - queryOffsetSecs: - rangeTo === 'now' ? 0 : getOffsetFromNowInSeconds(dateRangesInEpoch.endDate), - }; + performanceMeta.queryRangeSecs = getTimeDifferenceInSeconds(dateRangesInEpoch); + performanceMeta.queryFromOffsetSecs = + rangeFrom === 'now' ? 0 : getOffsetFromNowInSeconds(dateRangesInEpoch.startDate); + performanceMeta.queryToOffsetSecs = + rangeTo === 'now' ? 0 : getOffsetFromNowInSeconds(dateRangesInEpoch.endDate); + } + + if (eventData?.meta?.description) { + performanceMeta.description = eventData.meta.description; } - 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); }, }; } diff --git a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/measure_interaction/measure_interaction.test.tsx b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/measure_interaction/measure_interaction.test.tsx index 5768a6126c571..fb8a3f3a0f8c7 100644 --- a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/measure_interaction/measure_interaction.test.tsx +++ b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/measure_interaction/measure_interaction.test.tsx @@ -15,147 +15,201 @@ 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(); + + performance.getEntriesByName = jest + .fn() + .mockReturnValueOnce([{ name: 'start::pageChange' }]) + .mockReturnValueOnce([{ name: 'end::pageReady' }]) + .mockReturnValueOnce([]); + performance.clearMarks = jest.fn(); + }); - it('should mark the start of the page change', () => { - measureInteraction(); - expect(performance.mark).toHaveBeenCalledWith(perfomanceMarkers.startPageChange); - }); + it('should mark the start of the page change', () => { + const pathname = '/test-path'; + measureInteraction(pathname); + 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, + it('should mark the end of the page ready state and measure performance', () => { + const pathname = '/test-path'; + const interaction = measureInteraction(pathname); + + 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, + }, + }, + start: perfomanceMarkers.startPageChange, + end: perfomanceMarkers.endPageReady, + }); }); - }); - 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' }, - }; - - 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: eventData.customMetrics, - meta: { - queryRangeSecs: 900, - queryOffsetSecs: 0, + 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' }, + }; + + 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: eventData.customMetrics, + meta: { + queryRangeSecs: 900, + queryFromOffsetSecs: -900, + queryToOffsetSecs: 0, + isInitialLoad: true, + }, }, - }, - end: 'end::pageReady', - start: 'start::pageChange', + end: 'end::pageReady', + start: 'start::pageChange', + }); }); - }); - 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 - - const eventData = { - meta: { rangeFrom: '2024-12-09T00:00:00Z', rangeTo: '2024-12-09T00:30:00Z' }, - }; - - 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, + 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-09T00:00:00Z', rangeTo: '2024-12-09T00:30: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: 1800, + queryFromOffsetSecs: -1800, + queryToOffsetSecs: 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 - - const eventData = { - meta: { rangeFrom: '2024-12-08T00:00:00Z', rangeTo: '2024-12-09T00:00:00Z' }, - }; - - 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: 86400, - queryOffsetSecs: -1800, + 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-08T00:00:00Z', rangeTo: '2024-12-09T00: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, + queryFromOffsetSecs: -88200, + queryToOffsetSecs: -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 interaction = measureInteraction(); - const pathname = '/test-path'; - 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(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: 86400, - queryOffsetSecs: 1800, + 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, + queryFromOffsetSecs: -84600, + queryToOffsetSecs: 1800, + isInitialLoad: true, + }, }, - }, - end: 'end::pageReady', - start: 'start::pageChange', + 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'; - - interaction.pageReady(pathname); - interaction.pageReady(pathname); - - expect(performance.measure).toHaveBeenCalledTimes(1); + 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); + + 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:refresh] - ${pathname}`, { + detail: { + eventName: 'kibana:plugin_render_time', + type: 'kibana:performance', + customMetrics: undefined, + meta: { + queryRangeSecs: 86400, + queryFromOffsetSecs: -84600, + queryToOffsetSecs: 1800, + isInitialLoad: false, + }, + }, + end: 'end::pageReady', + start: 'start::pageRefresh', + }); + }); }); }); diff --git a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/performance_context.tsx b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/performance_context.tsx index c3e60270c6ac4..51a485b6ede17 100644 --- a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/performance_context.tsx +++ b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/performance_context.tsx @@ -13,13 +13,15 @@ import { useLocation } from 'react-router-dom'; import { PerformanceApi, PerformanceContext } from './use_performance_context'; import { PerformanceMetricEvent } from '../../performance_metric_events'; import { measureInteraction } from './measure_interaction'; - +import { DescriptionWithPrefix } from './types'; export type CustomMetrics = Omit; export interface Meta { - rangeFrom: string; - rangeTo: string; + rangeFrom?: string; + rangeTo?: string; + description?: DescriptionWithPrefix; } + export interface EventData { customMetrics?: CustomMetrics; meta?: Meta; @@ -28,7 +30,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 +47,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 {children}; diff --git a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/types.ts b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/types.ts new file mode 100644 index 0000000000000..5591880fd2ac0 --- /dev/null +++ b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +type ApmPageId = 'services' | 'traces' | 'dependencies'; +type InfraPageId = 'hosts'; +type OnboardingPageId = 'onboarding'; + +export type Key = `${ApmPageId}` | `${InfraPageId}` | `${OnboardingPageId}`; + +export type DescriptionWithPrefix = `[ttfmp_${Key}] ${string}`; diff --git a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/use_performance_context.tsx b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/use_performance_context.tsx index a2fab435778e1..68d724b7bf7cb 100644 --- a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/use_performance_context.tsx +++ b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/context/use_performance_context.tsx @@ -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(undefined); diff --git a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/performance_markers.ts b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/performance_markers.ts index 75a807e33b5c8..f5d9aef03135e 100644 --- a/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/performance_markers.ts +++ b/src/platform/packages/shared/kbn-ebt-tools/src/performance_metrics/performance_markers.ts @@ -11,4 +11,5 @@ export const perfomanceMarkers = { startPageChange: 'start::pageChange', endPageReady: 'end::pageReady', + startPageRefresh: 'start::pageRefresh', }; diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index 98cdc1e65c3a2..a68d4fac8306c 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -11,6 +11,13 @@ import React from 'react'; import * as stories from './service_overview.stories'; import * as useAdHocApmDataView from '../../../hooks/use_adhoc_apm_data_view'; +// Mock the usePerformanceContext hook +jest.mock('@kbn/ebt-tools', () => ({ + usePerformanceContext: () => ({ + onPageReady: jest.fn(), + }), +})); + const { Example } = composeStories(stories); describe('ServiceOverview', () => { diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/search_bar/search_bar.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/search_bar/search_bar.test.tsx index 04f1b4f39352f..6dd4063e64452 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/search_bar/search_bar.test.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/search_bar/search_bar.test.tsx @@ -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({ - + + + @@ -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 () => { diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/unified_search_bar/index.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/unified_search_bar/index.tsx index ac2b67b8b9c94..fdf653be70e8c 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/unified_search_bar/index.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/unified_search_bar/index.tsx @@ -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( UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS ); @@ -204,6 +206,7 @@ export function UnifiedSearchBar({ const onRefresh = () => { clearCache(); incrementTimeRangeId(); + onPageRefreshStart(); }; const onRefreshChange = ({ isPaused, refreshInterval }: Partial) => { diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/unified_search_bar/unified_search_bar.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/unified_search_bar/unified_search_bar.test.tsx index 7600cc6c0e17f..45864875b526b 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/unified_search_bar/unified_search_bar.test.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/unified_search_bar/unified_search_bar.test.tsx @@ -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 } > - + + + ); @@ -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); + }); }); }); diff --git a/x-pack/solutions/observability/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/solutions/observability/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index 759cfa8a8c819..3b152a26e70ae 100644 --- a/x-pack/solutions/observability/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -216,6 +216,9 @@ export function MockApmPluginContextWrapper({ createCallApmApi(contextValue.core); } + performance.mark = jest.fn(); + performance.clearMeasures = jest.fn(); + const contextHistory = useHistory(); const usedHistory = useMemo(() => { diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx index 8d522cebb5102..16833516fa431 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/hosts/components/search_bar/unified_search_bar.tsx @@ -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 ( diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.test.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.test.tsx index 0e70071f199ff..59030e0865707 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.test.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.test.tsx @@ -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<{}>) => ( - {children} + + {children} + ), }); @@ -73,6 +82,13 @@ describe('useMetricsExplorerState', () => { }); delete STORE.MetricsExplorerOptions; delete STORE.MetricsExplorerTimeRange; + + const pathname = '/hosts'; + (useLocation as jest.Mock).mockReturnValue(() => ({ + pathname, + })); + performance.mark = jest.fn(); + performance.clearMeasures = jest.fn(); }); afterEach(() => { @@ -88,9 +104,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', () => { diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts index 019936f66be85..53653c218892e 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts @@ -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, diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx index 64888c8bdf83b..de29813a08b26 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx @@ -6,8 +6,9 @@ */ 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'; import { InfraPageTemplate } from '../../../components/shared/templates/infra_page_template'; import { WithMetricsExplorerOptionsUrlState } from '../../../containers/metrics_explorer/with_metrics_explorer_options_url_state'; @@ -64,6 +65,8 @@ const MetricsExplorerContent = () => { const { currentView } = useMetricsExplorerViews(); const { kibanaVersion, isCloudEnv, isServerlessEnv } = useKibanaEnvironmentContext(); + const prevDataRef = useRef(data); + const { onPageReady } = usePerformanceContext(); useTrackPageview({ app: 'infra_metrics', path: 'metrics_explorer' }); useTrackPageview({ app: 'infra_metrics', path: 'metrics_explorer', delay: 15000 }); @@ -93,6 +96,19 @@ const MetricsExplorerContent = () => { currentTimerange: timeRange, }; + 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 (