diff --git a/packages/core/application/core-application-browser-internal/integration_tests/application_service.test.tsx b/packages/core/application/core-application-browser-internal/integration_tests/application_service.test.tsx index a4f117d5c883b..95dc6d8f8b181 100644 --- a/packages/core/application/core-application-browser-internal/integration_tests/application_service.test.tsx +++ b/packages/core/application/core-application-browser-internal/integration_tests/application_service.test.tsx @@ -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); diff --git a/packages/core/application/core-application-browser-internal/src/application_service.test.mocks.ts b/packages/core/application/core-application-browser-internal/src/application_service.test.mocks.ts index 7197e7308def6..a41c27f348f3a 100644 --- a/packages/core/application/core-application-browser-internal/src/application_service.test.mocks.ts +++ b/packages/core/application/core-application-browser-internal/src/application_service.test.mocks.ts @@ -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, }; }); diff --git a/packages/core/application/core-application-browser-internal/src/application_service.test.ts b/packages/core/application/core-application-browser-internal/src/application_service.test.ts index 65e5867db83a0..09e7ed5a385a4 100644 --- a/packages/core/application/core-application-browser-internal/src/application_service.test.ts +++ b/packages/core/application/core-application-browser-internal/src/application_service.test.ts @@ -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(); + 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, diff --git a/packages/core/application/core-application-browser-internal/src/application_service.tsx b/packages/core/application/core-application-browser-internal/src/application_service.tsx index 5808410b07fe2..0c8207ca1d2f6 100644 --- a/packages/core/application/core-application-browser-internal/src/application_service.tsx +++ b/packages/core/application/core-application-browser-internal/src/application_service.tsx @@ -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; /** 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); diff --git a/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.test.ts b/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.test.ts new file mode 100644 index 0000000000000..075095db9cfa8 --- /dev/null +++ b/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.test.ts @@ -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; + let location$: Subject; + + beforeEach(() => { + analytics = analyticsServiceMock.createAnalyticsServiceSetup(); + location$ = new ReplaySubject(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' }); + }); +}); diff --git a/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.ts b/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.ts new file mode 100644 index 0000000000000..9c79b0e15f070 --- /dev/null +++ b/packages/core/application/core-application-browser-internal/src/register_analytics_context_provider.ts @@ -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; +}) { + analytics.registerContextProvider({ + name: 'page url', + context$: location$.pipe(map((location) => ({ page_url: location }))), + schema: { + page_url: { type: 'text', _meta: { description: 'The page url' } }, + }, + }); +} diff --git a/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.test.ts b/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.test.ts new file mode 100644 index 0000000000000..3567929e6b688 --- /dev/null +++ b/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.test.ts @@ -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']); + }); +}); diff --git a/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.ts b/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.ts new file mode 100644 index 0000000000000..3e1957de38b63 --- /dev/null +++ b/packages/core/application/core-application-browser-internal/src/utils/get_location_observable.ts @@ -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 => { + const subject = new Subject(); + 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}`; +}; diff --git a/packages/core/application/core-application-browser-internal/src/utils/index.ts b/packages/core/application/core-application-browser-internal/src/utils/index.ts index e88b1f7a8a6fc..f22146584f70a 100644 --- a/packages/core/application/core-application-browser-internal/src/utils/index.ts +++ b/packages/core/application/core-application-browser-internal/src/utils/index.ts @@ -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'; diff --git a/packages/core/application/core-application-browser-internal/tsconfig.json b/packages/core/application/core-application-browser-internal/tsconfig.json index 8f54fa8aa6ae1..cc07927f15ed2 100644 --- a/packages/core/application/core-application-browser-internal/tsconfig.json +++ b/packages/core/application/core-application-browser-internal/tsconfig.json @@ -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/**/*", diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index c4213e5efdb58..b4a8b6d2d2815 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -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 = { diff --git a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts index 79e43f0df086f..3a7c075abd613 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts @@ -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'); + }); }); }