+
+ 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;
+}