mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[EBT] Add page url to browser-side context (#159916)
## Summary Part of https://github.com/elastic/kibana/issues/149249 Add a new EBT context providing the `page_url` field to events. `page_url` is based on the current url's `pathname` and `hash` exclusively (no domain, port, query param...) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
403b5d2fd5
commit
97dc2ecba1
12 changed files with 318 additions and 2 deletions
|
@ -12,6 +12,7 @@ import { act } from 'react-dom/test-utils';
|
|||
import { createMemoryHistory, MemoryHistory } from 'history';
|
||||
|
||||
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
|
||||
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
|
||||
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
|
||||
import type { AppMountParameters, AppUpdater } from '@kbn/core-application-browser';
|
||||
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
|
||||
|
@ -38,11 +39,13 @@ describe('ApplicationService', () => {
|
|||
beforeEach(() => {
|
||||
history = createMemoryHistory();
|
||||
const http = httpServiceMock.createSetupContract({ basePath: '/test' });
|
||||
const analytics = analyticsServiceMock.createAnalyticsServiceSetup();
|
||||
|
||||
http.post.mockResolvedValue({ navLinks: {} });
|
||||
|
||||
setupDeps = {
|
||||
http,
|
||||
analytics,
|
||||
history: history as any,
|
||||
};
|
||||
startDeps = {
|
||||
|
@ -87,6 +90,45 @@ describe('ApplicationService', () => {
|
|||
|
||||
expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1');
|
||||
});
|
||||
|
||||
it('updates the page_url analytics context', async () => {
|
||||
const { register } = service.setup(setupDeps);
|
||||
|
||||
const context$ = setupDeps.analytics.registerContextProvider.mock.calls[0][0]
|
||||
.context$ as Observable<{
|
||||
page_url: string;
|
||||
}>;
|
||||
const locations: string[] = [];
|
||||
context$.subscribe((context) => locations.push(context.page_url));
|
||||
|
||||
register(Symbol(), {
|
||||
id: 'app1',
|
||||
title: 'App1',
|
||||
mount: async () => () => undefined,
|
||||
});
|
||||
register(Symbol(), {
|
||||
id: 'app2',
|
||||
title: 'App2',
|
||||
mount: async () => () => undefined,
|
||||
});
|
||||
|
||||
const { getComponent } = await service.start(startDeps);
|
||||
update = createRenderer(getComponent());
|
||||
|
||||
await navigate('/app/app1/bar?hello=dolly');
|
||||
await flushPromises();
|
||||
await navigate('/app/app2#/foo');
|
||||
await flushPromises();
|
||||
await navigate('/app/app2#/another-path');
|
||||
await flushPromises();
|
||||
|
||||
expect(locations).toEqual([
|
||||
'/',
|
||||
'/app/app1/bar',
|
||||
'/app/app2#/foo',
|
||||
'/app/app2#/another-path',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('using navigateToApp', () => {
|
||||
|
@ -127,6 +169,46 @@ describe('ApplicationService', () => {
|
|||
expect(currentAppIds).toEqual(['app1']);
|
||||
});
|
||||
|
||||
it('updates the page_url analytics context', async () => {
|
||||
const { register } = service.setup(setupDeps);
|
||||
|
||||
const context$ = setupDeps.analytics.registerContextProvider.mock.calls[0][0]
|
||||
.context$ as Observable<{
|
||||
page_url: string;
|
||||
}>;
|
||||
const locations: string[] = [];
|
||||
context$.subscribe((context) => locations.push(context.page_url));
|
||||
|
||||
register(Symbol(), {
|
||||
id: 'app1',
|
||||
title: 'App1',
|
||||
mount: async () => () => undefined,
|
||||
});
|
||||
register(Symbol(), {
|
||||
id: 'app2',
|
||||
title: 'App2',
|
||||
mount: async () => () => undefined,
|
||||
});
|
||||
|
||||
const { navigateToApp, getComponent } = await service.start(startDeps);
|
||||
update = createRenderer(getComponent());
|
||||
|
||||
await act(async () => {
|
||||
await navigateToApp('app1');
|
||||
update();
|
||||
});
|
||||
await act(async () => {
|
||||
await navigateToApp('app2', { path: '/nested' });
|
||||
update();
|
||||
});
|
||||
await act(async () => {
|
||||
await navigateToApp('app2', { path: '/another-path' });
|
||||
update();
|
||||
});
|
||||
|
||||
expect(locations).toEqual(['/', '/app/app1', '/app/app2/nested', '/app/app2/another-path']);
|
||||
});
|
||||
|
||||
it('replaces the current history entry when the `replace` option is true', async () => {
|
||||
const { register } = service.setup(setupDeps);
|
||||
|
||||
|
|
|
@ -26,11 +26,23 @@ jest.doMock('history', () => ({
|
|||
}));
|
||||
|
||||
export const parseAppUrlMock = jest.fn();
|
||||
export const getLocationObservableMock = jest.fn();
|
||||
jest.doMock('./utils', () => {
|
||||
const original = jest.requireActual('./utils');
|
||||
|
||||
return {
|
||||
...original,
|
||||
parseAppUrl: parseAppUrlMock,
|
||||
getLocationObservable: getLocationObservableMock,
|
||||
};
|
||||
});
|
||||
|
||||
export const registerAnalyticsContextProviderMock = jest.fn();
|
||||
jest.doMock('./register_analytics_context_provider', () => {
|
||||
const original = jest.requireActual('./register_analytics_context_provider');
|
||||
|
||||
return {
|
||||
...original,
|
||||
registerAnalyticsContextProvider: registerAnalyticsContextProviderMock,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -10,17 +10,21 @@ import {
|
|||
MockCapabilitiesService,
|
||||
MockHistory,
|
||||
parseAppUrlMock,
|
||||
getLocationObservableMock,
|
||||
registerAnalyticsContextProviderMock,
|
||||
} from './application_service.test.mocks';
|
||||
|
||||
import { createElement } from 'react';
|
||||
import { BehaviorSubject, firstValueFrom, Subject } from 'rxjs';
|
||||
import { bufferCount, takeUntil } from 'rxjs/operators';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
|
||||
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
|
||||
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
|
||||
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
|
||||
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
|
||||
import { MockLifecycle } from './test_helpers/test_types';
|
||||
import { ApplicationService } from './application_service';
|
||||
import {
|
||||
|
@ -48,9 +52,12 @@ let service: ApplicationService;
|
|||
|
||||
describe('#setup()', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
const http = httpServiceMock.createSetupContract({ basePath: '/base-path' });
|
||||
const analytics = analyticsServiceMock.createAnalyticsServiceSetup();
|
||||
setupDeps = {
|
||||
http,
|
||||
analytics,
|
||||
redirectTo: jest.fn(),
|
||||
};
|
||||
startDeps = {
|
||||
|
@ -469,13 +476,38 @@ describe('#setup()', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analytics context provider', () => {
|
||||
it('calls getLocationObservable with the correct parameters', () => {
|
||||
const history = createBrowserHistory();
|
||||
service.setup({ ...setupDeps, history });
|
||||
|
||||
expect(getLocationObservableMock).toHaveBeenCalledTimes(1);
|
||||
expect(getLocationObservableMock).toHaveBeenCalledWith(window.location, history);
|
||||
});
|
||||
|
||||
it('calls registerAnalyticsContextProvider with the correct parameters', () => {
|
||||
const location$ = new Subject<string>();
|
||||
getLocationObservableMock.mockReturnValue(location$);
|
||||
|
||||
service.setup(setupDeps);
|
||||
|
||||
expect(registerAnalyticsContextProviderMock).toHaveBeenCalledTimes(1);
|
||||
expect(registerAnalyticsContextProviderMock).toHaveBeenCalledWith({
|
||||
analytics: setupDeps.analytics,
|
||||
location$,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#start()', () => {
|
||||
beforeEach(() => {
|
||||
const http = httpServiceMock.createSetupContract({ basePath: '/base-path' });
|
||||
const analytics = analyticsServiceMock.createAnalyticsServiceSetup();
|
||||
setupDeps = {
|
||||
http,
|
||||
analytics,
|
||||
redirectTo: jest.fn(),
|
||||
};
|
||||
startDeps = {
|
||||
|
@ -1185,8 +1217,10 @@ describe('#stop()', () => {
|
|||
|
||||
MockHistory.push.mockReset();
|
||||
const http = httpServiceMock.createSetupContract({ basePath: '/test' });
|
||||
const analytics = analyticsServiceMock.createAnalyticsServiceSetup();
|
||||
setupDeps = {
|
||||
http,
|
||||
analytics,
|
||||
};
|
||||
startDeps = {
|
||||
http,
|
||||
|
|
|
@ -17,6 +17,7 @@ import type { HttpSetup, HttpStart } from '@kbn/core-http-browser';
|
|||
import type { Capabilities } from '@kbn/core-capabilities-common';
|
||||
import type { MountPoint } from '@kbn/core-mount-utils-browser';
|
||||
import type { OverlayStart } from '@kbn/core-overlays-browser';
|
||||
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
|
||||
import type {
|
||||
App,
|
||||
AppDeepLink,
|
||||
|
@ -35,10 +36,18 @@ import type { InternalApplicationSetup, InternalApplicationStart, Mounter } from
|
|||
|
||||
import { getLeaveAction, isConfirmAction } from './application_leave';
|
||||
import { getUserConfirmationHandler } from './navigation_confirm';
|
||||
import { appendAppPath, parseAppUrl, relativeToAbsolute, getAppInfo } from './utils';
|
||||
import {
|
||||
appendAppPath,
|
||||
parseAppUrl,
|
||||
relativeToAbsolute,
|
||||
getAppInfo,
|
||||
getLocationObservable,
|
||||
} from './utils';
|
||||
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
|
||||
|
||||
export interface SetupDeps {
|
||||
http: HttpSetup;
|
||||
analytics: AnalyticsServiceSetup;
|
||||
history?: History<any>;
|
||||
/** Used to redirect to external urls */
|
||||
redirectTo?: (path: string) => void;
|
||||
|
@ -111,6 +120,7 @@ export class ApplicationService {
|
|||
|
||||
public setup({
|
||||
http: { basePath },
|
||||
analytics,
|
||||
redirectTo = (path: string) => {
|
||||
window.location.assign(path);
|
||||
},
|
||||
|
@ -126,6 +136,12 @@ export class ApplicationService {
|
|||
}),
|
||||
});
|
||||
|
||||
const location$ = getLocationObservable(window.location, this.history);
|
||||
registerAnalyticsContextProvider({
|
||||
analytics,
|
||||
location$,
|
||||
});
|
||||
|
||||
this.navigate = (url, state, replace) => {
|
||||
// basePath not needed here because `history` is configured with basename
|
||||
return replace ? this.history!.replace(url, state) : this.history!.push(url, state);
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 { firstValueFrom, ReplaySubject, Subject } from 'rxjs';
|
||||
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
|
||||
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
|
||||
|
||||
describe('registerAnalyticsContextProvider', () => {
|
||||
let analytics: ReturnType<typeof analyticsServiceMock.createAnalyticsServiceSetup>;
|
||||
let location$: Subject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
analytics = analyticsServiceMock.createAnalyticsServiceSetup();
|
||||
location$ = new ReplaySubject<string>(1);
|
||||
registerAnalyticsContextProvider({ analytics, location$ });
|
||||
});
|
||||
|
||||
test('should register the analytics context provider', () => {
|
||||
expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.registerContextProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'page url',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('emits a context value when location$ emits', async () => {
|
||||
location$.next('/some_url');
|
||||
await expect(
|
||||
firstValueFrom(analytics.registerContextProvider.mock.calls[0][0].context$)
|
||||
).resolves.toEqual({ page_url: '/some_url' });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
|
||||
import { type Observable, map } from 'rxjs';
|
||||
|
||||
export function registerAnalyticsContextProvider({
|
||||
analytics,
|
||||
location$,
|
||||
}: {
|
||||
analytics: AnalyticsServiceSetup;
|
||||
location$: Observable<string>;
|
||||
}) {
|
||||
analytics.registerContextProvider({
|
||||
name: 'page url',
|
||||
context$: location$.pipe(map((location) => ({ page_url: location }))),
|
||||
schema: {
|
||||
page_url: { type: 'text', _meta: { description: 'The page url' } },
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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 { createBrowserHistory, type History } from 'history';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { getLocationObservable } from './get_location_observable';
|
||||
|
||||
const nextTick = () => new Promise((resolve) => window.setTimeout(resolve, 1));
|
||||
|
||||
describe('getLocationObservable', () => {
|
||||
let history: History;
|
||||
|
||||
beforeEach(() => {
|
||||
history = createBrowserHistory();
|
||||
});
|
||||
|
||||
it('emits with the initial location', async () => {
|
||||
const location$ = getLocationObservable({ pathname: '/foo', hash: '' }, history);
|
||||
expect(await firstValueFrom(location$)).toEqual('/foo');
|
||||
});
|
||||
|
||||
it('emits when the location changes', async () => {
|
||||
const location$ = getLocationObservable({ pathname: '/foo', hash: '' }, history);
|
||||
const locations: string[] = [];
|
||||
location$.subscribe((location) => locations.push(location));
|
||||
|
||||
history.push({ pathname: '/bar' });
|
||||
history.push({ pathname: '/dolly' });
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(locations).toEqual(['/foo', '/bar', '/dolly']);
|
||||
});
|
||||
|
||||
it('emits only once for a given url', async () => {
|
||||
const location$ = getLocationObservable({ pathname: '/foo', hash: '' }, history);
|
||||
const locations: string[] = [];
|
||||
location$.subscribe((location) => locations.push(location));
|
||||
|
||||
history.push({ pathname: '/bar' });
|
||||
history.push({ pathname: '/bar' });
|
||||
history.push({ pathname: '/foo' });
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(locations).toEqual(['/foo', '/bar', '/foo']);
|
||||
});
|
||||
|
||||
it('includes the hash when present', async () => {
|
||||
const location$ = getLocationObservable({ pathname: '/foo', hash: '#/index' }, history);
|
||||
const locations: string[] = [];
|
||||
location$.subscribe((location) => locations.push(location));
|
||||
|
||||
history.push({ pathname: '/bar', hash: '#/home' });
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(locations).toEqual(['/foo#/index', '/bar#/home']);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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 { Observable, Subject, startWith, shareReplay, distinctUntilChanged } from 'rxjs';
|
||||
import type { History } from 'history';
|
||||
|
||||
// interface compatible for both window.location and history.location...
|
||||
export interface Location {
|
||||
pathname: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export const getLocationObservable = (
|
||||
initialLocation: Location,
|
||||
history: History
|
||||
): Observable<string> => {
|
||||
const subject = new Subject<string>();
|
||||
history.listen((location) => {
|
||||
subject.next(locationToUrl(location));
|
||||
});
|
||||
return subject.pipe(
|
||||
startWith(locationToUrl(initialLocation)),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1)
|
||||
);
|
||||
};
|
||||
|
||||
const locationToUrl = (location: Location) => {
|
||||
return `${location.pathname}${location.hash}`;
|
||||
};
|
|
@ -11,3 +11,4 @@ export { getAppInfo } from './get_app_info';
|
|||
export { parseAppUrl } from './parse_app_url';
|
||||
export { relativeToAbsolute } from './relative_to_absolute';
|
||||
export { removeSlashes } from './remove_slashes';
|
||||
export { getLocationObservable } from './get_location_observable';
|
||||
|
|
|
@ -33,6 +33,8 @@
|
|||
"@kbn/test-jest-helpers",
|
||||
"@kbn/core-custom-branding-browser",
|
||||
"@kbn/core-custom-branding-browser-mocks",
|
||||
"@kbn/core-analytics-browser-mocks",
|
||||
"@kbn/core-analytics-browser",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -241,7 +241,7 @@ export class CoreSystem {
|
|||
const notifications = this.notifications.setup({ uiSettings });
|
||||
const customBranding = this.customBranding.setup({ injectedMetadata });
|
||||
|
||||
const application = this.application.setup({ http });
|
||||
const application = this.application.setup({ http, analytics });
|
||||
this.coreApp.setup({ application, http, injectedMetadata, notifications });
|
||||
|
||||
const core: InternalCoreSetup = {
|
||||
|
|
|
@ -98,5 +98,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
expect(event.context).to.have.property('viewport_height');
|
||||
expect(event.context.viewport_height).to.be.a('number');
|
||||
});
|
||||
|
||||
it('should have the properties provided by the "page url" context provider', () => {
|
||||
expect(event.context).to.have.property('page_url');
|
||||
expect(event.context.page_url).to.be.a('string');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue