Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(instrumentation-user-interaction): support clicks in React apps #537

Merged
merged 4 commits into from Jun 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -98,10 +98,13 @@ export class UserInteractionInstrumentation extends InstrumentationBase<unknown>
* @param eventName
*/
private _createSpan(
element: HTMLElement,
element: EventTarget | null | undefined,
eventName: string,
parentSpan?: api.Span | undefined
): api.Span | undefined {
if (!(element instanceof HTMLElement)) {
return undefined;
}
if (!element.getAttribute) {
return undefined;
}
Expand Down Expand Up @@ -254,7 +257,7 @@ export class UserInteractionInstrumentation extends InstrumentationBase<unknown>
* auto instrument the click events
* This is done when zone is not available
*/
private _patchElement() {
private _patchAddEventListener() {
const plugin = this;
return (original: Function) => {
return function addEventListenerPatched(
Expand All @@ -265,9 +268,9 @@ export class UserInteractionInstrumentation extends InstrumentationBase<unknown>
) {
const once = useCapture && useCapture.once;
const patchedListener = (...args: any[]) => {
const target = this;
let parentSpan: api.Span | undefined;
const event: Event | undefined = args[0];
const target = event?.target;
if (event) {
parentSpan = plugin._eventsSpanMap.get(event);
}
Expand Down Expand Up @@ -439,7 +442,11 @@ export class UserInteractionInstrumentation extends InstrumentationBase<unknown>
applyThis?: any,
applyArgs?: any
): Zone {
const target: HTMLElement | undefined = task.target;
const event =
Array.isArray(applyArgs) && applyArgs[0] instanceof Event
? applyArgs[0]
: undefined;
const target = event?.target;
let span: api.Span | undefined;
const activeZone = this;
if (target) {
Expand Down Expand Up @@ -564,23 +571,23 @@ export class UserInteractionInstrumentation extends InstrumentationBase<unknown>
);
} else {
this._zonePatched = false;
if (isWrapped(HTMLElement.prototype.addEventListener)) {
this._unwrap(HTMLElement.prototype, 'addEventListener');
if (isWrapped(EventTarget.prototype.addEventListener)) {
this._unwrap(EventTarget.prototype, 'addEventListener');
api.diag.debug('removing previous patch from method addEventListener');
}
if (isWrapped(HTMLElement.prototype.removeEventListener)) {
this._unwrap(HTMLElement.prototype, 'removeEventListener');
if (isWrapped(EventTarget.prototype.removeEventListener)) {
this._unwrap(EventTarget.prototype, 'removeEventListener');
api.diag.debug(
'removing previous patch from method removeEventListener'
);
}
this._wrap(
HTMLElement.prototype,
EventTarget.prototype,
'addEventListener',
this._patchElement()
this._patchAddEventListener()
);
this._wrap(
HTMLElement.prototype,
EventTarget.prototype,
'removeEventListener',
this._patchRemoveEventListener()
);
Expand Down
Expand Up @@ -21,7 +21,7 @@ import * as types from '@opentelemetry/api';
*/
export type AsyncTask = Task & {
eventName: string;
target: HTMLElement;
target: EventTarget;
// Allows access to the private `_zone` property of a Zone.js Task.
_zone: Zone;
};
Expand Down
Expand Up @@ -36,10 +36,8 @@ export function createButton(disabled?: boolean): HTMLElement {

export function fakeInteraction(
callback: Function = function () {},
elem?: HTMLElement
element: HTMLElement = createButton()
) {
const element: HTMLElement = elem || createButton();

element.addEventListener('click', () => {
callback();
});
Expand Down
Expand Up @@ -337,10 +337,15 @@ describe('UserInteractionInstrumentation', () => {
callCount++;
};
document.body.addEventListener('click', listener1);
document.body.firstElementChild?.addEventListener('click', listener2);
document.body.firstElementChild?.dispatchEvent(
new MouseEvent('click', { bubbles: true })
);
try {
document.body.firstElementChild?.addEventListener('click', listener2);
document.body.firstElementChild?.dispatchEvent(
new MouseEvent('click', { bubbles: true })
);
} finally {
// remove added listener so we don't pollute other tests
document.body.removeEventListener('click', listener1);
}
assert.strictEqual(callCount, 2);
assert.strictEqual(exportSpy.args.length, 2);
assert.strictEqual(
Expand Down Expand Up @@ -410,6 +415,122 @@ describe('UserInteractionInstrumentation', () => {
});
});

it('should handle interactions listened on document - react < 17', done => {
const btn1 = document.createElement('button');
btn1.setAttribute('id', 'btn1');
document.body.appendChild(btn1);
const btn2 = document.createElement('button');
btn2.setAttribute('id', 'btn2');
document.body.appendChild(btn2);

const listener = (event: MouseEvent) => {
switch (event.target) {
case btn1:
getData(FILE_URL, () => {
sandbox.clock.tick(10);
}).then(() => {});
break;
case btn2:
getData(FILE_URL, () => {
sandbox.clock.tick(10);
}).then(() => {});
break;
}
};

document.addEventListener('click', listener);

try {
btn1.click();
btn2.click();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate this test (having two targets and ensuring that the span names properly line up)!

} finally {
// remove added listener so we don't pollute other tests
document.removeEventListener('click', listener);
}

sandbox.clock.tick(1000);
originalSetTimeout(() => {
assert.equal(exportSpy.args.length, 4, 'should export 4 spans');

const span1: tracing.ReadableSpan = exportSpy.args[0][0][0];
const span2: tracing.ReadableSpan = exportSpy.args[1][0][0];
const span3: tracing.ReadableSpan = exportSpy.args[2][0][0];
const span4: tracing.ReadableSpan = exportSpy.args[3][0][0];

assertClickSpan(span1, 'btn1');
assertClickSpan(span2, 'btn2');

assert.strictEqual(
span1.spanContext().spanId,
span3.parentSpanId,
'span3 has wrong parent'
);
assert.strictEqual(
span2.spanContext().spanId,
span4.parentSpanId,
'span4 has wrong parent'
);

done();
});
});

it('should handle interactions listened on a parent element (bubbled events) - react >= 17', done => {
const root = document.createElement('div');
document.body.appendChild(root);

const btn1 = document.createElement('button');
btn1.setAttribute('id', 'btn1');
root.appendChild(btn1);
const btn2 = document.createElement('button');
btn2.setAttribute('id', 'btn2');
root.appendChild(btn2);

root.addEventListener('click', event => {
switch (event.target) {
case btn1:
getData(FILE_URL, () => {
sandbox.clock.tick(10);
}).then(() => {});
break;
case btn2:
getData(FILE_URL, () => {
sandbox.clock.tick(10);
}).then(() => {});
break;
}
});

btn1.click();
btn2.click();

sandbox.clock.tick(1000);
originalSetTimeout(() => {
assert.equal(exportSpy.args.length, 4, 'should export 4 spans');

const span1: tracing.ReadableSpan = exportSpy.args[0][0][0];
const span2: tracing.ReadableSpan = exportSpy.args[1][0][0];
const span3: tracing.ReadableSpan = exportSpy.args[2][0][0];
const span4: tracing.ReadableSpan = exportSpy.args[3][0][0];

assertClickSpan(span1, 'btn1');
assertClickSpan(span2, 'btn2');

assert.strictEqual(
span1.spanContext().spanId,
span3.parentSpanId,
'span3 has wrong parent'
);
assert.strictEqual(
span2.spanContext().spanId,
span4.parentSpanId,
'span4 has wrong parent'
);

done();
});
});

it('should handle disable', () => {
assert.strictEqual(
isWrapped(HTMLElement.prototype.addEventListener),
Expand Down
Expand Up @@ -40,6 +40,13 @@ const FILE_URL =
'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/main/package.json';

describe('UserInteractionInstrumentation', () => {
afterEach(() => {
// clear body from elements created by some tests to make sure they are independent
while (document.body.lastChild) {
document.body.removeChild(document.body.lastChild);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this is necessary? Perhaps a comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment added.

}
});

describe('when zone.js is available', () => {
let contextManager: ZoneContextManager;
let userInteractionInstrumentation: UserInteractionInstrumentation;
Expand Down Expand Up @@ -313,6 +320,122 @@ describe('UserInteractionInstrumentation', () => {
});
});

it('should handle interactions listened on document - react < 17', done => {
const btn1 = document.createElement('button');
btn1.setAttribute('id', 'btn1');
document.body.appendChild(btn1);
const btn2 = document.createElement('button');
btn2.setAttribute('id', 'btn2');
document.body.appendChild(btn2);

const listener = (event: MouseEvent) => {
switch (event.target) {
case btn1:
getData(FILE_URL, () => {
sandbox.clock.tick(10);
}).then(() => {});
break;
case btn2:
getData(FILE_URL, () => {
sandbox.clock.tick(10);
}).then(() => {});
break;
}
};

document.addEventListener('click', listener);

try {
btn1.click();
btn2.click();
} finally {
// remove added listener so we don't pollute other tests
document.removeEventListener('click', listener);
}

sandbox.clock.tick(1000);
originalSetTimeout(() => {
assert.equal(exportSpy.args.length, 4, 'should export 4 spans');

const span1: tracing.ReadableSpan = exportSpy.args[0][0][0];
const span2: tracing.ReadableSpan = exportSpy.args[1][0][0];
const span3: tracing.ReadableSpan = exportSpy.args[2][0][0];
const span4: tracing.ReadableSpan = exportSpy.args[3][0][0];

assertClickSpan(span1, 'btn1');
assertClickSpan(span2, 'btn2');

assert.strictEqual(
span1.spanContext().spanId,
span3.parentSpanId,
'span3 has wrong parent'
);
assert.strictEqual(
span2.spanContext().spanId,
span4.parentSpanId,
'span4 has wrong parent'
);

done();
});
});

it('should handle interactions listened on a parent element (bubbled events) - react >= 17', done => {
const root = document.createElement('div');
document.body.appendChild(root);

const btn1 = document.createElement('button');
btn1.setAttribute('id', 'btn1');
root.appendChild(btn1);
const btn2 = document.createElement('button');
btn2.setAttribute('id', 'btn2');
root.appendChild(btn2);

root.addEventListener('click', event => {
switch (event.target) {
case btn1:
getData(FILE_URL, () => {
sandbox.clock.tick(10);
}).then(() => {});
break;
case btn2:
getData(FILE_URL, () => {
sandbox.clock.tick(10);
}).then(() => {});
break;
}
});

btn1.click();
btn2.click();

sandbox.clock.tick(1000);
originalSetTimeout(() => {
assert.equal(exportSpy.args.length, 4, 'should export 4 spans');

const span1: tracing.ReadableSpan = exportSpy.args[0][0][0];
const span2: tracing.ReadableSpan = exportSpy.args[1][0][0];
const span3: tracing.ReadableSpan = exportSpy.args[2][0][0];
const span4: tracing.ReadableSpan = exportSpy.args[3][0][0];

assertClickSpan(span1, 'btn1');
assertClickSpan(span2, 'btn2');

assert.strictEqual(
span1.spanContext().spanId,
span3.parentSpanId,
'span3 has wrong parent'
);
assert.strictEqual(
span2.spanContext().spanId,
span4.parentSpanId,
'span4 has wrong parent'
);

done();
});
});

it('should handle unpatch', () => {
const _window: WindowWithZone = window as unknown as WindowWithZone;
const ZoneWithPrototype = _window.Zone;
Expand Down