[8.x] [Performance] Refactor TTFMP query from, to fields (#213911) (#217090)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Performance] Refactor TTFMP query `from`, `to` fields
(#213911)](https://github.com/elastic/kibana/pull/213911)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Abdul Wahab
Zahid","email":"awahab07@yahoo.com"},"sourceCommit":{"committedDate":"2025-03-20T10:40:24Z","message":"[Performance]
Refactor TTFMP query `from`, `to` fields (#213911)\n\nCurrently Kibana
forwards `query_range_secs` and `query_offset_secs` to\nmark the
selected time range when reporting TTFMP event. This format\ncaused some
challenges to identify `from`, `to` date offsets
in\nvisualizations.\n\nTo simplify, the PR renames and sends the three
fields explicitly:\n- `query_from_offset_secs` offset to `0` (now), with
-ve for past and\n+ve for future dates\n- `query_to_offset_secs` offset
to `0` (now), with -ve for past and +ve\nfor future dates\n-
`query_range_secs` same as previously sent\n\n_This approach is followed
after a discussion, and based on
the\n[gist](https://gist.github.com/andrewvc/1f04a57a336d768e4ec5ff2eff06ba54)\nexcerpt:_\n\n```\nEarliest
date -> QueryFrom\nNewest date -> QueryTo\nDuration ->
QueryRange\n```\n\n### Indexing\nThese fields then should be mapped in
the EBT indexer to ingest in the\ntop level of the document, eventually
removing the need to create\nruntime fields in data views for
visualizations.\n\nAlso, runtime fields in data views should be updated
to reflect this\nchange. For backward compatibility, the runtime fields
can cater both\nthe old and new field names conditionally.\n\n###
Testing\n- Ensure that the TTFMP events are correctly reporting the date
ranges.\n\n###
Example\n\n![image](https://github.com/user-attachments/assets/529507fc-66f7-440a-8bbb-b34176e8d093)","sha":"e6e78ac6d83fe9c4a83785c717fb1b7f3fedbf0e","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport
missing","v9.1.0","v8.19.0"],"title":"[Performance] Refactor TTFMP query
`from`, `to`
fields","number":213911,"url":"https://github.com/elastic/kibana/pull/213911","mergeCommit":{"message":"[Performance]
Refactor TTFMP query `from`, `to` fields (#213911)\n\nCurrently Kibana
forwards `query_range_secs` and `query_offset_secs` to\nmark the
selected time range when reporting TTFMP event. This format\ncaused some
challenges to identify `from`, `to` date offsets
in\nvisualizations.\n\nTo simplify, the PR renames and sends the three
fields explicitly:\n- `query_from_offset_secs` offset to `0` (now), with
-ve for past and\n+ve for future dates\n- `query_to_offset_secs` offset
to `0` (now), with -ve for past and +ve\nfor future dates\n-
`query_range_secs` same as previously sent\n\n_This approach is followed
after a discussion, and based on
the\n[gist](https://gist.github.com/andrewvc/1f04a57a336d768e4ec5ff2eff06ba54)\nexcerpt:_\n\n```\nEarliest
date -> QueryFrom\nNewest date -> QueryTo\nDuration ->
QueryRange\n```\n\n### Indexing\nThese fields then should be mapped in
the EBT indexer to ingest in the\ntop level of the document, eventually
removing the need to create\nruntime fields in data views for
visualizations.\n\nAlso, runtime fields in data views should be updated
to reflect this\nchange. For backward compatibility, the runtime fields
can cater both\nthe old and new field names conditionally.\n\n###
Testing\n- Ensure that the TTFMP events are correctly reporting the date
ranges.\n\n###
Example\n\n![image](https://github.com/user-attachments/assets/529507fc-66f7-440a-8bbb-b34176e8d093)","sha":"e6e78ac6d83fe9c4a83785c717fb1b7f3fedbf0e"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/213911","number":213911,"mergeCommit":{"message":"[Performance]
Refactor TTFMP query `from`, `to` fields (#213911)\n\nCurrently Kibana
forwards `query_range_secs` and `query_offset_secs` to\nmark the
selected time range when reporting TTFMP event. This format\ncaused some
challenges to identify `from`, `to` date offsets
in\nvisualizations.\n\nTo simplify, the PR renames and sends the three
fields explicitly:\n- `query_from_offset_secs` offset to `0` (now), with
-ve for past and\n+ve for future dates\n- `query_to_offset_secs` offset
to `0` (now), with -ve for past and +ve\nfor future dates\n-
`query_range_secs` same as previously sent\n\n_This approach is followed
after a discussion, and based on
the\n[gist](https://gist.github.com/andrewvc/1f04a57a336d768e4ec5ff2eff06ba54)\nexcerpt:_\n\n```\nEarliest
date -> QueryFrom\nNewest date -> QueryTo\nDuration ->
QueryRange\n```\n\n### Indexing\nThese fields then should be mapped in
the EBT indexer to ingest in the\ntop level of the document, eventually
removing the need to create\nruntime fields in data views for
visualizations.\n\nAlso, runtime fields in data views should be updated
to reflect this\nchange. For backward compatibility, the runtime fields
can cater both\nthe old and new field names conditionally.\n\n###
Testing\n- Ensure that the TTFMP events are correctly reporting the date
ranges.\n\n###
Example\n\n![image](https://github.com/user-attachments/assets/529507fc-66f7-440a-8bbb-b34176e8d093)","sha":"e6e78ac6d83fe9c4a83785c717fb1b7f3fedbf0e"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Abdul Wahab Zahid 2025-04-07 17:45:57 +02:00 committed by GitHub
parent bbe76886d0
commit a6e6eddf47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 456 additions and 169 deletions

View file

@ -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",

View file

@ -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,8 @@ describe('trackPerformanceMeasureEntries', () => {
type: 'kibana:performance',
meta: {
queryRangeSecs: 86400,
queryOffsetSecs: 0,
queryFromOffsetSecs: -86400,
queryToOffsetSecs: 0,
},
},
},
@ -152,7 +164,84 @@ 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_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',
},
},
},
]);
trackPerformanceMeasureEntries(analyticsClientMock, true);
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:
'[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);
});
});

View file

@ -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) {

View file

@ -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 (!trackedRoutes.includes(pathname)) {
performance.measure(pathname, {
if (eventData?.meta?.description) {
performanceMeta.description = eventData.meta.description;
}
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,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();
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,
queryFromOffsetSecs: -900,
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
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,
queryFromOffsetSecs: -1800,
queryToOffsetSecs: 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,
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 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',
});
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,
queryFromOffsetSecs: -84600,
queryToOffsetSecs: 1800,
isInitialLoad: false,
},
},
end: 'end::pageReady',
start: 'start::pageRefresh',
});
});
});
});

View file

@ -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<PerformanceMetricEvent, 'eventName' | 'meta' | 'duration'>;
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 <PerformanceContext.Provider value={api}>{children}</PerformanceContext.Provider>;

View file

@ -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}`;

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

@ -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

@ -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

@ -218,6 +218,9 @@ export function MockApmPluginContextWrapper({
createCallApmApi(contextValue.core);
}
performance.mark = jest.fn();
performance.clearMeasures = 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,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', () => {

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,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 (
<InfraPageTemplate
onboardingFlow={OnboardingFlow.Infra}