[Perfomance] Track time range picker with onPageReady function (#202889)

## Summary

closes https://github.com/elastic/observability-dev/issues/3377
## Metrics 
#### `meta.query_range_secs` - The duration of the selected time range
in seconds.
#### `meta.query_offset_secs` - The offset from "now" to the
'rangeTo'/end' time picker value in seconds.

____

Extend the `onPageReady` function to support date ranges in the meta
field. The function should compute the query range in seconds based on
the provided time range and report it to telemetry as
meta.query_range_secs.




If the `rangeTo` is different from 'now', calculate the offset. 
- A negative offset indicates that the rangeTo is in the past, 
- a positive offset means it is in the future, 
- and zero indicates that the rangeTo is exactly 'now'." 



### How to instrument
To report the selected time range, pass the `rangeFrom` and `rangeTo` . 
> Failing to pass the correct type will result in TS error.


Then, use this data when invoking onPageReady:
```
 onPageReady({
        meta: { rangeFrom, rangeTo },
 });
```

### Analysis 

Meta is flatten field. In order to aggregate the data it's necessary to
create a run time field. You can add a field in the

1. select data view (`ebt-kibana-*-performance-metrics`) 
2. Add a new field
3. Type double
4. Set value 

`query_range_secs`
```
def meta = doc[“meta”].size();
if (meta > 0) {
    def range = doc[“meta.query_range_secs”].size();
    if (range > 0) {
        // Emit the value of ‘meta.target’
        emit(Double.parseDouble(doc[“meta.query_range_secs”].value));
    }
}

```

`query_offset_secs` 
```

def meta = doc[“meta”].size();
if (meta > 0) {
    def offset = doc[“meta.query_offset_secs”].size();
    if (offset > 0) {
     
        emit(Double.parseDouble(doc[“meta.query_offset_secs”].value));
    }
}

```







### Examples


<img width="1478" alt="Screenshot 2024-12-09 at 19 51 32"
src="https://github.com/user-attachments/assets/72f796e1-4f20-487f-b62a-b6a4aead9a4a">

<img width="1478" alt="Screenshot 2024-12-09 at 19 56 08"
src="https://github.com/user-attachments/assets/c278dc3b-e6f3-47ed-9c90-954d71b59161">

<img width="1478" alt="Screenshot 2024-12-09 at 19 53 45 1"
src="https://github.com/user-attachments/assets/ef42ecef-48cd-4396-9f5d-c971098d5219">





### Notes
- Instrumented only 2 solutions as an example (dataset and apm services)

### TODO
- [x] Update documentation -
https://github.com/elastic/kibana/pull/204179
- [ ] Update dashboards (create a runtime field) 
- [x] Track offset ( we need to know if the user selected now or now)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Katerina 2024-12-13 15:11:44 +02:00 committed by GitHub
parent 492d4d2e5f
commit bd1c00fd65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 398 additions and 58 deletions

View file

@ -128,4 +128,31 @@ describe('trackPerformanceMeasureEntries', () => {
value1: 'value1',
});
});
test('reports an analytics event with query metadata', () => {
setupMockPerformanceObserver([
{
name: '/',
entryType: 'measure',
startTime: 100,
duration: 1000,
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
meta: {
queryRangeSecs: 86400,
queryOffsetSecs: 0,
},
},
},
]);
trackPerformanceMeasureEntries(analyticsClientMock, true);
expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(1);
expect(analyticsClientMock.reportEvent).toHaveBeenCalledWith('performance_metric', {
duration: 1000,
eventName: 'kibana:plugin_render_time',
meta: { target: '/', query_range_secs: 86400, query_offset_secs: 0 },
});
});
});

View file

@ -27,6 +27,7 @@ export function trackPerformanceMeasureEntries(analytics: AnalyticsClient, isDev
if (entry.entryType === 'measure' && entry.detail?.type === 'kibana:performance') {
const target = entry?.name;
const duration = entry.duration;
const meta = entry.detail?.meta;
const customMetrics = Object.keys(entry.detail?.customMetrics ?? {}).reduce(
(acc, metric) => {
if (ALLOWED_CUSTOM_METRICS_KEYS_VALUES.includes(metric)) {
@ -72,6 +73,8 @@ export function trackPerformanceMeasureEntries(analytics: AnalyticsClient, isDev
...customMetrics,
meta: {
target,
query_range_secs: meta?.queryRangeSecs,
query_offset_secs: meta?.queryOffsetSecs,
},
});
} catch (error) {

View file

@ -25,6 +25,7 @@ SHARED_DEPS = [
"@npm//@elastic/apm-rum-core",
"@npm//react",
"@npm//react-router-dom",
"//packages/kbn-timerange"
]
js_library(

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 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".
*/
import {
getDateRange,
getOffsetFromNowInSeconds,
getTimeDifferenceInSeconds,
} from '@kbn/timerange';
import { perfomanceMarkers } from '../../performance_markers';
import { EventData } from '../performance_context';
interface PerformanceMeta {
queryRangeSecs: number;
queryOffsetSecs: number;
}
export function measureInteraction() {
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;
performance.mark(perfomanceMarkers.endPageReady);
if (eventData?.meta) {
const { rangeFrom, rangeTo } = eventData.meta;
// Convert the date range to epoch timestamps (in milliseconds)
const dateRangesInEpoch = getDateRange({
from: rangeFrom,
to: rangeTo,
});
performanceMeta = {
queryRangeSecs: getTimeDifferenceInSeconds(dateRangesInEpoch),
queryOffsetSecs:
rangeTo === 'now' ? 0 : getOffsetFromNowInSeconds(dateRangesInEpoch.endDate),
};
}
if (!trackedRoutes.includes(pathname)) {
performance.measure(pathname, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics: eventData?.customMetrics,
meta: performanceMeta,
},
start: perfomanceMarkers.startPageChange,
end: perfomanceMarkers.endPageReady,
});
trackedRoutes.push(pathname);
}
},
};
}

View file

@ -0,0 +1,161 @@
/*
* 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".
*/
import { measureInteraction } from '.';
import { perfomanceMarkers } from '../../performance_markers';
describe('measureInteraction', () => {
afterAll(() => {
jest.restoreAllMocks();
});
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,
});
});
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,
},
},
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,
},
},
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,
},
},
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,
},
},
end: 'end::pageReady',
start: 'start::pageChange',
});
});
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);
});
});

View file

@ -10,38 +10,19 @@
import React, { useMemo, useState } from 'react';
import { afterFrame } from '@elastic/apm-rum-core';
import { useLocation } from 'react-router-dom';
import { perfomanceMarkers } from '../performance_markers';
import { PerformanceApi, PerformanceContext } from './use_performance_context';
import { PerformanceMetricEvent } from '../../performance_metric_events';
import { measureInteraction } from './measure_interaction';
export type CustomMetrics = Omit<PerformanceMetricEvent, 'eventName' | 'meta' | 'duration'>;
function measureInteraction() {
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, customMetrics?: CustomMetrics) {
performance.mark(perfomanceMarkers.endPageReady);
if (!trackedRoutes.includes(pathname)) {
performance.measure(pathname, {
detail: {
eventName: 'kibana:plugin_render_time',
type: 'kibana:performance',
customMetrics,
},
start: perfomanceMarkers.startPageChange,
end: perfomanceMarkers.endPageReady,
});
trackedRoutes.push(pathname);
}
},
};
export interface Meta {
rangeFrom: string;
rangeTo: string;
}
export interface EventData {
customMetrics?: CustomMetrics;
meta?: Meta;
}
export function PerformanceContextProvider({ children }: { children: React.ReactElement }) {
@ -61,9 +42,9 @@ export function PerformanceContextProvider({ children }: { children: React.React
const api = useMemo<PerformanceApi>(
() => ({
onPageReady(customMetrics) {
onPageReady(eventData) {
if (isRendered) {
interaction.pageReady(location.pathname, customMetrics);
interaction.pageReady(location.pathname, eventData);
}
},
}),

View file

@ -8,18 +8,22 @@
*/
import { useEffect, useState } from 'react';
import { CustomMetrics } from './performance_context';
import type { CustomMetrics, Meta } from './performance_context';
import { usePerformanceContext } from '../../..';
export const usePageReady = (state: { customMetrics?: CustomMetrics; isReady: boolean }) => {
export const usePageReady = (state: {
customMetrics?: CustomMetrics;
isReady: boolean;
meta?: Meta;
}) => {
const { onPageReady } = usePerformanceContext();
const [isReported, setIsReported] = useState(false);
useEffect(() => {
if (state.isReady && !isReported) {
onPageReady(state.customMetrics);
onPageReady({ customMetrics: state.customMetrics, meta: state.meta });
setIsReported(true);
}
}, [isReported, onPageReady, state.customMetrics, state.isReady]);
}, [isReported, onPageReady, state.customMetrics, state.isReady, state.meta]);
};

View file

@ -8,14 +8,13 @@
*/
import { createContext, useContext } from 'react';
import { CustomMetrics } from './performance_context';
import type { EventData } from './performance_context';
export interface PerformanceApi {
/**
* 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 customMetrics - Custom metrics to be included in the performance measure.
* @param eventData - Data to send with the performance measure, conforming the structure of a {@link EventData}.
*/
onPageReady(customMetrics?: CustomMetrics): void;
onPageReady(eventData?: EventData): void;
}
export const PerformanceContext = createContext<PerformanceApi | undefined>(undefined);

View file

@ -5,8 +5,6 @@
"types": ["jest", "node"]
},
"include": ["**/*.ts", "**/*.tsx"],
"kbn_references": [
"@kbn/logging-mocks"
],
"kbn_references": ["@kbn/logging-mocks", "@kbn/timerange"],
"exclude": ["target/**/*"]
}

View file

@ -0,0 +1,28 @@
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
SRCS = glob(
[
"**/*.ts",
"**/*.js",
],
exclude = [
"**/*.config.js",
"**/*.mock.*",
"**/*.test.*",
"**/*.stories.*",
"**/__snapshots__/**",
"**/integration_tests/**",
"**/mocks/**",
"**/scripts/**",
"**/storybook/**",
"**/test_fixtures/**",
"**/test_helpers/**",
],
)
js_library(
name = "kbn-io-ts-utils",
package_name = "@kbn/io-ts-utils",
srcs = ["package.json"] + SRCS,
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,28 @@
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
SRCS = glob(
[
"**/*.ts",
"**/*.js",
],
exclude = [
"**/*.config.js",
"**/*.mock.*",
"**/*.test.*",
"**/*.stories.*",
"**/__snapshots__/**",
"**/integration_tests/**",
"**/mocks/**",
"**/scripts/**",
"**/storybook/**",
"**/test_fixtures/**",
"**/test_helpers/**",
],
)
js_library(
name = "kbn-timerange",
package_name = "@kbn/timerange",
srcs = ["package.json"] + SRCS,
visibility = ["//visibility:public"],
)

View file

@ -7,4 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { getDateRange, getDateISORange } from './src';
export {
getDateRange,
getDateISORange,
getOffsetFromNowInSeconds,
getTimeDifferenceInSeconds,
} from './src';

View file

@ -1,9 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/timerange",
"owner": [
"@elastic/obs-ux-logs-team"
],
"group": "observability",
"visibility": "private"
}
"owner": ["@elastic/obs-ux-logs-team"],
"group": "platform",
"visibility": "shared"
}

View file

@ -52,3 +52,24 @@ export function getDateISORange({ from, to }: { from: string; to: string }) {
endDate,
};
}
export function getTimeDifferenceInSeconds({
startDate,
endDate,
}: {
startDate: number;
endDate: number;
}): number {
if (!startDate || !endDate || startDate > endDate) {
throw new Error(`Invalid Dates: from: ${startDate}, to: ${endDate}`);
}
const rangeInSeconds = (endDate - startDate) / 1000;
return Math.round(rangeInSeconds);
}
export function getOffsetFromNowInSeconds(epochDate: number) {
const now = Date.now();
return Math.round((epochDate - now) / 1000);
}

View file

@ -180,6 +180,9 @@ export function ServiceInventory() {
const [renderedItems, setRenderedItems] = useState<ServiceListItem[]>([]);
const mainStatisticsFetch = useServicesMainStatisticsFetcher(debouncedSearchQuery);
const { mainStatisticsData, mainStatisticsStatus } = mainStatisticsFetch;
const {
query: { rangeFrom, rangeTo },
} = useApmParams('/services');
const displayHealthStatus = mainStatisticsData.items.some((item) => 'healthStatus' in item);
@ -285,9 +288,11 @@ export function ServiceInventory() {
mainStatisticsStatus === FETCH_STATUS.SUCCESS &&
comparisonFetch.status === FETCH_STATUS.SUCCESS
) {
onPageReady();
onPageReady({
meta: { rangeFrom, rangeTo },
});
}
}, [mainStatisticsStatus, comparisonFetch.status, onPageReady]);
}, [mainStatisticsStatus, comparisonFetch.status, onPageReady, rangeFrom, rangeTo]);
return (
<>

View file

@ -186,7 +186,9 @@ export function ServiceMap({
}
if (status === FETCH_STATUS.SUCCESS) {
onPageReady();
onPageReady({
meta: { rangeFrom: start, rangeTo: end },
});
}
return (

View file

@ -135,15 +135,20 @@ export function TraceList({ response }: Props) {
const { data: { items } = { items: [] }, status } = response;
const { onPageReady } = usePerformanceContext();
const { query } = useApmParams('/traces');
const {
query,
query: { rangeFrom, rangeTo },
} = useApmParams('/traces');
const traceListColumns = useMemo(() => getTraceListColumns({ query }), [query]);
useEffect(() => {
if (status === FETCH_STATUS.SUCCESS) {
onPageReady();
onPageReady({
meta: { rangeFrom, rangeTo },
});
}
}, [status, onPageReady]);
}, [status, onPageReady, rangeFrom, rangeTo]);
return (
<ManagedTable

View file

@ -6,7 +6,6 @@
*/
import React from 'react';
import {
EuiFlexGroup,
EuiPanel,
@ -28,10 +27,12 @@ import {
summaryPanelQualityTooltipText,
} from '../../../../common/translations';
import { mapPercentagesToQualityCounts } from '../../quality_indicator';
import { useDatasetQualityFilters } from '../../../hooks/use_dataset_quality_filters';
import { VerticalRule } from '../../common/vertical_rule';
export function DatasetsQualityIndicators() {
const { onPageReady } = usePerformanceContext();
const { timeRange } = useDatasetQualityFilters();
const {
datasetsQuality,
isDatasetsQualityLoading,
@ -45,10 +46,16 @@ export function DatasetsQualityIndicators() {
if (!isDatasetsQualityLoading && (numberOfDatasets || numberOfDocuments)) {
onPageReady({
key1: 'datasets',
value1: numberOfDatasets,
key2: 'documents',
value2: numberOfDocuments,
customMetrics: {
key1: 'datasets',
value1: numberOfDatasets,
key2: 'documents',
value2: numberOfDocuments,
},
meta: {
rangeFrom: timeRange.from,
rangeTo: timeRange.to,
},
});
}