[dashboard] fix time_to_data does not capture entire client-side rendering (#200640)

Closes https://github.com/elastic/kibana/issues/194489

PR adds new `PublishesRendered` interface. Embeddables can implement
this interface to provide feedback when rendering is complete.

PR updates ReactEmbeddableRender phase tracking logic to include check
for `PublishesRendered` value when interface is implemented.

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-11-25 10:51:05 -07:00 committed by GitHub
parent b6586a95f2
commit cdeb1e9844
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 179 additions and 29 deletions

View file

@ -108,6 +108,7 @@ export {
type PhaseEventType,
type PublishesPhaseEvents,
} from './interfaces/publishes_phase_events';
export { apiPublishesRendered, type PublishesRendered } from './interfaces/publishes_rendered';
export {
apiPublishesSavedObjectId,
type PublishesSavedObjectId,

View file

@ -0,0 +1,20 @@
/*
* 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 { PublishingSubject } from '../publishing_subject';
export interface PublishesRendered {
rendered$: PublishingSubject<boolean>;
}
export const apiPublishesRendered = (
unknownApi: null | unknown
): unknownApi is PublishesRendered => {
return Boolean(unknownApi && (unknownApi as PublishesRendered)?.rendered$ !== undefined);
};

View file

@ -0,0 +1,100 @@
/*
* 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 { BehaviorSubject, skip } from 'rxjs';
import { PhaseTracker } from './phase_tracker';
describe('PhaseTracker', () => {
describe('api does not implement PublishesDataLoading or PublishesRendered', () => {
test(`should emit 'rendered' event`, (done) => {
const phaseTracker = new PhaseTracker();
phaseTracker
.getPhase$()
.pipe(skip(1))
.subscribe((phaseEvent) => {
expect(phaseEvent?.status).toBe('rendered');
done();
});
phaseTracker.trackPhaseEvents('1', {});
});
});
describe('api implements PublishesDataLoading', () => {
test(`should emit 'loading' event when dataLoading is true`, (done) => {
const phaseTracker = new PhaseTracker();
phaseTracker
.getPhase$()
.pipe(skip(1))
.subscribe((phaseEvent) => {
expect(phaseEvent?.status).toBe('loading');
done();
});
phaseTracker.trackPhaseEvents('1', { dataLoading: new BehaviorSubject(true) });
});
test(`should emit 'rendered' event when dataLoading is false`, (done) => {
const phaseTracker = new PhaseTracker();
phaseTracker
.getPhase$()
.pipe(skip(1))
.subscribe((phaseEvent) => {
expect(phaseEvent?.status).toBe('rendered');
done();
});
phaseTracker.trackPhaseEvents('1', { dataLoading: new BehaviorSubject(false) });
});
});
describe('api implements PublishesDataLoading and PublishesRendered', () => {
test(`should emit 'loading' event when dataLoading is true`, (done) => {
const phaseTracker = new PhaseTracker();
phaseTracker
.getPhase$()
.pipe(skip(1))
.subscribe((phaseEvent) => {
expect(phaseEvent?.status).toBe('loading');
done();
});
phaseTracker.trackPhaseEvents('1', {
dataLoading: new BehaviorSubject(true),
rendered$: new BehaviorSubject(false),
});
});
test(`should emit 'loading' event when dataLoading is false but rendered is false`, (done) => {
const phaseTracker = new PhaseTracker();
phaseTracker
.getPhase$()
.pipe(skip(1))
.subscribe((phaseEvent) => {
expect(phaseEvent?.status).toBe('loading');
done();
});
phaseTracker.trackPhaseEvents('1', {
dataLoading: new BehaviorSubject(false),
rendered$: new BehaviorSubject(false),
});
});
test(`should emit 'rendered' event only when rendered is true`, (done) => {
const phaseTracker = new PhaseTracker();
phaseTracker
.getPhase$()
.pipe(skip(1))
.subscribe((phaseEvent) => {
expect(phaseEvent?.status).toBe('rendered');
done();
});
phaseTracker.trackPhaseEvents('1', {
dataLoading: new BehaviorSubject(false),
rendered$: new BehaviorSubject(true),
});
});
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 {
PhaseEvent,
apiPublishesDataLoading,
apiPublishesRendered,
} from '@kbn/presentation-publishing';
import { BehaviorSubject, Subscription, combineLatest } from 'rxjs';
export class PhaseTracker {
private firstLoadCompleteTime: number | undefined;
private embeddableStartTime = performance.now();
private subscriptions = new Subscription();
private phase$ = new BehaviorSubject<PhaseEvent | undefined>(undefined);
getPhase$() {
return this.phase$;
}
public trackPhaseEvents(uuid: string, api: unknown) {
const dataLoading$ = apiPublishesDataLoading(api)
? api.dataLoading
: new BehaviorSubject(false);
const rendered$ = apiPublishesRendered(api) ? api.rendered$ : new BehaviorSubject(true);
this.subscriptions.add(
combineLatest([dataLoading$, rendered$]).subscribe(([dataLoading, rendered]) => {
if (!this.firstLoadCompleteTime) {
this.firstLoadCompleteTime = performance.now();
}
const duration = this.firstLoadCompleteTime - this.embeddableStartTime;
const status = dataLoading || !rendered ? 'loading' : 'rendered';
this.phase$.next({ id: uuid, status, timeToEvent: duration });
})
);
}
public cleanup() {
this.subscriptions.unsubscribe();
}
}

View file

@ -16,12 +16,7 @@ import {
SerializedPanelState,
} from '@kbn/presentation-containers';
import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public';
import {
apiPublishesDataLoading,
ComparatorDefinition,
PhaseEvent,
StateComparators,
} from '@kbn/presentation-publishing';
import { ComparatorDefinition, StateComparators } from '@kbn/presentation-publishing';
import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import { BehaviorSubject, combineLatest, debounceTime, skip, Subscription, switchMap } from 'rxjs';
import { v4 as generateId } from 'uuid';
@ -31,6 +26,7 @@ import {
DefaultEmbeddableApi,
SetReactEmbeddableApiRegistration,
} from './types';
import { PhaseTracker } from './phase_tracker';
const ON_STATE_CHANGE_DEBOUNCE = 100;
@ -78,25 +74,12 @@ export const ReactEmbeddableRenderer = <
onAnyStateChange?: (state: SerializedPanelState<SerializedState>) => void;
}) => {
const cleanupFunction = useRef<(() => void) | null>(null);
const firstLoadCompleteTime = useRef<number | null>(null);
const phaseTracker = useRef(new PhaseTracker());
const componentPromise = useMemo(
() => {
const uuid = maybeId ?? generateId();
/**
* Phase tracking instrumentation for telemetry
*/
const phase$ = new BehaviorSubject<PhaseEvent | undefined>(undefined);
const embeddableStartTime = performance.now();
const reportPhaseChange = (loading: boolean) => {
if (firstLoadCompleteTime.current === null) {
firstLoadCompleteTime.current = performance.now();
}
const duration = firstLoadCompleteTime.current - embeddableStartTime;
phase$.next({ id: uuid, status: loading ? 'loading' : 'rendered', timeToEvent: duration });
};
/**
* Build the embeddable promise
*/
@ -126,7 +109,7 @@ export const ReactEmbeddableRenderer = <
return {
...apiRegistration,
uuid,
phase$,
phase$: phaseTracker.current.getPhase$(),
parentApi,
hasLockedHoverActions$,
lockHoverActions: (lock: boolean) => {
@ -186,6 +169,7 @@ export const ReactEmbeddableRenderer = <
cleanupFunction.current = () => {
subscriptions.unsubscribe();
phaseTracker.current.cleanup();
unsavedChanges.cleanup();
};
return fullApi as Api & HasSnapshottableState<RuntimeState>;
@ -200,13 +184,8 @@ export const ReactEmbeddableRenderer = <
lastSavedRuntimeState
);
if (apiPublishesDataLoading(api)) {
subscriptions.add(
api.dataLoading.subscribe((loading) => reportPhaseChange(Boolean(loading)))
);
} else {
reportPhaseChange(false);
}
phaseTracker.current.trackPhaseEvents(uuid, api);
return { api, Component };
};

View file

@ -17,6 +17,7 @@ import {
HasSupportedTriggers,
PublishesDataLoading,
PublishesDataViews,
PublishesRendered,
PublishesTimeRange,
SerializedTimeRange,
SerializedTitles,
@ -92,6 +93,7 @@ export const isVisualizeRuntimeState = (state: unknown): state is VisualizeRunti
export type VisualizeApi = Partial<HasEditCapabilities> &
PublishesDataViews &
PublishesDataLoading &
PublishesRendered &
HasVisualizeConfig &
HasInspectorAdapters &
HasSupportedTriggers &

View file

@ -179,6 +179,7 @@ export const getVisualizeEmbeddableFactory: (deps: {
defaultPanelTitle,
dataLoading: dataLoading$,
dataViews: new BehaviorSubject<DataView[] | undefined>(initialDataViews),
rendered$: hasRendered$,
supportedTriggers: () => [
ACTION_CONVERT_TO_LENS,
APPLY_FILTER_TRIGGER,
@ -397,7 +398,6 @@ export const getVisualizeEmbeddableFactory: (deps: {
if (hasRendered$.getValue() === true) return;
hasRendered$.next(true);
hasRendered$.complete();
},
onEvent: async (event) => {
// Visualize doesn't respond to sizing events, so ignore.