[Embeddables Rebuild] Publish Phase Events (#184445)

allows Dashboards with React Embeddables to properly fire phase events and report telemetry.
This commit is contained in:
Devon Thomson 2024-05-29 16:44:01 -04:00 committed by GitHub
parent b34ff5203c
commit 81d3e1a4be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 218 additions and 65 deletions

View file

@ -21,11 +21,11 @@ export interface PhaseEvent {
}
export interface PublishesPhaseEvents {
onPhaseChange: PublishingSubject<PhaseEvent | undefined>;
phase$: PublishingSubject<PhaseEvent | undefined>;
}
export const apiPublishesPhaseEvents = (
unknownApi: null | unknown
): unknownApi is PublishesPhaseEvents => {
return Boolean(unknownApi && (unknownApi as PublishesPhaseEvents)?.onPhaseChange !== undefined);
return Boolean(unknownApi && (unknownApi as PublishesPhaseEvents)?.phase$ !== undefined);
};

View file

@ -103,6 +103,7 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
showBorder: useMargins,
showNotifications: true,
showShadow: false,
onPanelStatusChange,
};
// render React embeddable
@ -123,7 +124,6 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
<EmbeddablePanel
key={type}
index={index}
onPanelStatusChange={onPanelStatusChange}
embeddable={() => container.untilEmbeddableLoaded(id)}
{...panelProps}
/>

View file

@ -103,7 +103,7 @@ export const legacyEmbeddableToApi = (
/**
* Performance tracking
*/
const onPhaseChange = new BehaviorSubject<PhaseEvent | undefined>(undefined);
const phase$ = new BehaviorSubject<PhaseEvent | undefined>(undefined);
let loadingStartTime = 0;
subscriptions.add(
@ -132,7 +132,7 @@ export const legacyEmbeddableToApi = (
})
)
.subscribe((statusOutput) => {
onPhaseChange.next(statusOutput);
phase$.next(statusOutput);
})
);
@ -252,7 +252,7 @@ export const legacyEmbeddableToApi = (
dataLoading,
blockingError,
onPhaseChange,
phase$,
onEdit,
isEditingEnabled,

View file

@ -118,7 +118,7 @@ test('updating output state retains instance information', async () => {
test('fires phase events when output changes', async () => {
const phaseEventTest = new PhaseTestEmbeddable();
let phaseEventCount = 0;
phaseEventTest.onPhaseChange.subscribe((event) => {
phaseEventTest.phase$.subscribe((event) => {
if (event) {
phaseEventCount++;
}

View file

@ -127,7 +127,7 @@ export abstract class Embeddable<
dataLoading: this.dataLoading,
filters$: this.filters$,
blockingError: this.blockingError,
onPhaseChange: this.onPhaseChange,
phase$: this.phase$,
setPanelTitle: this.setPanelTitle,
linkToLibrary: this.linkToLibrary,
hidePanelTitle: this.hidePanelTitle,
@ -168,7 +168,7 @@ export abstract class Embeddable<
public panelTitle: LegacyEmbeddableAPI['panelTitle'];
public dataLoading: LegacyEmbeddableAPI['dataLoading'];
public filters$: LegacyEmbeddableAPI['filters$'];
public onPhaseChange: LegacyEmbeddableAPI['onPhaseChange'];
public phase$: LegacyEmbeddableAPI['phase$'];
public linkToLibrary: LegacyEmbeddableAPI['linkToLibrary'];
public blockingError: LegacyEmbeddableAPI['blockingError'];
public setPanelTitle: LegacyEmbeddableAPI['setPanelTitle'];

View file

@ -7,7 +7,7 @@
*/
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { setStubKibanaServices as setupPresentationPanelServices } from '@kbn/presentation-panel-plugin/public/mocks';
import { render, waitFor, screen } from '@testing-library/react';
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
@ -15,36 +15,36 @@ import { registerReactEmbeddableFactory } from './react_embeddable_registry';
import { ReactEmbeddableRenderer } from './react_embeddable_renderer';
import { ReactEmbeddableFactory } from './types';
describe('react embeddable renderer', () => {
const testEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = {
type: 'test',
deserializeState: jest.fn().mockImplementation((state) => state.rawState),
buildEmbeddable: async (state, registerApi) => {
const api = registerApi(
{
serializeState: () => ({
rawState: {
name: state.name,
bork: state.bork,
},
}),
},
{
name: [new BehaviorSubject<string>(state.name), () => {}],
bork: [new BehaviorSubject<string>(state.bork), () => {}],
}
);
return {
Component: () => (
<div data-test-subj="superTestEmbeddable">
SUPER TEST COMPONENT, name: {state.name} bork: {state.bork}
</div>
),
api,
};
},
};
const testEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = {
type: 'test',
deserializeState: jest.fn().mockImplementation((state) => state.rawState),
buildEmbeddable: async (state, registerApi) => {
const api = registerApi(
{
serializeState: () => ({
rawState: {
name: state.name,
bork: state.bork,
},
}),
},
{
name: [new BehaviorSubject<string>(state.name), () => {}],
bork: [new BehaviorSubject<string>(state.bork), () => {}],
}
);
return {
Component: () => (
<div data-test-subj="superTestEmbeddable">
SUPER TEST COMPONENT, name: {state.name} bork: {state.bork}
</div>
),
api,
};
},
};
describe('react embeddable renderer', () => {
const getTestEmbeddableFactory = async () => {
return testEmbeddableFactory;
};
@ -185,6 +185,7 @@ describe('react embeddable renderer', () => {
serializeState: expect.any(Function),
resetUnsavedChanges: expect.any(Function),
snapshotRuntimeState: expect.any(Function),
phase$: expect.any(Object),
})
);
});
@ -209,3 +210,104 @@ describe('react embeddable renderer', () => {
);
});
});
describe('reactEmbeddable phase events', () => {
it('publishes rendered phase immediately when dataLoading is not defined', async () => {
const immediateLoadEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = {
...testEmbeddableFactory,
type: 'immediateLoad',
};
registerReactEmbeddableFactory('immediateLoad', () =>
Promise.resolve(immediateLoadEmbeddableFactory)
);
setupPresentationPanelServices();
const renderedEvent = jest.fn();
render(
<ReactEmbeddableRenderer
type={'test'}
maybeId={'12345'}
onApiAvailable={(api) => {
api.phase$.subscribe((phase) => {
if (phase?.status === 'rendered') {
renderedEvent();
}
});
}}
getParentApi={() => ({
getSerializedStateForChild: () => ({
rawState: { name: 'Kuni Garu' },
}),
})}
/>
);
await waitFor(() => expect(renderedEvent).toHaveBeenCalled());
});
it('publishes rendered phase event when dataLoading is complete', async () => {
const dataLoadingEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = {
...testEmbeddableFactory,
type: 'loadClicker',
buildEmbeddable: async (state, registerApi) => {
const dataLoading = new BehaviorSubject<boolean | undefined>(true);
const api = registerApi(
{
serializeState: () => ({
rawState: {
name: state.name,
bork: state.bork,
},
}),
dataLoading,
},
{
name: [new BehaviorSubject<string>(state.name), () => {}],
bork: [new BehaviorSubject<string>(state.bork), () => {}],
}
);
return {
Component: () => (
<>
<div data-test-subj="superTestEmbeddable">
SUPER TEST COMPONENT, name: {state.name} bork: {state.bork}
</div>
<button data-test-subj="clickToStopLoading" onClick={() => dataLoading.next(false)}>
Done loading
</button>
</>
),
api,
};
},
};
registerReactEmbeddableFactory('loadClicker', () =>
Promise.resolve(dataLoadingEmbeddableFactory)
);
setupPresentationPanelServices();
const phaseFn = jest.fn();
render(
<ReactEmbeddableRenderer
type={'loadClicker'}
maybeId={'12345'}
onApiAvailable={(api) => {
api.phase$.subscribe((phase) => {
phaseFn(phase);
});
}}
getParentApi={() => ({
getSerializedStateForChild: () => ({
rawState: { name: 'Kuni Garu' },
}),
})}
/>
);
await waitFor(() => {
expect(phaseFn).toHaveBeenCalledWith(expect.objectContaining({ status: 'loading' }));
});
await fireEvent.click(screen.getByTestId('clickToStopLoading'));
await waitFor(() => {
expect(phaseFn).toHaveBeenCalledWith(expect.objectContaining({ status: 'rendered' }));
});
});
});

View file

@ -8,9 +8,14 @@
import { HasSerializedChildState, SerializedPanelState } from '@kbn/presentation-containers';
import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public';
import { ComparatorDefinition, StateComparators } from '@kbn/presentation-publishing';
import {
apiPublishesDataLoading,
ComparatorDefinition,
PhaseEvent,
StateComparators,
} from '@kbn/presentation-publishing';
import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import { combineLatest, debounceTime, skip, switchMap } from 'rxjs';
import { BehaviorSubject, combineLatest, debounceTime, skip, Subscription, switchMap } from 'rxjs';
import { v4 as generateId } from 'uuid';
import { getReactEmbeddableFactory } from './react_embeddable_registry';
import { initializeReactEmbeddableState } from './react_embeddable_state';
@ -58,13 +63,32 @@ export const ReactEmbeddableRenderer = <
onAnyStateChange?: (state: SerializedPanelState<SerializedState>) => void;
}) => {
const cleanupFunction = useRef<(() => void) | null>(null);
const firstLoadCompleteTime = useRef<number | null>(null);
const componentPromise = useMemo(
() =>
(async () => {
() => {
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
*/
return (async () => {
const parentApi = getParentApi();
const uuid = maybeId ?? generateId();
const factory = await getReactEmbeddableFactory<SerializedState, Api, RuntimeState>(type);
const subscriptions = new Subscription();
const { initialState, startStateDiffing } = await initializeReactEmbeddableState<
SerializedState,
@ -84,23 +108,25 @@ export const ReactEmbeddableRenderer = <
const comparatorDefinitions: Array<
ComparatorDefinition<RuntimeState, keyof RuntimeState>
> = Object.values(comparators);
combineLatest(comparatorDefinitions.map((comparator) => comparator[0]))
.pipe(
skip(1),
debounceTime(ON_STATE_CHANGE_DEBOUNCE),
switchMap(() => {
const isAsync =
apiRegistration.serializeState.prototype?.name === 'AsyncFunction';
return isAsync
? (apiRegistration.serializeState() as Promise<
SerializedPanelState<SerializedState>
>)
: Promise.resolve(apiRegistration.serializeState());
subscriptions.add(
combineLatest(comparatorDefinitions.map((comparator) => comparator[0]))
.pipe(
skip(1),
debounceTime(ON_STATE_CHANGE_DEBOUNCE),
switchMap(() => {
const isAsync =
apiRegistration.serializeState.prototype?.name === 'AsyncFunction';
return isAsync
? (apiRegistration.serializeState() as Promise<
SerializedPanelState<SerializedState>
>)
: Promise.resolve(apiRegistration.serializeState());
})
)
.subscribe((serializedState) => {
onAnyStateChange(serializedState);
})
)
.subscribe((serializedState) => {
onAnyStateChange(serializedState);
});
);
}
const { unsavedChanges, resetUnsavedChanges, cleanup, snapshotRuntimeState } =
@ -108,13 +134,17 @@ export const ReactEmbeddableRenderer = <
const fullApi = {
...apiRegistration,
uuid,
phase$,
parentApi,
unsavedChanges,
type: factory.type,
resetUnsavedChanges,
snapshotRuntimeState,
} as unknown as Api;
cleanupFunction.current = () => cleanup();
cleanupFunction.current = () => {
subscriptions.unsubscribe();
cleanup();
};
onApiAvailable?.(fullApi);
return fullApi;
};
@ -126,13 +156,22 @@ export const ReactEmbeddableRenderer = <
parentApi
);
if (apiPublishesDataLoading(api)) {
subscriptions.add(
api.dataLoading.subscribe((loading) => reportPhaseChange(Boolean(loading)))
);
} else {
reportPhaseChange(false);
}
return React.forwardRef<typeof api>((_, ref) => {
// expose the api into the imperative handle
useImperativeHandle(ref, () => api, []);
return <Component />;
});
})(),
})();
},
/**
* Disabling exhaustive deps because we do not want to re-fetch the component
* from the embeddable registry unless the type changes.

View file

@ -11,7 +11,12 @@ import {
SerializedPanelState,
} from '@kbn/presentation-containers';
import { DefaultPresentationPanelApi } from '@kbn/presentation-panel-plugin/public/panel_component/types';
import { HasType, PublishesUnsavedChanges, StateComparators } from '@kbn/presentation-publishing';
import {
HasType,
PublishesPhaseEvents,
PublishesUnsavedChanges,
StateComparators,
} from '@kbn/presentation-publishing';
import { MaybePromise } from '@kbn/utility-types';
import React from 'react';
@ -25,6 +30,7 @@ export interface DefaultEmbeddableApi<
RuntimeState extends object = SerializedState
> extends DefaultPresentationPanelApi,
HasType,
PublishesPhaseEvents,
PublishesUnsavedChanges,
HasSerializableState<SerializedState>,
HasSnapshottableState<RuntimeState> {}
@ -38,7 +44,13 @@ export type ReactEmbeddableApiRegistration<
Api extends DefaultEmbeddableApi<SerializedState> = DefaultEmbeddableApi<SerializedState>
> = Omit<
Api,
'uuid' | 'parent' | 'type' | 'unsavedChanges' | 'resetUnsavedChanges' | 'snapshotRuntimeState'
| 'uuid'
| 'parent'
| 'type'
| 'unsavedChanges'
| 'resetUnsavedChanges'
| 'snapshotRuntimeState'
| 'phase$'
>;
/**

View file

@ -84,7 +84,7 @@ export const PresentationPanelInternal = <
useEffect(() => {
let subscription: Subscription;
if (api && onPanelStatusChange && apiPublishesPhaseEvents(api)) {
subscription = api.onPhaseChange.subscribe((phase) => {
subscription = api.phase$.subscribe((phase) => {
if (phase) onPanelStatusChange(phase);
});
}