Skip to content

Commit

Permalink
feat: Dynamically update DOM event listeners (#112)
Browse files Browse the repository at this point in the history
  • Loading branch information
limhjgrace committed Mar 4, 2022
1 parent 2a67daa commit d4bfbb5
Show file tree
Hide file tree
Showing 6 changed files with 400 additions and 26 deletions.
17 changes: 17 additions & 0 deletions app/dom_event.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@
cwr('enable');
}

function dynamicallyCreateButton() {
const parentButton = document.getElementById(
'dynamicallyCreateButton'
);
const newButton = document.createElement('button');
newButton.innerHTML = 'Button Four';
newButton.id = 'button4';
newButton.setAttribute('label', 'label1');
parentButton.insertAdjacentElement('afterend', newButton);
}

function registerDomEvents() {
cwr('registerDomEvents', [
{ event: 'click', cssLocator: '[label="label2"]' }
Expand Down Expand Up @@ -68,6 +79,12 @@
<button id="button2" label="label1">Button Two</button>
<button id="button3" label="label1">Button Three</button>
<hr />
<button
id="dynamicallyCreateButton"
onclick="dynamicallyCreateButton()"
>
Add Button
</button>
<button id="registerDomEvents" onclick="registerDomEvents()">
Update Plugin
</button>
Expand Down
2 changes: 1 addition & 1 deletion docs/cdn_installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ telemetries: [

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| events | Array | `[]` | An array of target DOM events to record. Each DOM event is defined by an *event* and a *selector*. The event must be a [DOM event](https://www.w3schools.com/jsref/dom_obj_event.asp). The selector must be one of (1) `cssLocator`, (2) `elementId` or (3) `element`.<br/><br/>When two or more selectors are provided for a target DOM event, only one selector will be used. The selectors will be honored with the following precedence: (1) `cssLocator`, (2) `elementId` or (3) `element`. For example, if both `cssLocator` and `elementId` are provided, only the `cssLocator` selector will be used.<br/><br/>**Examples:**<br/>Record all elements identified by CSS selector `[label="label1"]`:<br/> `[{ event: 'click', cssLocator: '[label="label1"]'`<br/><br/>Record a single element with ID `mybutton`:<br/>`[{ event: 'click', elementId: 'mybutton' }]`<br/><br/>Record a complete clickstream<br/>`[{ event: 'click', element: document }]`. |
| events | Array | `[]` | An array of target DOM events to record. Each DOM event is defined by an *event* and a *selector*. The event must be a [DOM event](https://www.w3schools.com/jsref/dom_obj_event.asp). The selector must be one of (1) `cssLocator`, (2) `elementId` or (3) `element`.<br/><br/>When two or more selectors are provided for a target DOM event, only one selector will be used. The selectors will be honored with the following precedence: (1) `cssLocator`, (2) `elementId` or (3) `element`. For example, if both `cssLocator` and `elementId` are provided, only the `cssLocator` selector will be used.<br/><br/>**Examples:**<br/>Record all elements identified by CSS selector `[label="label1"]`:<br/> `[{ event: 'click', cssLocator: '[label="label1"]' }]`<br/><br/>Record a single element with ID `mybutton`:<br/>`[{ event: 'click', elementId: 'mybutton' }]`<br/><br/>Record a complete clickstream<br/>`[{ event: 'click', element: document }]`. |

## Performance

Expand Down
4 changes: 4 additions & 0 deletions src/loader/loader-dom-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ loader('cwr', 'abc123', '1.0', 'us-west-2', './rum_javascript_telemetry.js', {
event: 'click',
elementId: 'button1',
element: document
},
{
event: 'click',
elementId: 'button4'
}
]
})
Expand Down
79 changes: 54 additions & 25 deletions src/plugins/event-plugins/DomEventPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,25 @@ const defaultConfig: DomEventPluginConfig = {
events: []
};

export type ElementEventListener = {
element: HTMLElement;
eventListener: EventListener;
};

export class DomEventPlugin implements Plugin {
private recordEvent: RecordEvent | undefined;
private pluginId: string;
private eventListenerMap: Map<TargetDomEvent, EventListener>;
private eventListenerMap: Map<TargetDomEvent, ElementEventListener[]>;
private enabled: boolean;
private config: DomEventPluginConfig;
private observer: MutationObserver;

constructor(config?: PartialDomEventPluginConfig) {
this.pluginId = DOM_EVENT_PLUGIN_ID;
this.eventListenerMap = new Map<TargetDomEvent, EventListener>();
this.eventListenerMap = new Map<
TargetDomEvent,
ElementEventListener[]
>();
this.config = { ...defaultConfig, ...config };
this.enabled = false;
}
Expand All @@ -62,6 +71,7 @@ export class DomEventPlugin implements Plugin {
return;
}
this.addListeners();
this.observeDOMMutation();
this.enabled = true;
}

Expand All @@ -70,6 +80,7 @@ export class DomEventPlugin implements Plugin {
return;
}
this.removeListeners();
this.observer.disconnect();
this.enabled = false;
}

Expand Down Expand Up @@ -131,41 +142,59 @@ export class DomEventPlugin implements Plugin {
private addEventHandler(domEvent: TargetDomEvent): void {
const eventType = domEvent.event;
const eventListener = this.getEventListener(domEvent.cssLocator);
this.eventListenerMap.set(domEvent, eventListener);

const identifiedElementList = [];
const elementEventListenerList: ElementEventListener[] = this.eventListenerMap.has(
domEvent
)
? this.eventListenerMap.get(domEvent)
: [];

// first add event listener to all elements identified by the CSS locator
if (domEvent.cssLocator) {
const elementList = document.querySelectorAll(domEvent.cssLocator);
elementList.forEach((element: HTMLElement) => {
element.addEventListener(eventType, eventListener);
const cssLocatedElements = document.querySelectorAll(
domEvent.cssLocator
);
cssLocatedElements.forEach((element) => {
identifiedElementList.push(element);
});
} else if (domEvent.elementId) {
document
.getElementById(domEvent.elementId)
?.addEventListener(eventType, eventListener);
const identifiedElement = document.getElementById(
domEvent.elementId
);
if (identifiedElement) {
identifiedElementList.push(identifiedElement);
}
} else if (domEvent.element) {
domEvent.element.addEventListener(eventType, eventListener);
identifiedElementList.push(domEvent.element);
}

identifiedElementList.forEach((element) => {
element.addEventListener(eventType, eventListener);
elementEventListenerList.push({ element, eventListener });
});

this.eventListenerMap.set(domEvent, elementEventListenerList);
}

private removeEventHandler(domEvent: TargetDomEvent): void {
const eventListener:
| EventListener
| undefined = this.eventListenerMap.get(domEvent);

if (domEvent.cssLocator && eventListener) {
const elementList = document.querySelectorAll(domEvent.cssLocator);
elementList.forEach((element: HTMLElement) => {
const elementEventListenerList = this.eventListenerMap.get(domEvent);
if (elementEventListenerList) {
elementEventListenerList.forEach((elementEventListener) => {
const element = elementEventListener.element;
const eventListener = elementEventListener.eventListener;
element.removeEventListener(domEvent.event, eventListener);
});
} else if (domEvent.elementId && eventListener) {
const element = document.getElementById(domEvent.elementId);
if (element) {
element.removeEventListener(domEvent.event, eventListener);
}
} else if (domEvent.element && eventListener) {
domEvent.element.removeEventListener(domEvent.event, eventListener);
this.eventListenerMap.delete(domEvent);
}
this.eventListenerMap.delete(domEvent);
}

private observeDOMMutation() {
this.observer = new MutationObserver(() => {
this.removeListeners();
this.addListeners();
});
// we track only changes to nodes/DOM elements, not attributes or characterData
this.observer.observe(document, { childList: true, subtree: true });
}
}
151 changes: 151 additions & 0 deletions src/plugins/event-plugins/__integ__/DomEventPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const button2: Selector = Selector(`#button2`);
const button3: Selector = Selector(`#button3`);

const registerDomEvents: Selector = Selector(`#registerDomEvents`);
const dynamicallyCreateButton: Selector = Selector(`#dynamicallyCreateButton`);
const button4: Selector = Selector(`#button4`);
const button5: Selector = Selector(`#button5`);

const dispatch: Selector = Selector(`#dispatch`);
Expand Down Expand Up @@ -297,3 +299,152 @@ test('when new DOM events are registered and then a button is clicked, the event
});
}
});

test('when listening for a click on a dynamically added element given an element id, the event is recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(dynamicallyCreateButton)
.wait(300)
.click(button4)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).elementId === 'button4'
);

const eventType = events[0].type;
const eventDetails = JSON.parse(events[0].details);

await t
.expect(eventType)
.eql(DOM_EVENT_TYPE)
.expect(eventDetails)
.contains({
event: 'click',
elementId: 'button4'
});
});

test('when listening for a click on a dynamically added element given a CSS locator, the event is recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(dynamicallyCreateButton)
.wait(300)
.click(button4)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).cssLocator === '[label="label1"]'
);

for (let i = 0; i < events.length; i++) {
let eventType = events[i].type;
let eventDetails = JSON.parse(events[i].details);

await t
.expect(events.length)
.eql(1)
.expect(eventType)
.eql(DOM_EVENT_TYPE)
.expect(eventDetails)
.contains({
event: 'click',
cssLocator: '[label="label1"]'
});
}
});

test('when listening for a click given an element id on an existing element and a dynamically added element, both events are recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(dynamicallyCreateButton)
.wait(300)
.click(button4)
.click(button2)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).cssLocator === '[label="label1"]'
);

for (let i = 0; i < events.length; i++) {
let eventType = events[i].type;
let eventDetails = JSON.parse(events[i].details);

await t
.expect(events.length)
.eql(2)
.expect(eventType)
.eql(DOM_EVENT_TYPE)
.expect(eventDetails)
.contains({
event: 'click',
cssLocator: '[label="label1"]'
});
}
});

test('when client is disabled then click on dynamically added element is not recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(disable)
.click(dynamicallyCreateButton)
.wait(300)
.click(button4)
.click(enable)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).elementId === 'button4'
);

await t.expect(events.length).eql(0);
});

test('when client is disabled then clicks on existing or dynamically added element are not recorded', async (t: TestController) => {
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
await t
.wait(300)
.click(disable)
.click(dynamicallyCreateButton)
.wait(300)
.click(button4)
.click(button2)
.click(enable)
.click(dispatch)
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) =>
e.type === DOM_EVENT_TYPE &&
JSON.parse(e.details).elementId === 'button2'
);

await t.expect(events.length).eql(0);
});

0 comments on commit d4bfbb5

Please sign in to comment.