[POC] Track time to first meaningful paint (#180309)

part of https://github.com/elastic/observability-dev/issues/3269 

#### Time to First Meaningful Paint (TTFMP)


Measures the time from the start of navigation to the point at which the
most meaningful element appears on the screen. TTFMP is instrumented
differently on each page as the most meaningful element varies from page
to page.

example: 
```
event_type : "performance_metric" and eventName: "kibana:plugin_render_time" 
```

#### Usage

To instrument TTFMP you need to `PerfomanceContextProvider` at the root
at the app and run `onPageReady` function once meaningful data are
fetched . The meaningful data can be one or more


```

import { usePerformanceContext } from '@kbn/ebt-tools';
const { onPageReady } = usePerformanceContext();

```

#### Current instrumentation

Based on telemetry, I instrumented the TTFMP for most viewed pages in
apm and infra:

1. /services 
2. /traces
3. /service-map
4. /hosts
5. infra /inventory

All pages except hosts have one component so the page ready once the
data is loaded for the component. For hosts I set it when the hosts list
table and the hosts number is loaded.
#### Telemetry  

Example dashboard of the metrics I sent from my local setup
-
[Dashboard](f240fff6-fac9-491b-81d1-ac39006c5c94?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:now-7d,to:now)))


## Updates for reviewers
Previously the PR was checking both the component AND the time to first
meaningful paint. We decided to focus on the time to first meaningful
paint


### Notes
TTFMP is subject to change for each page based on what we're going to
define as meaningful

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Katerina 2024-05-14 15:04:00 +03:00 committed by GitHub
parent 599f01598e
commit a19143c198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 334 additions and 69 deletions

View file

@ -102,6 +102,7 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@elastic/apm-rum": "^5.16.0",
"@elastic/apm-rum-core": "^5.21.0",
"@elastic/apm-rum-react": "^2.0.2",
"@elastic/charts": "64.1.0",
"@elastic/datemath": "5.0.3",

View file

@ -23,10 +23,22 @@ function findRegisteredEventTypeByName(eventTypeName: string) {
([{ eventType }]) => eventType === eventTypeName
)!;
}
interface MockEntryList {
getEntries: () => [object];
}
type ObsCallback = (_entries: MockEntryList, _obs: object) => undefined;
describe('AnalyticsService', () => {
let analyticsService: AnalyticsService;
beforeEach(() => {
const mockObs = { observe: jest.fn, disconnect: jest.fn };
const mockPerformanceObserver = function (callback: ObsCallback) {
callback({ getEntries: () => [{}] }, mockObs);
return mockObs;
};
(global.PerformanceObserver as unknown) = mockPerformanceObserver;
jest.clearAllMocks();
analyticsService = new AnalyticsService(coreContextMock.create());
});

View file

@ -13,7 +13,9 @@ import { registerPerformanceMetricEventType } from '@kbn/ebt-tools';
import type { CoreContext } from '@kbn/core-base-browser-internal';
import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal';
import type { AnalyticsServiceSetup, AnalyticsServiceStart } from '@kbn/core-analytics-browser';
import { trackPerformanceMeasureEntries } from './track_performance_measure_entries';
import { trackClicks } from './track_clicks';
import { getSessionId } from './get_session_id';
import { createLogger } from './logger';
import { trackViewportSize } from './track_viewport_size';
@ -44,6 +46,9 @@ export class AnalyticsService {
this.registerSessionIdContext();
this.registerBrowserInfoAnalyticsContext();
this.subscriptionsHandler.add(trackClicks(this.analyticsClient, core.env.mode.dev));
this.subscriptionsHandler.add(
trackPerformanceMeasureEntries(this.analyticsClient, core.env.mode.dev)
);
this.subscriptionsHandler.add(trackViewportSize(this.analyticsClient));
// Register a flush method in the browser so CI can explicitly call it before closing the browser.

View file

@ -0,0 +1,66 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { AnalyticsClient } from '@kbn/analytics-client';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
export function trackPerformanceMeasureEntries(analytics: AnalyticsClient, isDevMode: boolean) {
function perfObserver(
list: PerformanceObserverEntryList,
observer: PerformanceObserver,
droppedEntriesCount: number
) {
list.getEntries().forEach((entry: any) => {
if (entry.entryType === 'measure' && entry.detail?.type === 'kibana:performance') {
const target = entry?.name;
const duration = entry.duration;
if (isDevMode) {
if (!target) {
// eslint-disable-next-line no-console
console.error(`Failed to report the performance entry. Measure name is undefined`);
}
if (!duration) {
// eslint-disable-next-line no-console
console.error(
`Failed to report the performance entry. Duration for the measure: ${target} is undefined`
);
}
// eslint-disable-next-line no-console
console.log(`The measure ${target} completed in ${duration / 1000}s`);
}
if (droppedEntriesCount > 0) {
// eslint-disable-next-line no-console
console.warn(
`${droppedEntriesCount} performance entries got dropped due to the buffer being full.`
);
}
try {
reportPerformanceMetricEvent(analytics, {
eventName: entry.detail.eventName,
duration,
meta: {
target,
},
});
} catch (error) {
if (isDevMode) {
// eslint-disable-next-line no-console
console.error(`Failed to report the performance event`, { event, error });
}
}
}
});
}
const observer = new PerformanceObserver(perfObserver as PerformanceObserverCallback);
observer.observe({ type: 'measure', buffered: true });
}

View file

@ -6,4 +6,6 @@
* Side Public License, v 1.
*/
export * from './src/performance_metrics';
export * from './src/performance_metric_events';

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 or the Server
* Side Public License, v 1.
*/
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';
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.
*/
pageReady(pathname: string) {
performance.mark(perfomanceMarkers.endPageReady);
if (!trackedRoutes.includes(pathname)) {
performance.measure(pathname, {
detail: { eventName: 'kibana:plugin_render_time', type: 'kibana:performance' },
start: perfomanceMarkers.startPageChange,
end: perfomanceMarkers.endPageReady,
});
trackedRoutes.push(pathname);
}
},
};
}
export function PerformanceContextProvider({ children }: { children: React.ReactElement }) {
const [isRendered, setIsRendered] = useState(false);
const location = useLocation();
const interaction = measureInteraction();
React.useEffect(() => {
afterFrame(() => {
setIsRendered(true);
});
return () => {
setIsRendered(false);
performance.clearMeasures(location.pathname);
};
}, [location.pathname]);
const api = useMemo<PerformanceApi>(
() => ({
onPageReady() {
if (isRendered) {
interaction.pageReady(location.pathname);
}
},
}),
[isRendered, location.pathname, interaction]
);
return <PerformanceContext.Provider value={api}>{children}</PerformanceContext.Provider>;
}
// dynamic import
// eslint-disable-next-line import/no-default-export
export default PerformanceContextProvider;

View file

@ -0,0 +1,25 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { createContext, useContext } from 'react';
export interface PerformanceApi {
onPageReady(): void;
}
export const PerformanceContext = createContext<PerformanceApi | undefined>(undefined);
export function usePerformanceContext() {
const api = useContext(PerformanceContext);
if (!api) {
throw new Error('Missing Performance API in context');
}
return api;
}

View file

@ -0,0 +1,25 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { Suspense } from 'react';
type Loader<TElement extends React.ComponentType<any>> = () => Promise<{
default: TElement;
}>;
function dynamic<TElement extends React.ComponentType<any>, TRef = {}>(loader: Loader<TElement>) {
const Component = React.lazy(loader);
return React.forwardRef<TRef, React.ComponentPropsWithRef<TElement>>((props, ref) => (
<Suspense fallback={null}>{React.createElement(Component, { ...props, ref })}</Suspense>
));
}
export { usePerformanceContext } from './context/use_performance_context';
export { perfomanceMarkers } from './performance_markers';
export const PerformanceContextProvider = dynamic(() => import('./context/performance_context'));

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
// Time To First Meaningful Paint (ttfmp)
export const perfomanceMarkers = {
startPageChange: 'start::pageChange',
endPageReady: 'end::pageReady',
};

View file

@ -0,0 +1,11 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
declare module '@elastic/apm-rum-core' {
export function afterFrame(callback: () => void): void;
}

View file

@ -2,19 +2,9 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
"types": ["jest", "node"]
},
"include": [
"**/*.ts"
],
"kbn_references": [
"@kbn/analytics-client",
"@kbn/logging-mocks",
],
"exclude": [
"target/**/*",
]
"include": ["**/*.ts", "**/*.tsx"],
"kbn_references": ["@kbn/analytics-client", "@kbn/logging-mocks"],
"exclude": ["target/**/*"]
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { usePerformanceContext } from '@kbn/ebt-tools';
import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
@ -177,6 +178,7 @@ function useServicesDetailedStatisticsFetcher({
export function ServiceInventory() {
const [debouncedSearchQuery, setDebouncedSearchQuery] = useStateDebounced('');
const { onPageReady } = usePerformanceContext();
const [renderedItems, setRenderedItems] = useState<ServiceListItem[]>([]);
@ -281,6 +283,15 @@ export function ServiceInventory() {
});
}, [mainStatisticsStatus, mainStatisticsData.items, setScreenContext]);
useEffect(() => {
if (
mainStatisticsStatus === FETCH_STATUS.SUCCESS &&
comparisonFetch.status === FETCH_STATUS.SUCCESS
) {
onPageReady();
}
}, [mainStatisticsStatus, comparisonFetch.status, onPageReady]);
const { fields, isSaving, saveSingleSetting } = useEditableSettings([
apmEnableServiceInventoryTableSearchBar,
]);

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { usePerformanceContext } from '@kbn/ebt-tools';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import React, { ReactNode } from 'react';
import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context';
@ -95,6 +96,7 @@ export function ServiceMap({
const license = useLicenseContext();
const serviceName = useServiceName();
const { config } = useApmPluginContext();
const { onPageReady } = usePerformanceContext();
const {
data = { elements: [] },
@ -172,6 +174,10 @@ export function ServiceMap({
);
}
if (status === FETCH_STATUS.SUCCESS) {
onPageReady();
}
return (
<>
<SearchBar showTimeComparison />

View file

@ -6,9 +6,10 @@
*/
import { EuiIcon, EuiToolTip, RIGHT_ALIGNMENT } from '@elastic/eui';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { TypeOf } from '@kbn/typed-react-router-config';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { ApmRoutes } from '../../routing/apm_route_config';
import { asMillisecondDuration, asTransactionRate } from '../../../../common/utils/formatters';
@ -132,10 +133,18 @@ const noItemsMessage = (
export function TraceList({ response }: Props) {
const { data: { items } = { items: [] }, status } = response;
const { onPageReady } = usePerformanceContext();
const { query } = useApmParams('/traces');
const traceListColumns = useMemo(() => getTraceListColumns({ query }), [query]);
useEffect(() => {
if (status === FETCH_STATUS.SUCCESS) {
onPageReady();
}
}, [status, onPageReady]);
return (
<ManagedTable
isLoading={status === FETCH_STATUS.LOADING}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { PerformanceContextProvider } from '@kbn/ebt-tools';
import { APP_WRAPPER_CLASS } from '@kbn/core/public';
import { KibanaContextProvider, useDarkMode } from '@kbn/kibana-react-plugin/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
@ -71,36 +72,37 @@ export function ApmAppRoot({
<i18nCore.Context>
<TimeRangeIdContextProvider>
<RouterProvider history={history} router={apmRouter as any}>
<ApmErrorBoundary>
<RedirectDependenciesToDependenciesInventory>
<RedirectWithDefaultEnvironment>
<RedirectWithDefaultDateRange>
<RedirectWithOffset>
<TrackPageview>
<UpdateExecutionContextOnRouteChange>
<BreadcrumbsContextProvider>
<UrlParamsProvider>
<LicenseProvider>
<AnomalyDetectionJobsContextProvider>
<InspectorContextProvider>
<ApmThemeProvider>
<MountApmHeaderActionMenu />
<Route component={ScrollToTopOnPathChange} />
<RouteRenderer />
</ApmThemeProvider>
</InspectorContextProvider>
</AnomalyDetectionJobsContextProvider>
</LicenseProvider>
</UrlParamsProvider>
</BreadcrumbsContextProvider>
</UpdateExecutionContextOnRouteChange>
</TrackPageview>
</RedirectWithOffset>
</RedirectWithDefaultDateRange>
</RedirectWithDefaultEnvironment>
</RedirectDependenciesToDependenciesInventory>
</ApmErrorBoundary>
<PerformanceContextProvider>
<ApmErrorBoundary>
<RedirectDependenciesToDependenciesInventory>
<RedirectWithDefaultEnvironment>
<RedirectWithDefaultDateRange>
<RedirectWithOffset>
<TrackPageview>
<UpdateExecutionContextOnRouteChange>
<BreadcrumbsContextProvider>
<UrlParamsProvider>
<LicenseProvider>
<AnomalyDetectionJobsContextProvider>
<InspectorContextProvider>
<ApmThemeProvider>
<MountApmHeaderActionMenu />
<Route component={ScrollToTopOnPathChange} />
<RouteRenderer />
</ApmThemeProvider>
</InspectorContextProvider>
</AnomalyDetectionJobsContextProvider>
</LicenseProvider>
</UrlParamsProvider>
</BreadcrumbsContextProvider>
</UpdateExecutionContextOnRouteChange>
</TrackPageview>
</RedirectWithOffset>
</RedirectWithDefaultDateRange>
</RedirectWithDefaultEnvironment>
</RedirectDependenciesToDependenciesInventory>
</ApmErrorBoundary>
</PerformanceContextProvider>
</RouterProvider>
</TimeRangeIdContextProvider>
</i18nCore.Context>

View file

@ -76,7 +76,7 @@ export function SparkPlot({
);
}
function SparkPlotItem({
export function SparkPlotItem({
type,
color,
isLoading,

View file

@ -116,7 +116,8 @@
"@kbn/react-kibana-context-theme",
"@kbn/core-http-request-handler-context-server",
"@kbn/search-types",
"@kbn/presentation-publishing",
"@kbn/ebt-tools",
"@kbn/presentation-publishing"
],
"exclude": ["target/**/*"]
}

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PerformanceContextProvider } from '@kbn/ebt-tools';
import { History } from 'history';
import { CoreStart } from '@kbn/core/public';
import React from 'react';
@ -99,12 +99,14 @@ const MetricsApp: React.FC<{
<SourceProvider sourceId="default">
<PluginConfigProvider value={pluginConfig}>
<Router history={history}>
<Routes>
<Route path="/link-to" component={LinkToMetricsPage} />
{uiCapabilities?.infrastructure?.show && (
<Route path="/" component={InfrastructurePage} />
)}
</Routes>
<PerformanceContextProvider>
<Routes>
<Route path="/link-to" component={LinkToMetricsPage} />
{uiCapabilities?.infrastructure?.show && (
<Route path="/" component={InfrastructurePage} />
)}
</Routes>
</PerformanceContextProvider>
</Router>
</PluginConfigProvider>
</SourceProvider>

View file

@ -27,21 +27,21 @@ export const HostsContent = () => {
) : (
<HostsViewProvider>
<HostsTableProvider>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem grow={false}>
<HostCountProvider>
<HostCountProvider>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem grow={false}>
<KPIGrid />
</HostCountProvider>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<HostsTable />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertsQueryProvider>
<Tabs />
</AlertsQueryProvider>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<HostsTable />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AlertsQueryProvider>
<Tabs />
</AlertsQueryProvider>
</EuiFlexItem>
</EuiFlexGroup>
</HostCountProvider>
</HostsTableProvider>
</HostsViewProvider>
)}

View file

@ -5,18 +5,22 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { usePerformanceContext } from '@kbn/ebt-tools';
import { EuiBasicTable } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiEmptyPrompt } from '@elastic/eui';
import { HostNodeRow, useHostsTableContext } from '../hooks/use_hosts_table';
import { useHostsViewContext } from '../hooks/use_hosts_view';
import { useHostCountContext } from '../hooks/use_host_count';
import { FlyoutWrapper } from './host_details_flyout/flyout_wrapper';
import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '../constants';
import { FilterAction } from './table/filter_action';
export const HostsTable = () => {
const { loading } = useHostsViewContext();
const { isRequestRunning: hostCountLoading } = useHostCountContext();
const { onPageReady } = usePerformanceContext();
const {
columns,
@ -33,6 +37,12 @@ export const HostsTable = () => {
filterSelectedHosts,
} = useHostsTableContext();
useEffect(() => {
if (!loading && !hostCountLoading) {
onPageReady();
}
}, [loading, hostCountLoading, onPageReady]);
return (
<>
<FilterAction

View file

@ -6,6 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { usePerformanceContext } from '@kbn/ebt-tools';
import React, { useCallback } from 'react';
import { useCurrentEuiBreakpoint } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
@ -63,6 +64,7 @@ export const NodesOverview = ({
}: Props) => {
const currentBreakpoint = useCurrentEuiBreakpoint();
const [{ detailsItemId }, setFlyoutUrlState] = useAssetDetailsFlyoutState();
const { onPageReady } = usePerformanceContext();
const closeFlyout = useCallback(
() => setFlyoutUrlState({ detailsItemId: null }),
@ -115,6 +117,10 @@ export const NodesOverview = ({
const bounds = autoBounds ? dataBounds : boundsOverride;
const isStatic = ['xs', 's'].includes(currentBreakpoint!);
if (!loading) {
onPageReady();
}
if (view === 'table') {
return (
<TableContainer>