mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[EBT] Track "click" events (#131755)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
7df8edf9b3
commit
ab43a29f01
5 changed files with 209 additions and 0 deletions
|
@ -9,6 +9,7 @@
|
|||
import type { AnalyticsClient } from '@kbn/analytics-client';
|
||||
import { createAnalytics } from '@kbn/analytics-client';
|
||||
import { of } from 'rxjs';
|
||||
import { trackClicks } from './track_clicks';
|
||||
import { InjectedMetadataSetup } from '../injected_metadata';
|
||||
import { CoreContext } from '../core_system';
|
||||
import { getSessionId } from './get_session_id';
|
||||
|
@ -53,6 +54,7 @@ export class AnalyticsService {
|
|||
// and can benefit other consumers of the client.
|
||||
this.registerSessionIdContext();
|
||||
this.registerBrowserInfoAnalyticsContext();
|
||||
trackClicks(this.analyticsClient, core.env.mode.dev);
|
||||
}
|
||||
|
||||
public setup({ injectedMetadata }: AnalyticsServiceSetupDeps): AnalyticsServiceSetup {
|
||||
|
|
96
src/core/public/analytics/track_clicks.test.ts
Normal file
96
src/core/public/analytics/track_clicks.test.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 } from 'rxjs';
|
||||
import { analyticsClientMock } from './analytics_service.test.mocks';
|
||||
import { trackClicks } from './track_clicks';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
describe('trackClicks', () => {
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('registers the analytics event type and a listener to the "click" events', () => {
|
||||
trackClicks(analyticsClientMock, true);
|
||||
|
||||
expect(analyticsClientMock.registerEventType).toHaveBeenCalledTimes(1);
|
||||
expect(analyticsClientMock.registerEventType).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventType: 'click',
|
||||
})
|
||||
);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined);
|
||||
});
|
||||
|
||||
test('reports an analytics event when a click event occurs', async () => {
|
||||
// Gather an actual "click" event
|
||||
const event$ = new ReplaySubject<MouseEvent>(1);
|
||||
const parent = document.createElement('div');
|
||||
parent.setAttribute('data-test-subj', 'test-click-target-parent');
|
||||
const element = document.createElement('button');
|
||||
parent.appendChild(element);
|
||||
element.setAttribute('data-test-subj', 'test-click-target');
|
||||
element.innerText = 'test'; // Only to validate that it is not included in the event.
|
||||
element.value = 'test'; // Only to validate that it is not included in the event.
|
||||
element.addEventListener('click', (e) => event$.next(e));
|
||||
element.click();
|
||||
// Using an observable because the event might not be immediately available
|
||||
const event = await firstValueFrom(event$.pipe(take(1)));
|
||||
event$.complete(); // No longer needed
|
||||
|
||||
trackClicks(analyticsClientMock, true);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
(addEventListenerSpy.mock.calls[0][1] as EventListener)(event);
|
||||
expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analyticsClientMock.reportEvent).toHaveBeenCalledWith('click', {
|
||||
target: [
|
||||
'DIV',
|
||||
'data-test-subj=test-click-target-parent',
|
||||
'BUTTON',
|
||||
'data-test-subj=test-click-target',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('handles any processing errors logging them in dev mode', async () => {
|
||||
trackClicks(analyticsClientMock, true);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A basic MouseEvent does not have a target and will fail the logic, making it go to the catch branch as intended.
|
||||
(addEventListenerSpy.mock.calls[0][1] as EventListener)(new MouseEvent('click'));
|
||||
expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(0);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"Failed to report the click event",
|
||||
Object {
|
||||
"error": [TypeError: Cannot read properties of null (reading 'parentElement')],
|
||||
"event": MouseEvent {
|
||||
"isTrusted": false,
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('swallows any processing errors when not in dev mode', async () => {
|
||||
trackClicks(analyticsClientMock, false);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A basic MouseEvent does not have a target and will fail the logic, making it go to the catch branch as intended.
|
||||
(addEventListenerSpy.mock.calls[0][1] as EventListener)(new MouseEvent('click'));
|
||||
expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(0);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
77
src/core/public/analytics/track_clicks.ts
Normal file
77
src/core/public/analytics/track_clicks.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { fromEvent } from 'rxjs';
|
||||
import type { AnalyticsClient } from '@kbn/analytics-client';
|
||||
|
||||
/** HTML attributes that should be skipped from reporting because they might contain user data */
|
||||
const POTENTIAL_PII_HTML_ATTRIBUTES = ['value'];
|
||||
|
||||
/**
|
||||
* Registers the event type "click" in the analytics client.
|
||||
* Then it listens to all the "click" events in the UI and reports them with the `target` property being a
|
||||
* full list of the element's and its parents' attributes. This allows
|
||||
* @param analytics
|
||||
*/
|
||||
export function trackClicks(analytics: AnalyticsClient, isDevMode: boolean) {
|
||||
analytics.registerEventType<{ target: string[] }>({
|
||||
eventType: 'click',
|
||||
schema: {
|
||||
target: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description:
|
||||
'The attributes of the clicked element and all its parents in the form `{attr.name}={attr.value}`. It allows finding the clicked elements by looking up its attributes like "data-test-subj=my-button".',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// window or document?
|
||||
// I tested it on multiple browsers and it seems to work the same.
|
||||
// My assumption is that window captures other documents like iframes as well?
|
||||
return fromEvent(window, 'click').subscribe((event) => {
|
||||
try {
|
||||
const target = event.target as HTMLElement;
|
||||
analytics.reportEvent('click', { target: getTargetDefinition(target) });
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
// Defensively log the error in dev mode to catch any potential bugs.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to report the click event`, { event, error });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of strings consisting on the tag name and all the attributes of the element.
|
||||
* Additionally, it recursively walks up the DOM tree to find all the parents' definitions and prepends them to the list.
|
||||
*
|
||||
* @example
|
||||
* From
|
||||
* ```html
|
||||
* <div data-test-subj="my-parent">
|
||||
* <div data-test-subj="my-button" />
|
||||
* </div>
|
||||
* ```
|
||||
* it returns ['DIV', 'data-test-subj=my-parent', 'DIV', 'data-test-subj=my-button']
|
||||
* @param target The child node to start from.
|
||||
*/
|
||||
function getTargetDefinition(target: HTMLElement): string[] {
|
||||
return [
|
||||
...(target.parentElement ? getTargetDefinition(target.parentElement) : []),
|
||||
target.tagName,
|
||||
...[...target.attributes]
|
||||
.filter((attr) => !POTENTIAL_PII_HTML_ATTRIBUTES.includes(attr.name))
|
||||
.map((attr) => `${attr.name}=${attr.value}`),
|
||||
];
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../services';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const ebtUIHelper = getService('kibana_ebt_ui');
|
||||
const { common } = getPageObjects(['common']);
|
||||
|
||||
describe('General "click"', () => {
|
||||
beforeEach(async () => {
|
||||
await common.navigateToApp('home');
|
||||
// Just click on the top div and expect it's still there... we're just testing the click event generation
|
||||
await common.clickAndValidate('kibanaChrome', 'kibanaChrome');
|
||||
});
|
||||
|
||||
it('should emit a "click" event', async () => {
|
||||
const [event] = await ebtUIHelper.getLastEvents(1, ['click']);
|
||||
expect(event.event_type).to.eql('click');
|
||||
expect(event.properties.target).to.be.an('array');
|
||||
const targets = event.properties.target as string[];
|
||||
expect(targets.includes('DIV')).to.be(true);
|
||||
expect(targets.includes('id=kibana-body')).to.be(true);
|
||||
expect(targets.includes('data-test-subj=kibanaChrome')).to.be(true);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../../services';
|
|||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('from the browser', () => {
|
||||
// Add tests for UI-instrumented events here:
|
||||
loadTestFile(require.resolve('./click'));
|
||||
loadTestFile(require.resolve('./loaded_kibana'));
|
||||
loadTestFile(require.resolve('./core_context_providers'));
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue