Skip to content

Commit

Permalink
[dashboard] fix time_to_data does not capture entire client-side rend…
Browse files Browse the repository at this point in the history
…ering (elastic#200640)

Closes elastic#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 <[email protected]>
  • Loading branch information
nreese and elasticmachine authored Nov 25, 2024
1 parent b6586a9 commit cdeb1e9
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 29 deletions.
1 change: 1 addition & 0 deletions packages/presentation/presentation_publishing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
};
Original file line number Diff line number Diff line change
@@ -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),
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +26,7 @@ import {
DefaultEmbeddableApi,
SetReactEmbeddableApiRegistration,
} from './types';
import { PhaseTracker } from './phase_tracker';

const ON_STATE_CHANGE_DEBOUNCE = 100;

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -126,7 +109,7 @@ export const ReactEmbeddableRenderer = <
return {
...apiRegistration,
uuid,
phase$,
phase$: phaseTracker.current.getPhase$(),
parentApi,
hasLockedHoverActions$,
lockHoverActions: (lock: boolean) => {
Expand Down Expand Up @@ -186,6 +169,7 @@ export const ReactEmbeddableRenderer = <

cleanupFunction.current = () => {
subscriptions.unsubscribe();
phaseTracker.current.cleanup();
unsavedChanges.cleanup();
};
return fullApi as Api & HasSnapshottableState<RuntimeState>;
Expand All @@ -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 };
};

Expand Down
2 changes: 2 additions & 0 deletions src/plugins/visualizations/public/embeddable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
HasSupportedTriggers,
PublishesDataLoading,
PublishesDataViews,
PublishesRendered,
PublishesTimeRange,
SerializedTimeRange,
SerializedTitles,
Expand Down Expand Up @@ -92,6 +93,7 @@ export const isVisualizeRuntimeState = (state: unknown): state is VisualizeRunti
export type VisualizeApi = Partial<HasEditCapabilities> &
PublishesDataViews &
PublishesDataLoading &
PublishesRendered &
HasVisualizeConfig &
HasInspectorAdapters &
HasSupportedTriggers &
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit cdeb1e9

Please sign in to comment.