diff --git a/examples/tracer-web/examples/user-interaction/index.html b/examples/tracer-web/examples/user-interaction/index.html new file mode 100644 index 0000000000..0209a4d8f2 --- /dev/null +++ b/examples/tracer-web/examples/user-interaction/index.html @@ -0,0 +1,39 @@ + + + + + + User Interaction Example + + + + + + + + + + Example of using Web Tracer with UserInteractionPlugin and XMLHttpRequestPlugin with console exporter and collector exporter + +
+ +
+
+
+
+
+
+
+
+ + + + diff --git a/examples/tracer-web/examples/user-interaction/index.js b/examples/tracer-web/examples/user-interaction/index.js new file mode 100644 index 0000000000..2e764f5cf0 --- /dev/null +++ b/examples/tracer-web/examples/user-interaction/index.js @@ -0,0 +1,80 @@ +import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/tracing'; +import { WebTracerProvider } from '@opentelemetry/web'; +import { XMLHttpRequestPlugin } from '@opentelemetry/plugin-xml-http-request'; +import { UserInteractionPlugin } from '@opentelemetry/plugin-user-interaction'; +import { ZoneScopeManager } from '@opentelemetry/scope-zone'; +import { CollectorExporter } from '@opentelemetry/exporter-collector'; +import { B3Format } from '@opentelemetry/core'; + +const providerWithZone = new WebTracerProvider({ + httpTextFormat: new B3Format(), + scopeManager: new ZoneScopeManager(), + plugins: [ + new UserInteractionPlugin(), + new XMLHttpRequestPlugin({ + ignoreUrls: [/localhost:8090\/sockjs-node/], + propagateTraceHeaderCorsUrls: [ + 'http://localhost:8090' + ] + }) + ] +}); + +providerWithZone.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); +providerWithZone.addSpanProcessor(new SimpleSpanProcessor(new CollectorExporter())); + +let lastButtonId = 0; + +function btnAddClick() { + lastButtonId++; + const btn = document.createElement('button'); + // for easier testing of element xpath + let navigate = false; + if (lastButtonId % 2 === 0) { + btn.setAttribute('id', `button${lastButtonId}`); + navigate = true; + } + btn.setAttribute('class', `buttonClass${lastButtonId}`); + btn.append(document.createTextNode(`Click ${lastButtonId}`)); + btn.addEventListener('click', onClick.bind(this, navigate)); + document.querySelector('#buttons').append(btn); +} + +function prepareClickEvents() { + for (let i = 0; i < 5; i++) { + btnAddClick(); + } + const btnAdd = document.getElementById('btnAdd'); + btnAdd.addEventListener('click', btnAddClick); +} + +function onClick(navigate) { + if (navigate) { + history.pushState({ test: 'testing' }, '', `${location.pathname}`); + history.pushState({ test: 'testing' }, '', `${location.pathname}#foo=bar1`); + } + getData('https://httpbin.org/get?a=1').then(() => { + getData('https://httpbin.org/get?a=1').then(() => { + console.log('data downloaded 2'); + }); + getData('https://httpbin.org/get?a=1').then(() => { + console.log('data downloaded 3'); + }); + console.log('data downloaded 1'); + }); +} + +function getData(url, resolve) { + return new Promise(async (resolve, reject) => { + const req = new XMLHttpRequest(); + req.open('GET', url, true); + req.setRequestHeader('Content-Type', 'application/json'); + req.setRequestHeader('Accept', 'application/json'); + req.send(); + req.onload = function () { + resolve(); + }; + }); +} + +window.addEventListener('load', prepareClickEvents); diff --git a/examples/tracer-web/examples/xml-http-request/index.js b/examples/tracer-web/examples/xml-http-request/index.js index bf705d3d7f..3ad74ea04c 100644 --- a/examples/tracer-web/examples/xml-http-request/index.js +++ b/examples/tracer-web/examples/xml-http-request/index.js @@ -1,4 +1,3 @@ - import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/tracing'; import { WebTracerProvider } from '@opentelemetry/web'; import { XMLHttpRequestPlugin } from '@opentelemetry/plugin-xml-http-request'; diff --git a/examples/tracer-web/package.json b/examples/tracer-web/package.json index 7a68243c30..666fa4892c 100644 --- a/examples/tracer-web/package.json +++ b/examples/tracer-web/package.json @@ -37,6 +37,7 @@ "@opentelemetry/core": "^0.3.3", "@opentelemetry/exporter-collector": "^0.3.3", "@opentelemetry/plugin-document-load": "^0.3.3", + "@opentelemetry/plugin-user-interaction": "^0.3.3", "@opentelemetry/plugin-xml-http-request": "^0.3.3", "@opentelemetry/scope-zone": "^0.3.3", "@opentelemetry/tracing": "^0.3.3", diff --git a/examples/tracer-web/webpack.config.js b/examples/tracer-web/webpack.config.js index 8f7fed9410..b23949d731 100644 --- a/examples/tracer-web/webpack.config.js +++ b/examples/tracer-web/webpack.config.js @@ -9,6 +9,7 @@ const common = { entry: { 'document-load': 'examples/document-load/index.js', 'xml-http-request': 'examples/xml-http-request/index.js', + 'user-interaction': 'examples/user-interaction/index.js', }, output: { path: path.resolve(__dirname, 'dist'), diff --git a/lerna.json b/lerna.json index 27367019e2..39f25f28f0 100644 --- a/lerna.json +++ b/lerna.json @@ -2,6 +2,7 @@ "lerna": "3.13.4", "npmClient": "npm", "packages": [ + "examples/tracer-web", "benchmark/*", "packages/*", "packages/opentelemetry-plugin-postgres/*" diff --git a/packages/opentelemetry-plugin-user-interaction/LICENSE b/packages/opentelemetry-plugin-user-interaction/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/opentelemetry-plugin-user-interaction/README.md b/packages/opentelemetry-plugin-user-interaction/README.md new file mode 100644 index 0000000000..d1fd09fbb1 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/README.md @@ -0,0 +1,104 @@ +# OpenTelemetry UserInteraction Plugin for web +[![Gitter chat][gitter-image]][gitter-url] +[![NPM Published Version][npm-img]][npm-url] +[![dependencies][dependencies-image]][dependencies-url] +[![devDependencies][devDependencies-image]][devDependencies-url] +[![Apache License][license-image]][license-image] + +This module provides auto instrumentation of user interaction for web. +This module can work either with [zone-js] or without it. +With [zone-js] and ZoneScopeManager it will fully support the async operations. +If you use Angular you already have the [zone-js]. It will be the same if you use [@opentelemetry/scope-zone]. +Without [zone-js] it will still work but with limited support. + +## Installation + +```bash +npm install --save @opentelemetry/plugin-user-interaction +``` + +## Usage + +```js +import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/tracing'; +import { WebTracer } from '@opentelemetry/web'; +import { UserInteractionPlugin } from '@opentelemetry/plugin-user-interaction'; +import { ZoneScopeManager } from '@opentelemetry/scope-zone'; +// or if you already have zone.js +// import { ZoneScopeManager } from '@opentelemetry/scope-zone-peer-dep'; + +const webTracerWithZone = new WebTracer({ + scopeManager: new ZoneScopeManager(), // optional + plugins: [ + new UserInteractionPlugin() + ] +}); +webTracerWithZone.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); + +// and some test +const btn1 = document.createElement('button'); +btn1.append(document.createTextNode('btn1')); +btn1.addEventListener('click', () => { + console.log('clicked'); +}); +document.querySelector('body').append(btn1); + +const btn2 = document.createElement('button'); +btn2.append(document.createTextNode('btn2')); +btn2.addEventListener('click', () => { + getData('https://httpbin.org/get').then(() => { + getData('https://httpbin.org/get').then(() => { + console.log('data downloaded 2'); + }); + getData('https://httpbin.org/get').then(() => { + console.log('data downloaded 3'); + }); + console.log('data downloaded 1'); + }); +}); +document.querySelector('body').append(btn2); + +function getData(url) { + return new Promise(async (resolve) => { + const req = new XMLHttpRequest(); + req.open('GET', url, true); + req.setRequestHeader('Content-Type', 'application/json'); + req.setRequestHeader('Accept', 'application/json'); + req.send(); + req.onload = function () { + resolve(); + }; + }); +} + +// now click on buttons + +``` + +## Example Screenshots +![Screenshot of the running example](images/main.jpg) +![Screenshot of the running example](images/click.jpg) +![Screenshot of the running example](images/main-sync.jpg) +![Screenshot of the running example](images/click-sync.jpg) + +## Useful links +- For more information on OpenTelemetry, visit: +- For more about OpenTelemetry JavaScript: +- For help or feedback on this project, join us on [gitter][gitter-url] + +## License + +Apache 2.0 - See [LICENSE][license-url] for more information. + +[gitter-image]: https://badges.gitter.im/open-telemetry/opentelemetry-js.svg +[gitter-url]: https://gitter.im/open-telemetry/opentelemetry-node?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge +[license-url]: https://github.com/open-telemetry/opentelemetry-js/blob/master/LICENSE +[license-image]: https://img.shields.io/badge/license-Apache_2.0-green.svg?style=flat +[dependencies-image]: https://david-dm.org/open-telemetry/opentelemetry-js/status.svg?path=packages/opentelemetry-plugin-user-interaction +[dependencies-url]: https://david-dm.org/open-telemetry/opentelemetry-js?path=packages%2Fopentelemetry-plugin-user-interaction +[devDependencies-image]: https://david-dm.org/open-telemetry/opentelemetry-js/dev-status.svg?path=packages/opentelemetry-plugin-user-interaction +[devDependencies-url]: https://david-dm.org/open-telemetry/opentelemetry-js?path=packages%2Fopentelemetry-plugin-user-interaction&type=dev +[npm-url]: https://www.npmjs.com/package/@opentelemetry/plugin-user-interaction +[npm-img]: https://badge.fury.io/js/%40opentelemetry%plugin-user-interaction.svg +[zone-js]: https://www.npmjs.com/package/zone.js +[@opentelemetry/scope-zone]: https://www.npmjs.com/package/@opentelemetry/scope-zone diff --git a/packages/opentelemetry-plugin-user-interaction/images/click-sync.jpg b/packages/opentelemetry-plugin-user-interaction/images/click-sync.jpg new file mode 100644 index 0000000000..87b80e2b13 Binary files /dev/null and b/packages/opentelemetry-plugin-user-interaction/images/click-sync.jpg differ diff --git a/packages/opentelemetry-plugin-user-interaction/images/click.jpg b/packages/opentelemetry-plugin-user-interaction/images/click.jpg new file mode 100644 index 0000000000..708d88f51f Binary files /dev/null and b/packages/opentelemetry-plugin-user-interaction/images/click.jpg differ diff --git a/packages/opentelemetry-plugin-user-interaction/images/main-sync.jpg b/packages/opentelemetry-plugin-user-interaction/images/main-sync.jpg new file mode 100644 index 0000000000..e53d0d0253 Binary files /dev/null and b/packages/opentelemetry-plugin-user-interaction/images/main-sync.jpg differ diff --git a/packages/opentelemetry-plugin-user-interaction/images/main.jpg b/packages/opentelemetry-plugin-user-interaction/images/main.jpg new file mode 100644 index 0000000000..515cb74ada Binary files /dev/null and b/packages/opentelemetry-plugin-user-interaction/images/main.jpg differ diff --git a/packages/opentelemetry-plugin-user-interaction/karma.conf.js b/packages/opentelemetry-plugin-user-interaction/karma.conf.js new file mode 100644 index 0000000000..67456dce5c --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/karma.conf.js @@ -0,0 +1,25 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const karmaWebpackConfig = require('../../karma.webpack'); +const karmaBaseConfig = require('../../karma.base'); + +module.exports = (config) => { + config.set(Object.assign({}, karmaBaseConfig, { + frameworks: karmaBaseConfig.frameworks.concat(['jquery-1.8.3']), + webpack: karmaWebpackConfig, + })) +}; diff --git a/packages/opentelemetry-plugin-user-interaction/package.json b/packages/opentelemetry-plugin-user-interaction/package.json new file mode 100644 index 0000000000..736f136287 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/package.json @@ -0,0 +1,91 @@ +{ + "name": "@opentelemetry/plugin-user-interaction", + "version": "0.3.3", + "description": "OpenTelemetry UserInteraction automatic instrumentation package.", + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": "open-telemetry/opentelemetry-js", + "scripts": { + "check": "gts check", + "clean": "rimraf build/*", + "codecov:browser": "nyc report --reporter=json && codecov -f coverage/*.json -p ../../", + "precompile": "tsc --version", + "version:update": "node ../../scripts/version-update.js", + "compile": "npm run version:update && tsc -p .", + "fix": "gts fix", + "prepare": "npm run compile", + "tdd": "karma start", + "test:browser": "nyc karma start --single-run", + "watch": "tsc -w" + }, + "keywords": [ + "opentelemetry", + "web", + "tracing", + "profiling", + "metrics", + "stats" + ], + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.d.ts", + "doc", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@babel/core": "^7.6.0", + "@opentelemetry/plugin-xml-http-request": "^0.3.3", + "@opentelemetry/scope-zone-peer-dep": "^0.3.3", + "@opentelemetry/tracing": "^0.3.3", + "@types/jquery": "^3.3.31", + "@types/mocha": "^5.2.5", + "@types/node": "^12.6.8", + "@types/shimmer": "^1.0.1", + "@types/sinon": "^7.0.13", + "@types/webpack-env": "1.13.9", + "@types/zone.js": "^0.5.12", + "babel-loader": "^8.0.6", + "codecov": "^3.6.1", + "gts": "^1.1.0", + "istanbul-instrumenter-loader": "^3.0.1", + "karma": "^4.4.1", + "karma-chrome-launcher": "^3.1.0", + "karma-coverage-istanbul-reporter": "^2.1.0", + "karma-jquery": "^0.2.4", + "karma-mocha": "^1.3.0", + "karma-spec-reporter": "^0.0.32", + "karma-webpack": "^4.0.2", + "mocha": "^6.1.0", + "nyc": "^14.1.1", + "rimraf": "^3.0.0", + "sinon": "^7.5.0", + "ts-loader": "^6.0.4", + "ts-mocha": "^6.0.0", + "ts-node": "^8.6.2", + "tslint-consistent-codestyle": "^1.16.0", + "tslint-microsoft-contrib": "^6.2.0", + "typescript": "3.6.4", + "webpack": "^4.35.2", + "webpack-cli": "^3.3.9", + "webpack-merge": "^4.2.2" + }, + "dependencies": { + "@opentelemetry/core": "^0.3.3", + "@opentelemetry/types": "^0.3.3", + "@opentelemetry/web": "^0.3.3", + "shimmer": "^1.2.1" + }, + "peerDependencies": { + "zone.js": "^0.10.2" + }, + "sideEffects": false +} diff --git a/packages/opentelemetry-plugin-user-interaction/src/enums/AttributeNames.ts b/packages/opentelemetry-plugin-user-interaction/src/enums/AttributeNames.ts new file mode 100644 index 0000000000..026f0a70d0 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/src/enums/AttributeNames.ts @@ -0,0 +1,25 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export enum AttributeNames { + COMPONENT = 'component', + EVENT_TYPE = 'event_type', + TARGET_ELEMENT = 'target_element', + TARGET_XPATH = 'target_xpath', + HTTP_URL = 'http.url', + // NOT ON OFFICIAL SPEC + HTTP_USER_AGENT = 'http.user_agent', +} diff --git a/packages/opentelemetry-plugin-user-interaction/src/index.ts b/packages/opentelemetry-plugin-user-interaction/src/index.ts new file mode 100644 index 0000000000..181b4864e9 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/src/index.ts @@ -0,0 +1,17 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './userInteraction'; diff --git a/packages/opentelemetry-plugin-user-interaction/src/types.ts b/packages/opentelemetry-plugin-user-interaction/src/types.ts new file mode 100644 index 0000000000..2eeb5ade16 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/src/types.ts @@ -0,0 +1,63 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as types from '@opentelemetry/types'; + +/** + * Async Zone task + */ +export type AsyncTask = Task & { + eventName: string; + target: HTMLElement; + // Allows access to the private `_zone` property of a Zone.js Task. + _zone: Zone; +}; + +/** + * Type for patching Zone RunTask function + */ +export type RunTaskFunction = ( + task: AsyncTask, + applyThis?: any, + applyArgs?: any +) => Zone; + +/** + * interface to store information in weak map per span + */ +export interface SpanData { + hrTimeLastTimeout?: types.HrTime; + taskCount: number; +} + +/** + * interface to be able to check Zone presence on window + */ +export interface WindowWithZone { + Zone: ZoneTypeWithPrototype; +} + +/** + * interface to be able to use prototype in Zone + */ +interface ZonePrototype { + prototype: any; +} + +/** + * type to be able to use prototype on Zone + */ +export type ZoneTypeWithPrototype = ZonePrototype & Zone; diff --git a/packages/opentelemetry-plugin-user-interaction/src/userInteraction.ts b/packages/opentelemetry-plugin-user-interaction/src/userInteraction.ts new file mode 100644 index 0000000000..224b2eb2cd --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/src/userInteraction.ts @@ -0,0 +1,457 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as shimmer from 'shimmer'; +import { BasePlugin, hrTime, isWrapped } from '@opentelemetry/core'; +import * as types from '@opentelemetry/types'; +import { getElementXPath } from '@opentelemetry/web'; +import { + AsyncTask, + RunTaskFunction, + SpanData, + WindowWithZone, + ZoneTypeWithPrototype, +} from './types'; +import { AttributeNames } from './enums/AttributeNames'; +import { VERSION } from './version'; + +const ZONE_SCOPE_KEY = 'OT_ZONE_SCOPE'; +const EVENT_CLICK_NAME = 'event_click:'; +const EVENT_NAVIGATION_NAME = 'Navigation:'; + +/** + * This class represents a UserInteraction plugin for auto instrumentation. + * If zone.js is available then it patches the zone otherwise it patches + * addEventListener of HTMLElement + */ +export class UserInteractionPlugin extends BasePlugin { + readonly component: string = 'user-interaction'; + readonly version = VERSION; + moduleName = this.component; + private _spansData = new WeakMap(); + private _zonePatched = false; + + constructor() { + super('@opentelemetry/plugin-user-interaction', VERSION); + } + + /** + * This will check if last task was timeout and will save the time to + * fix the user interaction when nothing happens + * This timeout comes from xhr plugin which is needed to collect information + * about last xhr main request from observer + * @param task + * @param span + */ + private _checkForTimeout(task: AsyncTask, span: types.Span) { + const spanData = this._spansData.get(span); + if (spanData) { + if (task.source === 'setTimeout') { + spanData.hrTimeLastTimeout = hrTime(); + } else if ( + task.source !== 'Promise.then' && + task.source !== 'setTimeout' + ) { + spanData.hrTimeLastTimeout = undefined; + } + } + } + + /** + * Creates a new span + * @param element + * @param eventName + */ + private _createSpan( + element: HTMLElement, + eventName: string + ): types.Span | undefined { + if (!element.getAttribute) { + return undefined; + } + if (element.hasAttribute('disabled')) { + return undefined; + } + const xpath = getElementXPath(element, true); + try { + const span = this._tracer.startSpan(`${EVENT_CLICK_NAME} ${xpath}`, { + attributes: { + [AttributeNames.COMPONENT]: this.component, + [AttributeNames.EVENT_TYPE]: eventName, + [AttributeNames.TARGET_ELEMENT]: element.tagName, + [AttributeNames.TARGET_XPATH]: xpath, + [AttributeNames.HTTP_URL]: window.location.href, + [AttributeNames.HTTP_USER_AGENT]: navigator.userAgent, + }, + parent: this._tracer.getCurrentSpan(), + }); + + this._spansData.set(span, { + taskCount: 0, + }); + + return span; + } catch (e) { + this._logger.error(this.component, e); + } + return undefined; + } + + /** + * Decrement number of tasks that left in zone, + * This is needed to be able to end span when no more tasks left + * @param span + */ + private _decrementTask(span: types.Span) { + const spanData = this._spansData.get(span); + if (spanData) { + spanData.taskCount--; + if (spanData.taskCount === 0) { + this._tryToEndSpan(span, spanData.hrTimeLastTimeout); + } + } + } + + /** + * It gets the element that has been clicked when zone tries to run a new task + * @param task + */ + private _getClickedElement(task: AsyncTask): HTMLElement | undefined { + if (task.eventName === 'click') { + return task.target; + } + return undefined; + } + + /** + * Increment number of tasks that are run within the same zone. + * This is needed to be able to end span when no more tasks left + * @param span + */ + private _incrementTask(span: types.Span) { + const spanData = this._spansData.get(span); + if (spanData) { + spanData.taskCount++; + } + } + + /** + * This patches the addEventListener of HTMLElement to be able to + * auto instrument the click events + * This is done when zone is not available + */ + private _patchElement() { + const plugin = this; + return (original: Function) => { + return function addEventListenerPatched( + this: HTMLElement, + type: any, + listener: any, + useCapture: any + ) { + const patchedListener = (...args: any[]) => { + const target = this; + const span = plugin._createSpan(target, 'click'); + if (span) { + return plugin._tracer.withSpan(span, () => { + const result = listener.apply(target, args); + // no zone so end span immediately + span.end(); + return result; + }); + } else { + return listener.apply(target, args); + } + }; + return original.call(this, type, patchedListener, useCapture); + }; + }; + } + + /** + * Patches the history api + */ + _patchHistoryApi() { + this._unpatchHistoryApi(); + + shimmer.wrap(history, 'replaceState', this._patchHistoryMethod()); + shimmer.wrap(history, 'pushState', this._patchHistoryMethod()); + shimmer.wrap(history, 'back', this._patchHistoryMethod()); + shimmer.wrap(history, 'forward', this._patchHistoryMethod()); + shimmer.wrap(history, 'go', this._patchHistoryMethod()); + } + + /** + * Patches the certain history api method + */ + _patchHistoryMethod() { + const plugin = this; + return (original: any) => { + return function patchHistoryMethod(this: History, ...args: unknown[]) { + const url = `${location.pathname}${location.hash}${location.search}`; + const result = original.apply(this, args); + const urlAfter = `${location.pathname}${location.hash}${location.search}`; + if (url !== urlAfter) { + plugin._updateInteractionName(urlAfter); + } + return result; + }; + }; + } + + /** + * unpatch the history api methods + */ + _unpatchHistoryApi() { + if (isWrapped(history.replaceState)) + shimmer.unwrap(history, 'replaceState'); + if (isWrapped(history.pushState)) shimmer.unwrap(history, 'pushState'); + if (isWrapped(history.back)) shimmer.unwrap(history, 'back'); + if (isWrapped(history.forward)) shimmer.unwrap(history, 'forward'); + if (isWrapped(history.go)) shimmer.unwrap(history, 'go'); + } + + /** + * Updates interaction span name + * @param url + */ + _updateInteractionName(url: string) { + const span: types.Span | undefined = this._tracer.getCurrentSpan(); + if (span && typeof span.updateName === 'function') { + span.updateName(`${EVENT_NAVIGATION_NAME} ${url}`); + } + } + + /** + * Patches zone cancel task - this is done to be able to correctly + * decrement the number of remaining tasks + */ + private _patchZoneCancelTask() { + const plugin = this; + return (original: any) => { + return function patchCancelTask( + this: Zone, + task: AsyncTask + ) { + const currentZone = Zone.current; + const currentSpan = currentZone.get(ZONE_SCOPE_KEY); + if (currentSpan && plugin._shouldCountTask(task, currentZone)) { + plugin._decrementTask(currentSpan); + } + return original.call(this, task) as T; + }; + }; + } + + /** + * Patches zone schedule task - this is done to be able to correctly + * increment the number of tasks running within current zone but also to + * save time in case of timeout running from xhr plugin when waiting for + * main request from PerformanceResourceTiming + */ + private _patchZoneScheduleTask() { + const plugin = this; + return (original: any) => { + return function patchScheduleTask( + this: Zone, + task: AsyncTask + ) { + const currentZone = Zone.current; + const currentSpan: types.Span = currentZone.get(ZONE_SCOPE_KEY); + if (currentSpan && plugin._shouldCountTask(task, currentZone)) { + plugin._incrementTask(currentSpan); + plugin._checkForTimeout(task, currentSpan); + } + return original.call(this, task) as T; + }; + }; + } + + /** + * Patches zone run task - this is done to be able to create a span when + * user interaction starts + * @private + */ + private _patchZoneRunTask() { + const plugin = this; + return (original: RunTaskFunction): RunTaskFunction => { + return function patchRunTask( + this: Zone, + task: AsyncTask, + applyThis?: any, + applyArgs?: any + ): Zone { + const target: HTMLElement | undefined = plugin._getClickedElement(task); + let span: types.Span | undefined; + if (target) { + span = plugin._createSpan(target, 'click'); + if (span) { + plugin._incrementTask(span); + try { + return plugin._tracer.withSpan(span, () => { + const currentZone = Zone.current; + task._zone = currentZone; + return original.call(currentZone, task, applyThis, applyArgs); + }); + } finally { + plugin._decrementTask(span); + } + } + } else { + span = this.get(ZONE_SCOPE_KEY); + } + + try { + return original.call(this, task, applyThis, applyArgs); + } finally { + if (span && plugin._shouldCountTask(task, Zone.current)) { + plugin._decrementTask(span); + } + } + }; + }; + } + + /** + * Decides if task should be counted. + * @param task + * @param currentZone + * @private + */ + private _shouldCountTask(task: AsyncTask, currentZone: Zone): boolean { + if (task._zone) { + currentZone = task._zone; + } + if (!currentZone || !task.data || task.data.isPeriodic) { + return false; + } + const currentSpan = currentZone.get(ZONE_SCOPE_KEY); + if (!currentSpan) { + return false; + } + if (!this._spansData.get(currentSpan)) { + return false; + } + return task.type === 'macroTask' || task.type === 'microTask'; + } + + /** + * Will try to end span when such span still exists. + * @param span + * @param endTime + * @private + */ + private _tryToEndSpan(span: types.Span, endTime?: types.HrTime) { + if (span) { + const spanData = this._spansData.get(span); + if (spanData) { + span.end(endTime); + this._spansData.delete(span); + } + } + } + + /** + * implements patch function + */ + protected patch() { + const ZoneWithPrototype = this.getZoneWithPrototype(); + this._logger.debug( + 'applying patch to', + this.moduleName, + this.version, + 'zone:', + !!ZoneWithPrototype + ); + if (ZoneWithPrototype) { + if (isWrapped(ZoneWithPrototype.prototype.runTask)) { + shimmer.unwrap(ZoneWithPrototype.prototype, 'runTask'); + this._logger.debug('removing previous patch from method runTask'); + } + if (isWrapped(ZoneWithPrototype.prototype.scheduleTask)) { + shimmer.unwrap(ZoneWithPrototype.prototype, 'scheduleTask'); + this._logger.debug('removing previous patch from method scheduleTask'); + } + if (isWrapped(ZoneWithPrototype.prototype.cancelTask)) { + shimmer.unwrap(ZoneWithPrototype.prototype, 'cancelTask'); + this._logger.debug('removing previous patch from method cancelTask'); + } + + this._zonePatched = true; + shimmer.wrap( + ZoneWithPrototype.prototype, + 'runTask', + this._patchZoneRunTask() + ); + shimmer.wrap( + ZoneWithPrototype.prototype, + 'scheduleTask', + this._patchZoneScheduleTask() + ); + shimmer.wrap( + ZoneWithPrototype.prototype, + 'cancelTask', + this._patchZoneCancelTask() + ); + } else { + this._zonePatched = false; + if (isWrapped(HTMLElement.prototype.addEventListener)) { + shimmer.unwrap(HTMLElement.prototype, 'addEventListener'); + this._logger.debug( + 'removing previous patch from method addEventListener' + ); + } + shimmer.wrap( + HTMLElement.prototype, + 'addEventListener', + this._patchElement() + ); + } + + this._patchHistoryApi(); + return this._moduleExports; + } + + /** + * implements unpatch function + */ + protected unpatch() { + const ZoneWithPrototype = this.getZoneWithPrototype(); + this._logger.debug( + 'removing patch from', + this.moduleName, + this.version, + 'zone:', + !!ZoneWithPrototype + ); + if (ZoneWithPrototype && this._zonePatched) { + shimmer.unwrap(ZoneWithPrototype.prototype, 'runTask'); + shimmer.unwrap(ZoneWithPrototype.prototype, 'scheduleTask'); + shimmer.unwrap(ZoneWithPrototype.prototype, 'cancelTask'); + } else { + shimmer.unwrap(HTMLElement.prototype, 'addEventListener'); + } + this._unpatchHistoryApi(); + } + + /** + * returns Zone + */ + getZoneWithPrototype(): ZoneTypeWithPrototype | undefined { + const _window: WindowWithZone = (window as unknown) as WindowWithZone; + return _window.Zone; + } +} diff --git a/packages/opentelemetry-plugin-user-interaction/src/version.ts b/packages/opentelemetry-plugin-user-interaction/src/version.ts new file mode 100644 index 0000000000..d2d10b02a6 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/src/version.ts @@ -0,0 +1,18 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// this is autogenerated file, see scripts/version-update.js +export const VERSION = '0.3.3'; diff --git a/packages/opentelemetry-plugin-user-interaction/test/helper.test.ts b/packages/opentelemetry-plugin-user-interaction/test/helper.test.ts new file mode 100644 index 0000000000..53736d5dcb --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/test/helper.test.ts @@ -0,0 +1,72 @@ +/*! + * Copyright 2020, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as tracing from '@opentelemetry/tracing'; +import * as assert from 'assert'; + +export class DummySpanExporter implements tracing.SpanExporter { + export(spans: tracing.ReadableSpan[]) {} + + shutdown() {} +} + +export function createButton(disabled?: boolean): HTMLElement { + const button = document.createElement('button'); + button.setAttribute('id', 'testBtn'); + if (disabled) { + button.setAttribute('disabled', 'disabled'); + } + return button; +} + +export function fakeInteraction( + callback: Function = function() {}, + elem?: HTMLElement +) { + const element: HTMLElement = elem || createButton(); + + element.addEventListener('click', () => { + callback(); + }); + + element.click(); +} + +export function assertClickSpan(span: tracing.ReadableSpan, id = 'testBtn') { + assert.equal(span.name, `event_click: //*[@id="${id}"]`); + + const attributes = span.attributes; + assert.equal(attributes.component, 'user-interaction'); + assert.equal(attributes.event_type, 'click'); + assert.equal(attributes.target_element, 'BUTTON'); + assert.equal(attributes.target_xpath, `//*[@id="${id}"]`); + assert.ok(attributes['http.url'] !== ''); + assert.ok(attributes['user_agent'] !== ''); +} + +export function getData(url: string, callbackAfterSend: Function) { + return new Promise(async (resolve, reject) => { + const req = new XMLHttpRequest(); + req.open('GET', url, true); + req.send(); + + req.onload = resolve; + req.onerror = reject; + req.ontimeout = reject; + + callbackAfterSend(); + }); +} diff --git a/packages/opentelemetry-plugin-user-interaction/test/index-webpack.ts b/packages/opentelemetry-plugin-user-interaction/test/index-webpack.ts new file mode 100644 index 0000000000..7731f09091 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/test/index-webpack.ts @@ -0,0 +1,23 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file is the webpack entry point for the browser Karma tests. It requires +// all modules ending in "test" from the current folder and all its subfolders. +const testsContext = require.context('.', true, /test$/); +testsContext.keys().forEach(testsContext); + +const srcContext = require.context('.', true, /src$/); +srcContext.keys().forEach(srcContext); diff --git a/packages/opentelemetry-plugin-user-interaction/test/userInteraction.nozone.test.ts b/packages/opentelemetry-plugin-user-interaction/test/userInteraction.nozone.test.ts new file mode 100644 index 0000000000..c3091d3cfc --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/test/userInteraction.nozone.test.ts @@ -0,0 +1,355 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// because of zone original timeout needs to be patched to be able to run +// code outside zone.js. This needs to be done before all +const originalSetTimeout = window.setTimeout; + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { isWrapped, LogLevel } from '@opentelemetry/core'; +import * as tracing from '@opentelemetry/tracing'; +import { WebTracerProvider } from '@opentelemetry/web'; +import { XMLHttpRequestPlugin } from '@opentelemetry/plugin-xml-http-request'; +import { UserInteractionPlugin } from '../src'; + +import { + assertClickSpan, + DummySpanExporter, + fakeInteraction, + getData, +} from './helper.test'; + +const FILE_URL = + 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json'; + +describe('UserInteractionPlugin', () => { + describe('when zone.js is NOT available', () => { + let userInteractionPlugin: UserInteractionPlugin; + let sandbox: sinon.SinonSandbox; + let webTracerProvider: WebTracerProvider; + let dummySpanExporter: DummySpanExporter; + let exportSpy: sinon.SinonSpy; + let requests: sinon.SinonFakeXMLHttpRequest[] = []; + beforeEach(() => { + sandbox = sinon.createSandbox(); + const fakeXhr = sandbox.useFakeXMLHttpRequest(); + fakeXhr.onCreate = function(xhr: sinon.SinonFakeXMLHttpRequest) { + requests.push(xhr); + setTimeout(() => { + requests[requests.length - 1].respond( + 200, + { 'Content-Type': 'application/json' }, + '{"foo":"bar"}' + ); + }); + }; + + sandbox.useFakeTimers(); + + userInteractionPlugin = new UserInteractionPlugin(); + + sinon + .stub(userInteractionPlugin, 'getZoneWithPrototype') + .callsFake(() => undefined); + + webTracerProvider = new WebTracerProvider({ + logLevel: LogLevel.ERROR, + plugins: [userInteractionPlugin, new XMLHttpRequestPlugin()], + }); + + dummySpanExporter = new DummySpanExporter(); + exportSpy = sandbox.stub(dummySpanExporter, 'export'); + webTracerProvider.addSpanProcessor( + new tracing.SimpleSpanProcessor(dummySpanExporter) + ); + + // this is needed as window is treated as scope and karma is adding + // context which is then detected as spanContext + (window as { context?: {} }).context = undefined; + }); + afterEach(() => { + requests = []; + sandbox.restore(); + exportSpy.restore(); + }); + + it('should handle task without async operation', () => { + fakeInteraction(); + assert.equal(exportSpy.args.length, 1, 'should export one span'); + const spanClick = exportSpy.args[0][0][0]; + assertClickSpan(spanClick); + }); + + it('should handle timeout', done => { + fakeInteraction(() => { + originalSetTimeout(() => { + const spanClick: tracing.ReadableSpan = exportSpy.args[0][0][0]; + + assert.equal(exportSpy.args.length, 1, 'should export one span'); + assertClickSpan(spanClick); + done(); + }); + }); + sandbox.clock.tick(10); + }); + + it('should handle target without function getAttribute', done => { + let callback: Function; + const btn: any = { + addEventListener: function(name: string, callbackF: Function) { + callback = callbackF; + }, + click: function() { + callback(); + }, + }; + fakeInteraction(() => { + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 0, 'should NOT export any span'); + done(); + }); + }, btn); + sandbox.clock.tick(10); + }); + + it('should not create span when element has attribute disabled', done => { + let callback: Function; + const btn: any = { + addEventListener: function(name: string, callbackF: Function) { + callback = callbackF; + }, + click: function() { + callback(); + }, + getAttribute: function() {}, + hasAttribute: function(name: string) { + return name === 'disabled' ? true : false; + }, + }; + fakeInteraction(() => { + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 0, 'should NOT export any span'); + done(); + }); + }, btn); + sandbox.clock.tick(10); + }); + + it('should not create span when start span fails', done => { + userInteractionPlugin['_tracer'].startSpan = function() { + throw 'foo'; + }; + + fakeInteraction(() => { + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 0, 'should NOT export any span'); + done(); + }); + }); + sandbox.clock.tick(10); + }); + + it('should handle task with navigation change', done => { + fakeInteraction(() => { + history.pushState( + { test: 'testing' }, + '', + `${location.pathname}#foo=bar1` + ); + getData(FILE_URL, () => { + sandbox.clock.tick(1000); + }).then(() => { + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 2, 'should export 2 spans'); + + const spanXhr: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const spanClick: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.equal( + spanXhr.parentSpanId, + spanClick.spanContext.spanId, + 'xhr span has wrong parent' + ); + assert.equal( + spanClick.name, + `Navigation: ${location.pathname}#foo=bar1` + ); + + const attributes = spanClick.attributes; + assert.equal(attributes.component, 'user-interaction'); + assert.equal(attributes.event_type, 'click'); + assert.equal(attributes.target_element, 'BUTTON'); + assert.equal(attributes.target_xpath, `//*[@id="testBtn"]`); + + done(); + }); + }); + }); + }); + + it('should handle task with timeout and async operation', done => { + fakeInteraction(() => { + getData(FILE_URL, () => { + sandbox.clock.tick(1000); + }).then(() => { + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 2, 'should export 2 spans'); + + const spanXhr: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const spanClick: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.equal( + spanXhr.parentSpanId, + spanClick.spanContext.spanId, + 'xhr span has wrong parent' + ); + assertClickSpan(spanClick); + + const attributes = spanXhr.attributes; + assert.equal(attributes.component, 'xml-http-request'); + assert.equal( + attributes['http.url'], + 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json' + ); + // all other attributes are checked in xhr anyway + + done(); + }); + }); + }); + }); + + it('should handle 3 overlapping interactions', done => { + const btn1 = document.createElement('button'); + btn1.setAttribute('id', 'btn1'); + const btn2 = document.createElement('button'); + btn2.setAttribute('id', 'btn2'); + const btn3 = document.createElement('button'); + btn3.setAttribute('id', 'btn3'); + fakeInteraction(() => { + getData(FILE_URL, () => { + sandbox.clock.tick(10); + }).then(() => {}); + }, btn1); + fakeInteraction(() => { + getData(FILE_URL, () => { + sandbox.clock.tick(10); + }).then(() => {}); + }, btn2); + fakeInteraction(() => { + getData(FILE_URL, () => { + sandbox.clock.tick(10); + }).then(() => {}); + }, btn3); + sandbox.clock.tick(1000); + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 6, 'should export 6 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]; + const span5: tracing.ReadableSpan = exportSpy.args[4][0][0]; + const span6: tracing.ReadableSpan = exportSpy.args[5][0][0]; + + assertClickSpan(span1, 'btn1'); + assertClickSpan(span2, 'btn2'); + assertClickSpan(span3, 'btn3'); + + assert.strictEqual( + span1.spanContext.spanId, + span4.parentSpanId, + 'span4 has wrong parent' + ); + assert.strictEqual( + span2.spanContext.spanId, + span5.parentSpanId, + 'span5 has wrong parent' + ); + assert.strictEqual( + span3.spanContext.spanId, + span6.parentSpanId, + 'span6 has wrong parent' + ); + + done(); + }); + }); + + it('should handle unpatch', () => { + assert.strictEqual( + isWrapped(HTMLElement.prototype.addEventListener), + true, + 'addEventListener should be wrapped' + ); + + assert.strictEqual( + isWrapped(history.replaceState), + true, + 'replaceState should be wrapped' + ); + assert.strictEqual( + isWrapped(history.pushState), + true, + 'pushState should be wrapped' + ); + assert.strictEqual( + isWrapped(history.back), + true, + 'back should be wrapped' + ); + assert.strictEqual( + isWrapped(history.forward), + true, + 'forward should be wrapped' + ); + assert.strictEqual(isWrapped(history.go), true, 'go should be wrapped'); + + userInteractionPlugin.disable(); + + assert.strictEqual( + isWrapped(HTMLElement.prototype.addEventListener), + false, + 'addEventListener should be unwrapped' + ); + + assert.strictEqual( + isWrapped(history.replaceState), + false, + 'replaceState should be unwrapped' + ); + assert.strictEqual( + isWrapped(history.pushState), + false, + 'pushState should be unwrapped' + ); + assert.strictEqual( + isWrapped(history.back), + false, + 'back should be unwrapped' + ); + assert.strictEqual( + isWrapped(history.forward), + false, + 'forward should be unwrapped' + ); + assert.strictEqual( + isWrapped(history.go), + false, + 'go should be unwrapped' + ); + }); + }); +}); diff --git a/packages/opentelemetry-plugin-user-interaction/test/userInteraction.test.ts b/packages/opentelemetry-plugin-user-interaction/test/userInteraction.test.ts new file mode 100644 index 0000000000..85507feff0 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/test/userInteraction.test.ts @@ -0,0 +1,357 @@ +/*! + * Copyright 2019, OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// because of zone original timeout needs to be patched to be able to run +// code outside zone.js. This needs to be done before all +const originalSetTimeout = window.setTimeout; + +import 'zone.js'; + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { isWrapped, LogLevel } from '@opentelemetry/core'; +import * as tracing from '@opentelemetry/tracing'; +import { WebTracerProvider } from '@opentelemetry/web'; +import { ZoneScopeManager } from '@opentelemetry/scope-zone-peer-dep'; +import { XMLHttpRequestPlugin } from '@opentelemetry/plugin-xml-http-request'; +import { UserInteractionPlugin } from '../src'; +import { WindowWithZone } from '../src/types'; +import { + assertClickSpan, + createButton, + DummySpanExporter, + fakeInteraction, + getData, +} from './helper.test'; + +const FILE_URL = + 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json'; + +describe('UserInteractionPlugin', () => { + describe('when zone.js is available', () => { + let userInteractionPlugin: UserInteractionPlugin; + let sandbox: sinon.SinonSandbox; + let webTracerProvider: WebTracerProvider; + let dummySpanExporter: DummySpanExporter; + let exportSpy: sinon.SinonSpy; + let requests: sinon.SinonFakeXMLHttpRequest[] = []; + beforeEach(() => { + sandbox = sinon.createSandbox(); + history.pushState({ test: 'testing' }, '', `${location.pathname}`); + const fakeXhr = sandbox.useFakeXMLHttpRequest(); + fakeXhr.onCreate = function(xhr: sinon.SinonFakeXMLHttpRequest) { + requests.push(xhr); + setTimeout(() => { + requests[requests.length - 1].respond( + 200, + { 'Content-Type': 'application/json' }, + '{"foo":"bar"}' + ); + }); + }; + + sandbox.useFakeTimers(); + + userInteractionPlugin = new UserInteractionPlugin(); + webTracerProvider = new WebTracerProvider({ + logLevel: LogLevel.ERROR, + scopeManager: new ZoneScopeManager(), + plugins: [userInteractionPlugin, new XMLHttpRequestPlugin()], + }); + dummySpanExporter = new DummySpanExporter(); + exportSpy = sandbox.stub(dummySpanExporter, 'export'); + webTracerProvider.addSpanProcessor( + new tracing.SimpleSpanProcessor(dummySpanExporter) + ); + + // this is needed as window is treated as scope and karma is adding + // context which is then detected as spanContext + (window as { context?: {} }).context = undefined; + }); + afterEach(() => { + requests = []; + sandbox.restore(); + exportSpy.restore(); + }); + + it('should handle task without async operation', () => { + fakeInteraction(); + assert.equal(exportSpy.args.length, 1, 'should export one span'); + const spanClick = exportSpy.args[0][0][0]; + assertClickSpan(spanClick); + }); + + it('should ignore timeout when nothing happens afterwards', done => { + fakeInteraction(() => { + originalSetTimeout(() => { + const spanClick: tracing.ReadableSpan = exportSpy.args[0][0][0]; + + assert.equal(exportSpy.args.length, 1, 'should export one span'); + assertClickSpan(spanClick); + done(); + }); + }); + sandbox.clock.tick(110); + }); + + it('should ignore periodic tasks', done => { + fakeInteraction(() => { + const interval = setInterval(() => { + // console.log('interval ....'); + }, 1); + originalSetTimeout(() => { + assert.equal( + exportSpy.args.length, + 1, + 'should not export more then one span' + ); + const spanClick = exportSpy.args[0][0][0]; + assertClickSpan(spanClick); + clearInterval(interval); + done(); + }, 30); + + sandbox.clock.tick(10); + }); + sandbox.clock.tick(10); + }); + + it('should handle task with navigation change', done => { + fakeInteraction(() => { + history.pushState( + { test: 'testing' }, + '', + `${location.pathname}#foo=bar1` + ); + getData(FILE_URL, () => { + sandbox.clock.tick(1000); + }).then(() => { + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 2, 'should export 2 spans'); + + const spanXhr: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const spanClick: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.equal( + spanXhr.parentSpanId, + spanClick.spanContext.spanId, + 'xhr span has wrong parent' + ); + assert.equal( + spanClick.name, + `Navigation: ${location.pathname}#foo=bar1` + ); + + const attributes = spanClick.attributes; + assert.equal(attributes.component, 'user-interaction'); + assert.equal(attributes.event_type, 'click'); + assert.equal(attributes.target_element, 'BUTTON'); + assert.equal(attributes.target_xpath, `//*[@id="testBtn"]`); + + done(); + }); + }); + }); + }); + + it('should handle task with timeout and async operation', done => { + fakeInteraction(() => { + getData(FILE_URL, () => { + sandbox.clock.tick(1000); + }).then(() => { + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 2, 'should export 2 spans'); + + const spanXhr: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const spanClick: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.equal( + spanXhr.parentSpanId, + spanClick.spanContext.spanId, + 'xhr span has wrong parent' + ); + assertClickSpan(spanClick); + + const attributes = spanXhr.attributes; + assert.equal(attributes.component, 'xml-http-request'); + assert.equal( + attributes['http.url'], + 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json' + ); + // all other attributes are checked in xhr anyway + + done(); + }); + }); + }); + }); + + it('should ignore interaction when element is disabled', done => { + const btn = createButton(true); + let called = false; + const callback = function() { + called = true; + }; + fakeInteraction(callback, btn); + sandbox.clock.tick(1000); + originalSetTimeout(() => { + assert.equal(called, false, 'callback should not be called'); + done(); + }); + }); + + it('should handle 3 overlapping interactions', done => { + const btn1 = document.createElement('button'); + btn1.setAttribute('id', 'btn1'); + const btn2 = document.createElement('button'); + btn2.setAttribute('id', 'btn2'); + const btn3 = document.createElement('button'); + btn3.setAttribute('id', 'btn3'); + fakeInteraction(() => { + getData(FILE_URL, () => { + sandbox.clock.tick(10); + }).then(() => {}); + }, btn1); + fakeInteraction(() => { + getData(FILE_URL, () => { + sandbox.clock.tick(10); + }).then(() => {}); + }, btn2); + fakeInteraction(() => { + getData(FILE_URL, () => { + sandbox.clock.tick(10); + }).then(() => {}); + }, btn3); + sandbox.clock.tick(1000); + originalSetTimeout(() => { + assert.equal(exportSpy.args.length, 6, 'should export 6 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]; + const span5: tracing.ReadableSpan = exportSpy.args[4][0][0]; + const span6: tracing.ReadableSpan = exportSpy.args[5][0][0]; + + assertClickSpan(span1, 'btn1'); + assertClickSpan(span2, 'btn2'); + assertClickSpan(span3, 'btn3'); + + assert.strictEqual( + span1.spanContext.spanId, + span4.parentSpanId, + 'span4 has wrong parent' + ); + assert.strictEqual( + span2.spanContext.spanId, + span5.parentSpanId, + 'span5 has wrong parent' + ); + assert.strictEqual( + span3.spanContext.spanId, + span6.parentSpanId, + 'span6 has wrong parent' + ); + + done(); + }); + }); + + it('should handle unpatch', () => { + const _window: WindowWithZone = (window as unknown) as WindowWithZone; + const ZoneWithPrototype = _window.Zone; + assert.strictEqual( + isWrapped(ZoneWithPrototype.prototype.runTask), + true, + 'runTask should be wrapped' + ); + assert.strictEqual( + isWrapped(ZoneWithPrototype.prototype.scheduleTask), + true, + 'scheduleTask should be wrapped' + ); + assert.strictEqual( + isWrapped(ZoneWithPrototype.prototype.cancelTask), + true, + 'cancelTask should be wrapped' + ); + + assert.strictEqual( + isWrapped(history.replaceState), + true, + 'replaceState should be wrapped' + ); + assert.strictEqual( + isWrapped(history.pushState), + true, + 'pushState should be wrapped' + ); + assert.strictEqual( + isWrapped(history.back), + true, + 'back should be wrapped' + ); + assert.strictEqual( + isWrapped(history.forward), + true, + 'forward should be wrapped' + ); + assert.strictEqual(isWrapped(history.go), true, 'go should be wrapped'); + + userInteractionPlugin.disable(); + + assert.strictEqual( + isWrapped(ZoneWithPrototype.prototype.runTask), + false, + 'runTask should be unwrapped' + ); + assert.strictEqual( + isWrapped(ZoneWithPrototype.prototype.scheduleTask), + false, + 'scheduleTask should be unwrapped' + ); + assert.strictEqual( + isWrapped(ZoneWithPrototype.prototype.cancelTask), + false, + 'cancelTask should be unwrapped' + ); + + assert.strictEqual( + isWrapped(history.replaceState), + false, + 'replaceState should be unwrapped' + ); + assert.strictEqual( + isWrapped(history.pushState), + false, + 'pushState should be unwrapped' + ); + assert.strictEqual( + isWrapped(history.back), + false, + 'back should be unwrapped' + ); + assert.strictEqual( + isWrapped(history.forward), + false, + 'forward should be unwrapped' + ); + assert.strictEqual( + isWrapped(history.go), + false, + 'go should be unwrapped' + ); + }); + }); +}); diff --git a/packages/opentelemetry-plugin-user-interaction/tsconfig.json b/packages/opentelemetry-plugin-user-interaction/tsconfig.json new file mode 100644 index 0000000000..ab49dd3fbd --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base", + "compilerOptions": { + "rootDir": ".", + "outDir": "build" + }, + "files": [ "node_modules/zone.js/dist/zone.js.d.ts"], + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/packages/opentelemetry-plugin-user-interaction/tslint.json b/packages/opentelemetry-plugin-user-interaction/tslint.json new file mode 100644 index 0000000000..0710b135d0 --- /dev/null +++ b/packages/opentelemetry-plugin-user-interaction/tslint.json @@ -0,0 +1,4 @@ +{ + "rulesDirectory": ["node_modules/tslint-microsoft-contrib"], + "extends": ["../../tslint.base.js", "./node_modules/tslint-consistent-codestyle"] +} diff --git a/packages/opentelemetry-web/karma.conf.js b/packages/opentelemetry-web/karma.conf.js index 7183aab033..88c2849684 100644 --- a/packages/opentelemetry-web/karma.conf.js +++ b/packages/opentelemetry-web/karma.conf.js @@ -19,6 +19,7 @@ const karmaBaseConfig = require('../../karma.base'); module.exports = (config) => { config.set(Object.assign({}, karmaBaseConfig, { + frameworks: karmaBaseConfig.frameworks.concat(['jquery-1.8.3']), webpack: karmaWebpackConfig })) }; diff --git a/packages/opentelemetry-web/package.json b/packages/opentelemetry-web/package.json index e0649266f1..e38ecd9ca9 100644 --- a/packages/opentelemetry-web/package.json +++ b/packages/opentelemetry-web/package.json @@ -44,6 +44,7 @@ "devDependencies": { "@babel/core": "^7.6.0", "@opentelemetry/scope-zone": "^0.3.3", + "@types/jquery": "^3.3.31", "@types/mocha": "^5.2.5", "@types/node": "^12.6.8", "@types/sinon": "^7.0.13", @@ -55,6 +56,7 @@ "karma": "^4.4.1", "karma-chrome-launcher": "^3.1.0", "karma-coverage-istanbul-reporter": "^2.1.0", + "karma-jquery": "^0.2.4", "karma-mocha": "^1.3.0", "karma-spec-reporter": "^0.0.32", "karma-webpack": "^4.0.2", diff --git a/packages/opentelemetry-web/src/utils.ts b/packages/opentelemetry-web/src/utils.ts index 6c9dc7e2a8..0beec8d9f0 100644 --- a/packages/opentelemetry-web/src/utils.ts +++ b/packages/opentelemetry-web/src/utils.ts @@ -230,3 +230,85 @@ export function parseUrl(url: string): HTMLAnchorElement { a.href = url; return a; } + +/** + * Get element XPath + * @param target - target element + * @param optimised - when id attribute of element is present the xpath can be + * simplified to contain id + */ +export function getElementXPath(target: any, optimised?: boolean) { + if (target.nodeType === Node.DOCUMENT_NODE) { + return '/'; + } + const targetValue = getNodeValue(target, optimised); + if (optimised && targetValue.indexOf('@id') > 0) { + return targetValue; + } + let xpath = ''; + if (target.parentNode) { + xpath += getElementXPath(target.parentNode, false); + } + xpath += targetValue; + + return xpath; +} + +/** + * get node index within the siblings + * @param target + */ +function getNodeIndex(target: HTMLElement): number { + if (!target.parentNode) { + return 0; + } + const allowedTypes = [target.nodeType]; + if (target.nodeType === Node.CDATA_SECTION_NODE) { + allowedTypes.push(Node.TEXT_NODE); + } + let elements = Array.from(target.parentNode.childNodes); + elements = elements.filter((element: Node) => { + const localName = (element as HTMLElement).localName; + return ( + allowedTypes.indexOf(element.nodeType) >= 0 && + localName === target.localName + ); + }); + if (elements.length >= 1) { + return elements.indexOf(target) + 1; // xpath starts from 1 + } + // if there are no other similar child xpath doesn't need index + return 0; +} + +/** + * get node value for xpath + * @param target + * @param optimised + */ +function getNodeValue(target: HTMLElement, optimised?: boolean): string { + const nodeType = target.nodeType; + const index = getNodeIndex(target); + let nodeValue = ''; + if (nodeType === Node.ELEMENT_NODE) { + const id = target.getAttribute('id'); + if (optimised && id) { + return `//*[@id="${id}"]`; + } + nodeValue = target.localName; + } else if ( + nodeType === Node.TEXT_NODE || + nodeType === Node.CDATA_SECTION_NODE + ) { + nodeValue = 'text()'; + } else if (nodeType === Node.COMMENT_NODE) { + nodeValue = 'comment()'; + } else { + return ''; + } + // if index is 1 it can be omitted in xpath + if (nodeValue && index > 1) { + return `/${nodeValue}[${index}]`; + } + return `/${nodeValue}`; +} diff --git a/packages/opentelemetry-web/test/utils.test.ts b/packages/opentelemetry-web/test/utils.test.ts index 9bda58b9ef..96745f5514 100644 --- a/packages/opentelemetry-web/test/utils.test.ts +++ b/packages/opentelemetry-web/test/utils.test.ts @@ -24,7 +24,12 @@ import { HrTime } from '@opentelemetry/api'; import * as assert from 'assert'; import * as sinon from 'sinon'; -import { addSpanNetworkEvent, getResource, PerformanceEntries } from '../src'; +import { + addSpanNetworkEvent, + getElementXPath, + getResource, + PerformanceEntries, +} from '../src'; import { PerformanceTimingNames as PTN } from '../src/enums/PerformanceTimingNames'; const SECOND_TO_NANOSECONDS = 1e9; @@ -38,6 +43,45 @@ function createHrTime(startTime: HrTime, addToStart: number): HrTime { } return [seconds, nanos]; } +const fixture = ` +
+
+
+
+
+
+
+
+
lorep ipsum
+
+
+ foo + + + + + bar +
+
+ aaaaaaaaa + + + bbb +
+
+
+
+
+ + + + bar +
+
+
+
+
+`; function createResource( resource = {}, @@ -383,4 +427,95 @@ describe('utils', () => { }); }); }); + describe('getElementXPath', () => { + let $fixture: any; + let child: any; + before(() => { + $fixture = $(fixture); + const body = document.querySelector('body'); + if (body) { + body.appendChild($fixture[0]); + child = body.lastChild; + } + }); + after(() => { + child.parentNode.removeChild(child); + }); + + it('should return correct path for element with id and optimise = true', () => { + const element = getElementXPath($fixture.find('#btn22')[0], true); + assert.strictEqual(element, '//*[@id="btn22"]'); + assert.strictEqual( + $fixture.find('#btn22')[0], + getElementByXpath(element) + ); + }); + + it( + 'should return correct path for element with id and surrounded by the' + + ' same type', + () => { + const element = getElementXPath($fixture.find('#btn22')[0]); + assert.strictEqual(element, '//html/body/div/div[4]/div[5]/button[3]'); + assert.strictEqual( + $fixture.find('#btn22')[0], + getElementByXpath(element) + ); + } + ); + + it( + 'should return correct path for element with id and and surrounded by' + + ' text nodes mixed with cnode', + () => { + const element = getElementXPath($fixture.find('#btn23')[0]); + assert.strictEqual(element, '//html/body/div/div[4]/div[6]/button'); + assert.strictEqual( + $fixture.find('#btn23')[0], + getElementByXpath(element) + ); + } + ); + + it( + 'should return correct path for text node element surrounded by cdata' + + ' nodes', + () => { + const text = $fixture.find('#cdata')[0]; + const textNode = document.createTextNode('foobar'); + text.appendChild(textNode); + const element = getElementXPath(textNode); + assert.strictEqual(element, '//html/body/div/div[4]/div[10]/text()[5]'); + assert.strictEqual(textNode, getElementByXpath(element)); + } + ); + + it('should return correct path when element is text node', () => { + const text = $fixture.find('#text')[0]; + const textNode = document.createTextNode('foobar'); + text.appendChild(textNode); + const element = getElementXPath(textNode); + assert.strictEqual(element, '//html/body/div/div[4]/div[3]/text()[2]'); + assert.strictEqual(textNode, getElementByXpath(element)); + }); + + it('should return correct path when element is comment node', () => { + const comment = $fixture.find('#comment')[0]; + const node = document.createComment('foobar'); + comment.appendChild(node); + const element = getElementXPath(node); + assert.strictEqual(element, '//html/body/div/div[4]/div[8]/comment()'); + assert.strictEqual(node, getElementByXpath(element)); + }); + }); }); + +function getElementByXpath(path: string) { + return document.evaluate( + path, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue; +}