[EBT] Track "click" events (#131755)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alejandro Fernández Haro 2022-05-10 14:04:45 +02:00 committed by GitHub
parent 7df8edf9b3
commit ab43a29f01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 209 additions and 0 deletions

View file

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

View 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);
});
});

View 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}`),
];
}

View file

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

View file

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