[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:
Pierre Gayvallet 2023-06-20 06:13:29 -04:00 committed by GitHub
parent 403b5d2fd5
commit 97dc2ecba1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 318 additions and 2 deletions

View file

@ -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);

View file

@ -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,
};
});

View file

@ -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,

View file

@ -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);

View file

@ -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' });
});
});

View file

@ -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' } },
},
});
}

View file

@ -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']);
});
});

View file

@ -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}`;
};

View file

@ -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';

View file

@ -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/**/*",

View file

@ -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 = {

View file

@ -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');
});
});
}