diff --git a/README.md b/README.md index 272461f988..6347bef679 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ These plugins are hosted at and make sure you have the browser console open. The application is using the `ConsoleSpanExporter` and will post the created spans to the browser console. + ## Useful links - For more information on OpenTelemetry, visit: diff --git a/examples/tracer-web/examples/fetch/index.html b/examples/tracer-web/examples/fetch/index.html new file mode 100644 index 0000000000..c8311c8e05 --- /dev/null +++ b/examples/tracer-web/examples/fetch/index.html @@ -0,0 +1,20 @@ + + + + + + Fetch Plugin Example + + + + + + + Example of using Web Tracer with Fetch plugin with console exporter and collector exporter + +
+ + + + + diff --git a/examples/tracer-web/examples/fetch/index.js b/examples/tracer-web/examples/fetch/index.js new file mode 100644 index 0000000000..5be984bbbe --- /dev/null +++ b/examples/tracer-web/examples/fetch/index.js @@ -0,0 +1,71 @@ +'use strict'; + +import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/tracing'; +import { CollectorExporter } from '@opentelemetry/exporter-collector'; +import { WebTracerProvider } from '@opentelemetry/web'; +import { FetchPlugin } from '@opentelemetry/plugin-fetch'; +import { ZoneContextManager } from '@opentelemetry/context-zone'; +import { B3Propagator } from '@opentelemetry/core'; + +const provider = new WebTracerProvider({ + plugins: [ + new FetchPlugin({ + ignoreUrls: [/localhost:8090\/sockjs-node/], + propagateTraceHeaderCorsUrls: [ + 'https://cors-test.appspot.com/test', + 'https://httpbin.org/get', + ], + clearTimingResources: true + }), + ], +}); + +provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); +provider.addSpanProcessor(new SimpleSpanProcessor(new CollectorExporter())); +provider.register({ + contextManager: new ZoneContextManager(), + propagator: new B3Propagator(), +}); + +const webTracerWithZone = provider.getTracer('example-tracer-web'); + +const getData = (url) => fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, +}); + +// example of keeping track of context between async operations +const prepareClickEvent = () => { + const url1 = 'https://httpbin.org/get'; + + const element = document.getElementById('button1'); + + const onClick = () => { + const span1 = webTracerWithZone.startSpan(`files-series-info`, { + parent: webTracerWithZone.getCurrentSpan(), + }); + webTracerWithZone.withSpan(span1, () => { + getData(url1).then((_data) => { + webTracerWithZone.getCurrentSpan().addEvent('fetching-span1-completed'); + span1.end(); + }); + }); + for (let i = 0, j = 5; i < j; i += 1) { + const span1 = webTracerWithZone.startSpan(`files-series-info-${i}`, { + parent: webTracerWithZone.getCurrentSpan(), + }); + webTracerWithZone.withSpan(span1, () => { + getData(url1).then((_data) => { + webTracerWithZone.getCurrentSpan().addEvent('fetching-span1-completed'); + span1.end(); + }); + }); + } + }; + element.addEventListener('click', onClick); +}; + +window.addEventListener('load', prepareClickEvent); diff --git a/examples/tracer-web/package.json b/examples/tracer-web/package.json index ecf4b1a222..bb8a3e4fe5 100644 --- a/examples/tracer-web/package.json +++ b/examples/tracer-web/package.json @@ -38,6 +38,7 @@ "@opentelemetry/core": "^0.8.3", "@opentelemetry/exporter-collector": "^0.8.3", "@opentelemetry/plugin-document-load": "^0.6.1", + "@opentelemetry/plugin-fetch": "^0.8.3", "@opentelemetry/plugin-user-interaction": "^0.6.1", "@opentelemetry/plugin-xml-http-request": "^0.8.3", "@opentelemetry/tracing": "^0.8.3", diff --git a/examples/tracer-web/webpack.config.js b/examples/tracer-web/webpack.config.js index b23949d731..3e1c14ef2f 100644 --- a/examples/tracer-web/webpack.config.js +++ b/examples/tracer-web/webpack.config.js @@ -8,6 +8,7 @@ const common = { mode: 'development', entry: { 'document-load': 'examples/document-load/index.js', + fetch: 'examples/fetch/index.js', 'xml-http-request': 'examples/xml-http-request/index.js', 'user-interaction': 'examples/user-interaction/index.js', }, diff --git a/karma.base.js b/karma.base.js index 70435dda11..cbae813f7b 100644 --- a/karma.base.js +++ b/karma.base.js @@ -20,7 +20,7 @@ module.exports = { browsers: ['ChromeHeadless'], frameworks: ['mocha'], coverageIstanbulReporter: { - reports: ['json'], + reports: ['html', 'json'], dir: '.nyc_output', fixWebpackSourcePaths: true }, diff --git a/packages/opentelemetry-plugin-fetch/.eslintignore b/packages/opentelemetry-plugin-fetch/.eslintignore new file mode 100644 index 0000000000..378eac25d3 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/.eslintignore @@ -0,0 +1 @@ +build diff --git a/packages/opentelemetry-plugin-fetch/.eslintrc.js b/packages/opentelemetry-plugin-fetch/.eslintrc.js new file mode 100644 index 0000000000..9dfe62f9b8 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + "env": { + "mocha": true, + "commonjs": true, + "node": true, + "browser": true + }, + ...require('../../eslint.config.js') +} diff --git a/packages/opentelemetry-plugin-fetch/.npmignore b/packages/opentelemetry-plugin-fetch/.npmignore new file mode 100644 index 0000000000..9505ba9450 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/.npmignore @@ -0,0 +1,4 @@ +/bin +/coverage +/doc +/test diff --git a/packages/opentelemetry-plugin-fetch/LICENSE b/packages/opentelemetry-plugin-fetch/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/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-fetch/README.md b/packages/opentelemetry-plugin-fetch/README.md new file mode 100644 index 0000000000..cdbbce00e8 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/README.md @@ -0,0 +1,68 @@ +# OpenTelemetry Fetch Instrumentation 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 for web using fetch. + +## Installation + +```bash +npm install --save @opentelemetry/plugin-fetch +``` + +## Usage + +```js +'use strict'; +import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/tracing'; +import { WebTracerProvider } from '@opentelemetry/web'; +import { FetchPlugin } from '@opentelemetry/plugin-fetch'; +import { ZoneContextManager } from '@opentelemetry/context-zone'; + +const provider = new WebTracerProvider({ + plugins: [ + new FetchPlugin(), + ], +}); + +provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); + +provider.register({ + contextManager: new ZoneContextManager(), +}); + +// and some test + +fetch('http://localhost:8090/fetch.js'); + +``` + +## Example Screenshots +![Screenshot of the running example](images/trace1.png) +![Screenshot of the running example](images/trace2.png) +![Screenshot of the running example](images/trace3.png) + +See [examples/tracer-web/fetch](https://github.com/open-telemetry/opentelemetry-js/tree/master/examples/tracer-web) for a short example. + +## 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-fetch +[dependencies-url]: https://david-dm.org/open-telemetry/opentelemetry-js?path=packages%2Fopentelemetry-plugin-fetch +[devDependencies-image]: https://david-dm.org/open-telemetry/opentelemetry-js/dev-status.svg?path=packages/opentelemetry-plugin-fetch +[devDependencies-url]: https://david-dm.org/open-telemetry/opentelemetry-js?path=packages%2Fopentelemetry-plugin-fetch&type=dev +[npm-url]: https://www.npmjs.com/package/@opentelemetry/plugin-fetch +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Fplugin-fetch.svg diff --git a/packages/opentelemetry-plugin-fetch/images/trace1.png b/packages/opentelemetry-plugin-fetch/images/trace1.png new file mode 100644 index 0000000000..f26085537d Binary files /dev/null and b/packages/opentelemetry-plugin-fetch/images/trace1.png differ diff --git a/packages/opentelemetry-plugin-fetch/images/trace2.png b/packages/opentelemetry-plugin-fetch/images/trace2.png new file mode 100644 index 0000000000..0f9130ce8a Binary files /dev/null and b/packages/opentelemetry-plugin-fetch/images/trace2.png differ diff --git a/packages/opentelemetry-plugin-fetch/images/trace3.png b/packages/opentelemetry-plugin-fetch/images/trace3.png new file mode 100644 index 0000000000..e2a602f295 Binary files /dev/null and b/packages/opentelemetry-plugin-fetch/images/trace3.png differ diff --git a/packages/opentelemetry-plugin-fetch/karma.conf.js b/packages/opentelemetry-plugin-fetch/karma.conf.js new file mode 100644 index 0000000000..edcd9f055f --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/karma.conf.js @@ -0,0 +1,24 @@ +/*! + * 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 + * + * 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, { + webpack: karmaWebpackConfig + })) +}; diff --git a/packages/opentelemetry-plugin-fetch/package.json b/packages/opentelemetry-plugin-fetch/package.json new file mode 100644 index 0000000000..2f1fbae5da --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/package.json @@ -0,0 +1,82 @@ +{ + "name": "@opentelemetry/plugin-fetch", + "version": "0.8.3", + "description": "OpenTelemetry fetch automatic instrumentation package.", + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": "open-telemetry/opentelemetry-js", + "scripts": { + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "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 .", + "prepare": "npm run compile", + "tdd": "karma start", + "test:browser": "nyc karma start --single-run", + "watch": "tsc -w" + }, + "keywords": [ + "fetch", + "opentelemetry", + "browser", + "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/context-zone": "^0.8.2", + "@opentelemetry/tracing": "^0.8.2", + "@types/mocha": "^7.0.0", + "@types/node": "^14.0.5", + "@types/shimmer": "^1.0.1", + "@types/sinon": "^7.0.13", + "@types/webpack-env": "1.15.2", + "babel-loader": "^8.0.6", + "codecov": "^3.1.0", + "gts": "^2.0.0", + "istanbul-instrumenter-loader": "^3.0.1", + "karma": "^5.0.5", + "karma-chrome-launcher": "^3.1.0", + "karma-coverage-istanbul-reporter": "^3.0.2", + "karma-mocha": "^2.0.1", + "karma-spec-reporter": "^0.0.32", + "karma-webpack": "^4.0.2", + "mocha": "^7.1.2", + "nyc": "^15.0.0", + "rimraf": "^3.0.0", + "sinon": "^7.5.0", + "ts-loader": "^6.0.4", + "ts-mocha": "^7.0.0", + "ts-node": "^8.6.2", + "typescript": "3.6.4", + "webpack": "^4.35.2", + "webpack-cli": "^3.3.9", + "webpack-merge": "^4.2.2" + }, + "dependencies": { + "@opentelemetry/api": "^0.8.3", + "@opentelemetry/core": "^0.8.3", + "@opentelemetry/web": "^0.8.3", + "shimmer": "^1.2.1" + } +} diff --git a/packages/opentelemetry-plugin-fetch/src/enums/AttributeNames.ts b/packages/opentelemetry-plugin-fetch/src/enums/AttributeNames.ts new file mode 100644 index 0000000000..452a1111dd --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/src/enums/AttributeNames.ts @@ -0,0 +1,31 @@ +/* + * Copyright The 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. + */ + +/** + * https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/semantic_conventions/http.md + */ +export enum AttributeNames { + COMPONENT = 'component', + HTTP_HOST = 'http.host', + HTTP_FLAVOR = 'http.flavor', + HTTP_METHOD = 'http.method', + HTTP_SCHEME = 'http.scheme', + HTTP_STATUS_CODE = 'http.status_code', + HTTP_STATUS_TEXT = 'http.status_text', + HTTP_URL = 'http.url', + HTTP_TARGET = 'http.target', + HTTP_USER_AGENT = 'http.user_agent', +} diff --git a/packages/opentelemetry-plugin-fetch/src/fetch.ts b/packages/opentelemetry-plugin-fetch/src/fetch.ts new file mode 100644 index 0000000000..cccc7d43f9 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/src/fetch.ts @@ -0,0 +1,351 @@ +/* + * Copyright The 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 * as api from '@opentelemetry/api'; +import * as core from '@opentelemetry/core'; +import * as web from '@opentelemetry/web'; +import { AttributeNames } from './enums/AttributeNames'; +import { FetchError, FetchResponse, SpanData } from './types'; +import { VERSION } from './version'; + +// how long to wait for observer to collect information about resources +// this is needed as event "load" is called before observer +// hard to say how long it should really wait, seems like 300ms is +// safe enough +const OBSERVER_WAIT_TIME_MS = 300; + +/** + * FetchPlugin Config + */ +export interface FetchPluginConfig extends api.PluginConfig { + // the number of timing resources is limited, after the limit + // (chrome 250, safari 150) the information is not collected anymore + // the only way to prevent that is to regularly clean the resources + // whenever it is possible, this is needed only when PerformanceObserver + // is not available + clearTimingResources?: boolean; + // urls which should include trace headers when origin doesn't match + propagateTraceHeaderCorsUrls?: web.PropagateTraceHeaderCorsUrls; +} + +/** + * This class represents a fetch plugin for auto instrumentation + */ +export class FetchPlugin extends core.BasePlugin> { + moduleName = 'fetch'; + private _usedResources = new WeakSet(); + private _tasksCount = 0; + + constructor(protected _config: FetchPluginConfig = {}) { + super('@opentelemetry/plugin-fetch', VERSION); + } + + /** + * Add cors pre flight child span + * @param span + * @param corsPreFlightRequest + */ + private _addChildSpan( + span: api.Span, + corsPreFlightRequest: PerformanceResourceTiming + ): void { + const childSpan = this._tracer.startSpan('CORS Preflight', { + parent: span, + startTime: corsPreFlightRequest[web.PerformanceTimingNames.FETCH_START], + }); + web.addSpanNetworkEvents(childSpan, corsPreFlightRequest); + childSpan.end( + corsPreFlightRequest[web.PerformanceTimingNames.RESPONSE_END] + ); + } + + /** + * Adds more attributes to span just before ending it + * @param span + * @param response + */ + private _addFinalSpanAttributes( + span: api.Span, + response: FetchResponse + ): void { + const parsedUrl = web.parseUrl(response.url); + span.setAttribute(AttributeNames.HTTP_STATUS_CODE, response.status); + span.setAttribute(AttributeNames.HTTP_STATUS_TEXT, response.statusText); + span.setAttribute(AttributeNames.HTTP_HOST, parsedUrl.host); + span.setAttribute( + AttributeNames.HTTP_SCHEME, + parsedUrl.protocol.replace(':', '') + ); + span.setAttribute(AttributeNames.HTTP_USER_AGENT, navigator.userAgent); + } + + /** + * Add headers + * @param options + * @param spanUrl + */ + private _addHeaders(options: RequestInit, spanUrl: string): void { + if ( + !web.shouldPropagateTraceHeaders( + spanUrl, + this._config.propagateTraceHeaderCorsUrls + ) + ) { + return; + } + const headers: { [key: string]: unknown } = {}; + api.propagation.inject(headers); + options.headers = Object.assign({}, headers, options.headers || {}); + } + + /** + * Clears the resource timings and all resources assigned with spans + * when {@link FetchPluginConfig.clearTimingResources} is + * set to true (default false) + * @private + */ + private _clearResources() { + if (this._tasksCount === 0 && this._config.clearTimingResources) { + performance.clearResourceTimings(); + this._usedResources = new WeakSet(); + } + } + + /** + * Creates a new span + * @param url + * @param options + */ + private _createSpan( + url: string, + options: Partial = {} + ): api.Span | undefined { + if (core.isUrlIgnored(url, this._config.ignoreUrls)) { + this._logger.debug('ignoring span as url matches ignored url'); + return; + } + const method = (options.method || 'GET').toUpperCase(); + const spanName = `HTTP ${method}`; + return this._tracer.startSpan(spanName, { + kind: api.SpanKind.CLIENT, + attributes: { + [AttributeNames.COMPONENT]: this.moduleName, + [AttributeNames.HTTP_METHOD]: method, + [AttributeNames.HTTP_URL]: url, + }, + }); + } + + /** + * Finds appropriate resource and add network events to the span + * @param span + * @param resourcesObserver + * @param endTime + */ + private _findResourceAndAddNetworkEvents( + span: api.Span, + resourcesObserver: SpanData, + endTime: api.HrTime + ): void { + let resources: PerformanceResourceTiming[] = resourcesObserver.entries; + if (!resources.length) { + // fallback - either Observer is not available or it took longer + // then OBSERVER_WAIT_TIME_MS and observer didn't collect enough + // information + resources = performance.getEntriesByType( + 'resource' + ) as PerformanceResourceTiming[]; + } + const resource = web.getResource( + resourcesObserver.spanUrl, + resourcesObserver.startTime, + endTime, + resources, + this._usedResources, + 'fetch' + ); + + if (resource.mainRequest) { + const mainRequest = resource.mainRequest; + this._markResourceAsUsed(mainRequest); + + const corsPreFlightRequest = resource.corsPreFlightRequest; + if (corsPreFlightRequest) { + this._addChildSpan(span, corsPreFlightRequest); + this._markResourceAsUsed(corsPreFlightRequest); + } + web.addSpanNetworkEvents(span, mainRequest); + } + } + + /** + * Marks certain [resource]{@link PerformanceResourceTiming} when information + * from this is used to add events to span. + * This is done to avoid reusing the same resource again for next span + * @param resource + */ + private _markResourceAsUsed(resource: PerformanceResourceTiming): void { + this._usedResources.add(resource); + } + + /** + * Finish span, add attributes, network events etc. + * @param span + * @param spanData + * @param response + */ + private _endSpan( + span: api.Span, + spanData: SpanData, + response: FetchResponse + ) { + const endTime = core.hrTime(); + spanData.observer.disconnect(); + this._addFinalSpanAttributes(span, response); + + setTimeout(() => { + this._findResourceAndAddNetworkEvents(span, spanData, endTime); + this._tasksCount--; + this._clearResources(); + span.end(endTime); + }, OBSERVER_WAIT_TIME_MS); + } + + /** + * Patches the constructor of fetch + */ + private _patchConstructor(): ( + original: (input: RequestInfo, init?: RequestInit) => Promise + ) => (input: RequestInfo, init?: RequestInit) => Promise { + return ( + original: (input: RequestInfo, init?: RequestInit) => Promise + ): ((input: RequestInfo, init?: RequestInit) => Promise) => { + const plugin = this; + + return function patchConstructor( + this: (input: RequestInfo, init?: RequestInit) => Promise, + input: RequestInfo, + init?: RequestInit + ): Promise { + const url = input instanceof Request ? input.url : input; + const options: RequestInit = + input instanceof Request ? input : init || {}; + + const span = plugin._createSpan(url, options); + if (!span) { + return original.apply(this, [url, options]); + } + const spanData = plugin._prepareSpanData(url); + + function onSuccess( + span: api.Span, + resolve: ( + value?: Response | PromiseLike | undefined + ) => void, + response: Response + ) { + try { + if (response.status >= 200 && response.status < 400) { + plugin._endSpan(span, spanData, response); + } else { + plugin._endSpan(span, spanData, { + status: response.status, + statusText: response.statusText, + url, + }); + } + } finally { + resolve(response); + } + } + + function onError( + span: api.Span, + reject: (reason?: unknown) => void, + error: FetchError + ) { + try { + plugin._endSpan(span, spanData, { + status: error.status || 0, + statusText: error.message, + url, + }); + } finally { + reject(error); + } + } + + return new Promise((resolve, reject) => { + return plugin._tracer.withSpan(span, () => { + plugin._addHeaders(options, url); + plugin._tasksCount++; + return original + .apply(this, [url, options]) + .then( + onSuccess.bind(this, span, resolve), + onError.bind(this, span, reject) + ); + }); + }); + }; + }; + } + + /** + * Prepares a span data - needed later for matching appropriate network + * resources + * @param spanUrl + */ + private _prepareSpanData(spanUrl: string): SpanData { + const startTime = core.hrTime(); + const entries: PerformanceResourceTiming[] = []; + const observer: PerformanceObserver = new PerformanceObserver(list => { + const entries = list.getEntries() as PerformanceResourceTiming[]; + entries.forEach(entry => { + if (entry.initiatorType === 'fetch' && entry.name === spanUrl) { + entries.push(entry); + } + }); + }); + observer.observe({ + entryTypes: ['resource'], + }); + return { entries, observer, startTime, spanUrl }; + } + + /** + * implements patch function + */ + patch() { + if (core.isWrapped(window.fetch)) { + shimmer.unwrap(window, 'fetch'); + this._logger.debug('removing previous patch for constructor'); + } + + shimmer.wrap(window, 'fetch', this._patchConstructor()); + + return this._moduleExports; + } + + /** + * implements unpatch function + */ + unpatch() { + shimmer.unwrap(window, 'fetch'); + this._usedResources = new WeakSet(); + } +} diff --git a/packages/opentelemetry-plugin-fetch/src/index.ts b/packages/opentelemetry-plugin-fetch/src/index.ts new file mode 100644 index 0000000000..1d39792560 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright The 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 './fetch'; diff --git a/packages/opentelemetry-plugin-fetch/src/types.ts b/packages/opentelemetry-plugin-fetch/src/types.ts new file mode 100644 index 0000000000..4144aceaa1 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/src/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright The 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 api from '@opentelemetry/api'; + +/** + * Interface used to provide information to finish span on fetch response + */ +export interface FetchResponse { + status: number; + statusText?: string; + url: string; +} + +/** + * Interface used to provide information to finish span on fetch error + */ +export interface FetchError { + status?: number; + message: string; +} + +/** + * Interface used to keep information about span between creating and + * ending span + */ +export interface SpanData { + entries: PerformanceResourceTiming[]; + observer: PerformanceObserver; + spanUrl: string; + startTime: api.HrTime; +} diff --git a/packages/opentelemetry-plugin-fetch/src/version.ts b/packages/opentelemetry-plugin-fetch/src/version.ts new file mode 100644 index 0000000000..9e616149a4 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/src/version.ts @@ -0,0 +1,18 @@ +/* + * Copyright The 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.8.3'; diff --git a/packages/opentelemetry-plugin-fetch/test/fetch.test.ts b/packages/opentelemetry-plugin-fetch/test/fetch.test.ts new file mode 100644 index 0000000000..cf27f84f40 --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/test/fetch.test.ts @@ -0,0 +1,563 @@ +/* + * Copyright The 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 api from '@opentelemetry/api'; +import * as core from '@opentelemetry/core'; +import { ZoneContextManager } from '@opentelemetry/context-zone'; +import * as tracing from '@opentelemetry/tracing'; +import { + PerformanceTimingNames as PTN, + WebTracerProvider, +} from '@opentelemetry/web'; +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { FetchPlugin, FetchPluginConfig } from '../src'; +import { AttributeNames } from '../src/enums/AttributeNames'; + +class DummySpanExporter implements tracing.SpanExporter { + export(spans: any) {} + + shutdown() {} +} + +const getData = (url: string, method?: string) => + fetch(url, { + method: method || 'GET', + headers: { + foo: 'bar', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + +const defaultResource = { + connectEnd: 15, + connectStart: 13, + decodedBodySize: 0, + domainLookupEnd: 12, + domainLookupStart: 11, + encodedBodySize: 0, + fetchStart: 10.1, + initiatorType: 'fetch', + nextHopProtocol: '', + redirectEnd: 0, + redirectStart: 0, + requestStart: 16, + responseEnd: 20.5, + responseStart: 17, + secureConnectionStart: 14, + transferSize: 0, + workerStart: 0, + duration: 0, + entryType: '', + name: '', + startTime: 0, +}; + +function createResource(resource = {}): PerformanceResourceTiming { + return Object.assign( + {}, + defaultResource, + resource + ) as PerformanceResourceTiming; +} + +function createMasterResource(resource = {}): PerformanceResourceTiming { + const masterResource: any = createResource(resource); + Object.keys(masterResource).forEach((key: string) => { + if (typeof masterResource[key] === 'number') { + masterResource[key] = masterResource[key] + 30; + } + }); + return masterResource; +} + +describe('fetch', () => { + let sandbox: sinon.SinonSandbox; + let contextManager: ZoneContextManager; + let lastResponse: any | undefined; + let webTracerWithZone: api.Tracer; + let webTracerProviderWithZone: WebTracerProvider; + let dummySpanExporter: DummySpanExporter; + let exportSpy: any; + let clearResourceTimingsSpy: any; + let rootSpan: api.Span; + let fakeNow = 0; + let fetchPlugin: FetchPlugin; + + const url = 'http://localhost:8090/get'; + const badUrl = 'http://foo.bar.com/get'; + + const clearData = () => { + sandbox.restore(); + lastResponse = undefined; + }; + + const prepareData = ( + done: any, + fileUrl: string, + config: FetchPluginConfig, + method?: string + ) => { + sandbox = sinon.createSandbox(); + sandbox.useFakeTimers(); + + sandbox.stub(core.otperformance, 'timeOrigin').value(0); + sandbox.stub(core.otperformance, 'now').callsFake(() => fakeNow); + + function fakeFetch(input: RequestInfo, init: RequestInit = {}) { + return new Promise((resolve, reject) => { + const response: any = { + args: {}, + url: fileUrl, + }; + response.headers = Object.assign({}, init.headers); + + if (init.method === 'DELETE') { + response.status = 405; + response.statusText = 'OK'; + resolve(new window.Response('foo', response)); + } else if (input === url) { + response.status = 200; + response.statusText = 'OK'; + resolve(new window.Response(JSON.stringify(response), response)); + } else { + response.status = 404; + response.statusText = 'Bad request'; + reject(new window.Response(JSON.stringify(response), response)); + } + }); + } + + sandbox.stub(window, 'fetch').callsFake(fakeFetch as any); + + const resources: PerformanceResourceTiming[] = []; + resources.push( + createResource({ + name: fileUrl, + }), + createMasterResource({ + name: fileUrl, + }) + ); + + const spyEntries = sandbox.stub(performance, 'getEntriesByType'); + spyEntries.withArgs('resource').returns(resources); + fetchPlugin = new FetchPlugin(config); + webTracerProviderWithZone = new WebTracerProvider({ + logLevel: core.LogLevel.ERROR, + plugins: [fetchPlugin], + }); + webTracerWithZone = webTracerProviderWithZone.getTracer('fetch-test'); + dummySpanExporter = new DummySpanExporter(); + exportSpy = sandbox.stub(dummySpanExporter, 'export'); + clearResourceTimingsSpy = sandbox.stub(performance, 'clearResourceTimings'); + webTracerProviderWithZone.addSpanProcessor( + new tracing.SimpleSpanProcessor(dummySpanExporter) + ); + + rootSpan = webTracerWithZone.startSpan('root'); + webTracerWithZone.withSpan(rootSpan, () => { + fakeNow = 0; + getData(fileUrl, method).then( + response => { + // this is a bit tricky as the only way to get all request headers from + // fetch is to use json() + response.json().then( + json => { + lastResponse = json; + const headers: { [key: string]: string } = {}; + Object.keys(lastResponse.headers).forEach(key => { + headers[key.toLowerCase()] = lastResponse.headers[key]; + }); + lastResponse.headers = headers; + // OBSERVER_WAIT_TIME_MS + sandbox.clock.tick(300); + done(); + }, + () => { + lastResponse = undefined; + // OBSERVER_WAIT_TIME_MS + sandbox.clock.tick(300); + done(); + } + ); + }, + () => { + lastResponse = undefined; + // OBSERVER_WAIT_TIME_MS + sandbox.clock.tick(300); + done(); + } + ); + fakeNow = 300; + }); + }; + + beforeEach(() => { + contextManager = new ZoneContextManager().enable(); + api.context.setGlobalContextManager(contextManager); + }); + + afterEach(() => { + api.context.disable(); + }); + + before(() => { + api.propagation.setGlobalPropagator(new core.B3Propagator()); + }); + + describe('when request is successful', () => { + beforeEach(done => { + const propagateTraceHeaderCorsUrls = [url]; + prepareData(done, url, { propagateTraceHeaderCorsUrls }); + }); + + afterEach(() => { + clearData(); + }); + + it('should wrap methods', () => { + assert.ok(core.isWrapped(window.fetch)); + fetchPlugin.patch(); + assert.ok(core.isWrapped(window.fetch)); + }); + + it('should unwrap methods', () => { + assert.ok(core.isWrapped(window.fetch)); + fetchPlugin.unpatch(); + assert.ok(!core.isWrapped(window.fetch)); + }); + + it('should create a span with correct root span', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual( + span.parentSpanId, + rootSpan.context().spanId, + 'parent span is not root span' + ); + }); + + it('span should have correct name', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual(span.name, 'HTTP GET', 'span has wrong name'); + }); + + it('span should have correct kind', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual(span.kind, api.SpanKind.CLIENT, 'span has wrong kind'); + }); + + it('span should have correct attributes', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const attributes = span.attributes; + const keys = Object.keys(attributes); + + assert.ok( + attributes[keys[0]] !== '', + `attributes ${AttributeNames.COMPONENT} is not defined` + ); + assert.strictEqual( + attributes[keys[1]], + 'GET', + `attributes ${AttributeNames.HTTP_METHOD} is wrong` + ); + assert.strictEqual( + attributes[keys[2]], + url, + `attributes ${AttributeNames.HTTP_URL} is wrong` + ); + assert.strictEqual( + attributes[keys[3]], + 200, + `attributes ${AttributeNames.HTTP_STATUS_CODE} is wrong` + ); + assert.ok( + attributes[keys[4]] === 'OK' || attributes[keys[4]] === '', + `attributes ${AttributeNames.HTTP_STATUS_TEXT} is wrong` + ); + assert.ok( + (attributes[keys[5]] as string).indexOf('localhost') === 0, + `attributes ${AttributeNames.HTTP_HOST} is wrong` + ); + assert.ok( + attributes[keys[6]] === 'http' || attributes[keys[6]] === 'https', + `attributes ${AttributeNames.HTTP_SCHEME} is wrong` + ); + assert.ok( + attributes[keys[7]] !== '', + `attributes ${AttributeNames.HTTP_USER_AGENT} is not defined` + ); + + assert.strictEqual(keys.length, 8, 'number of attributes is wrong'); + }); + + it('span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + const events = span.events; + assert.strictEqual(events.length, 9, 'number of events is wrong'); + + assert.strictEqual( + events[0].name, + PTN.FETCH_START, + `event ${PTN.FETCH_START} is not defined` + ); + assert.strictEqual( + events[1].name, + PTN.DOMAIN_LOOKUP_START, + `event ${PTN.DOMAIN_LOOKUP_START} is not defined` + ); + assert.strictEqual( + events[2].name, + PTN.DOMAIN_LOOKUP_END, + `event ${PTN.DOMAIN_LOOKUP_END} is not defined` + ); + assert.strictEqual( + events[3].name, + PTN.CONNECT_START, + `event ${PTN.CONNECT_START} is not defined` + ); + assert.strictEqual( + events[4].name, + PTN.SECURE_CONNECTION_START, + `event ${PTN.SECURE_CONNECTION_START} is not defined` + ); + assert.strictEqual( + events[5].name, + PTN.CONNECT_END, + `event ${PTN.CONNECT_END} is not defined` + ); + assert.strictEqual( + events[6].name, + PTN.REQUEST_START, + `event ${PTN.REQUEST_START} is not defined` + ); + assert.strictEqual( + events[7].name, + PTN.RESPONSE_START, + `event ${PTN.RESPONSE_START} is not defined` + ); + assert.strictEqual( + events[8].name, + PTN.RESPONSE_END, + `event ${PTN.RESPONSE_END} is not defined` + ); + }); + + it('should create a span for preflight request', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const parentSpan: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual( + span.parentSpanId, + parentSpan.spanContext.spanId, + 'parent span is not root span' + ); + }); + + it('preflight request span should have correct name', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + assert.strictEqual( + span.name, + 'CORS Preflight', + 'preflight request span has wrong name' + ); + }); + + it('preflight request span should have correct kind', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + assert.strictEqual( + span.kind, + api.SpanKind.INTERNAL, + 'span has wrong kind' + ); + }); + + it('preflight request span should have correct events', () => { + const span: tracing.ReadableSpan = exportSpy.args[0][0][0]; + const events = span.events; + assert.strictEqual(events.length, 9, 'number of events is wrong'); + + assert.strictEqual( + events[0].name, + PTN.FETCH_START, + `event ${PTN.FETCH_START} is not defined` + ); + assert.strictEqual( + events[1].name, + PTN.DOMAIN_LOOKUP_START, + `event ${PTN.DOMAIN_LOOKUP_START} is not defined` + ); + assert.strictEqual( + events[2].name, + PTN.DOMAIN_LOOKUP_END, + `event ${PTN.DOMAIN_LOOKUP_END} is not defined` + ); + assert.strictEqual( + events[3].name, + PTN.CONNECT_START, + `event ${PTN.CONNECT_START} is not defined` + ); + assert.strictEqual( + events[4].name, + PTN.SECURE_CONNECTION_START, + `event ${PTN.SECURE_CONNECTION_START} is not defined` + ); + assert.strictEqual( + events[5].name, + PTN.CONNECT_END, + `event ${PTN.CONNECT_END} is not defined` + ); + assert.strictEqual( + events[6].name, + PTN.REQUEST_START, + `event ${PTN.REQUEST_START} is not defined` + ); + assert.strictEqual( + events[7].name, + PTN.RESPONSE_START, + `event ${PTN.RESPONSE_START} is not defined` + ); + assert.strictEqual( + events[8].name, + PTN.RESPONSE_END, + `event ${PTN.RESPONSE_END} is not defined` + ); + }); + + it('should set trace headers', () => { + const span: api.Span = exportSpy.args[1][0][0]; + assert.strictEqual( + lastResponse.headers[core.X_B3_TRACE_ID], + span.context().traceId, + `trace header '${core.X_B3_TRACE_ID}' not set` + ); + assert.strictEqual( + lastResponse.headers[core.X_B3_SPAN_ID], + span.context().spanId, + `trace header '${core.X_B3_SPAN_ID}' not set` + ); + assert.strictEqual( + lastResponse.headers[core.X_B3_SAMPLED], + String(span.context().traceFlags), + `trace header '${core.X_B3_SAMPLED}' not set` + ); + }); + + it('should NOT clear the resources', () => { + assert.strictEqual( + clearResourceTimingsSpy.args.length, + 0, + 'resources have been cleared' + ); + }); + + describe('when propagateTraceHeaderCorsUrls does NOT MATCH', () => { + beforeEach(done => { + clearData(); + prepareData(done, url, {}); + }); + it('should NOT set trace headers', () => { + assert.strictEqual( + lastResponse.headers[core.X_B3_TRACE_ID], + undefined, + `trace header '${core.X_B3_TRACE_ID}' should not be set` + ); + assert.strictEqual( + lastResponse.headers[core.X_B3_SPAN_ID], + undefined, + `trace header '${core.X_B3_SPAN_ID}' should not be set` + ); + assert.strictEqual( + lastResponse.headers[core.X_B3_SAMPLED], + undefined, + `trace header '${core.X_B3_SAMPLED}' should not be set` + ); + }); + }); + }); + + describe('when url is ignored', () => { + beforeEach(done => { + const propagateTraceHeaderCorsUrls = url; + prepareData(done, url, { + propagateTraceHeaderCorsUrls, + ignoreUrls: [propagateTraceHeaderCorsUrls], + }); + }); + afterEach(() => { + clearData(); + }); + it('should NOT create any span', () => { + assert.strictEqual(exportSpy.args.length, 0, "span shouldn't b exported"); + }); + }); + + describe('when clearTimingResources is TRUE', () => { + beforeEach(done => { + const propagateTraceHeaderCorsUrls = url; + prepareData(done, url, { + propagateTraceHeaderCorsUrls, + clearTimingResources: true, + }); + }); + afterEach(() => { + clearData(); + }); + it('should clear the resources', () => { + assert.strictEqual( + clearResourceTimingsSpy.args.length, + 1, + "resources haven't been cleared" + ); + }); + }); + + describe('when request is NOT successful (wrong url)', () => { + beforeEach(done => { + const propagateTraceHeaderCorsUrls = badUrl; + prepareData(done, badUrl, { propagateTraceHeaderCorsUrls }); + }); + afterEach(() => { + clearData(); + }); + it('should create a span with correct root span', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual( + span.parentSpanId, + rootSpan.context().spanId, + 'parent span is not root span' + ); + }); + }); + + describe('when request is NOT successful (405)', () => { + beforeEach(done => { + const propagateTraceHeaderCorsUrls = url; + prepareData(done, url, { propagateTraceHeaderCorsUrls }, 'DELETE'); + }); + afterEach(() => { + clearData(); + }); + + it('should create a span with correct root span', () => { + const span: tracing.ReadableSpan = exportSpy.args[1][0][0]; + assert.strictEqual( + span.parentSpanId, + rootSpan.context().spanId, + 'parent span is not root span' + ); + }); + }); +}); diff --git a/packages/opentelemetry-plugin-fetch/test/index-webpack.ts b/packages/opentelemetry-plugin-fetch/test/index-webpack.ts new file mode 100644 index 0000000000..061a48ccfa --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/test/index-webpack.ts @@ -0,0 +1,20 @@ +/* + * Copyright The 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. + */ +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-fetch/tsconfig.json b/packages/opentelemetry-plugin-fetch/tsconfig.json new file mode 100644 index 0000000000..71661a842e --- /dev/null +++ b/packages/opentelemetry-plugin-fetch/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base", + "compilerOptions": { + "rootDir": ".", + "outDir": "build", + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/packages/opentelemetry-plugin-xml-http-request/src/xhr.ts b/packages/opentelemetry-plugin-xml-http-request/src/xhr.ts index ff18cb02f1..59b59cc03d 100644 --- a/packages/opentelemetry-plugin-xml-http-request/src/xhr.ts +++ b/packages/opentelemetry-plugin-xml-http-request/src/xhr.ts @@ -21,17 +21,17 @@ import { isUrlIgnored, isWrapped, otperformance, - urlMatches, } from '@opentelemetry/core'; import { HttpAttribute, GeneralAttribute, } from '@opentelemetry/semantic-conventions'; import { - addSpanNetworkEvent, + addSpanNetworkEvents, getResource, parseUrl, PerformanceTimingNames as PTN, + shouldPropagateTraceHeaders, } from '@opentelemetry/web'; import * as shimmer from 'shimmer'; import { EventNames } from './enums/EventNames'; @@ -86,7 +86,12 @@ export class XMLHttpRequestPlugin extends BasePlugin { * @private */ private _addHeaders(xhr: XMLHttpRequest, spanUrl: string) { - if (!this._shouldPropagateTraceHeaders(spanUrl)) { + if ( + !shouldPropagateTraceHeaders( + spanUrl, + this._config.propagateTraceHeaderCorsUrls + ) + ) { return; } const headers: { [key: string]: unknown } = {}; @@ -96,34 +101,6 @@ export class XMLHttpRequestPlugin extends BasePlugin { }); } - /** - * checks if trace headers should be propagated - * @param spanUrl - * @private - */ - _shouldPropagateTraceHeaders(spanUrl: string) { - let propagateTraceHeaderUrls = - this._config.propagateTraceHeaderCorsUrls || []; - if ( - typeof propagateTraceHeaderUrls === 'string' || - propagateTraceHeaderUrls instanceof RegExp - ) { - propagateTraceHeaderUrls = [propagateTraceHeaderUrls]; - } - const parsedSpanUrl = parseUrl(spanUrl); - - if (parsedSpanUrl.origin === window.location.origin) { - return true; - } else { - for (const propagateTraceHeaderUrl of propagateTraceHeaderUrls) { - if (urlMatches(spanUrl, propagateTraceHeaderUrl)) { - return true; - } - } - return false; - } - } - /** * Add cors pre flight child span * @param span @@ -138,7 +115,7 @@ export class XMLHttpRequestPlugin extends BasePlugin { const childSpan = this._tracer.startSpan('CORS Preflight', { startTime: corsPreFlightRequest[PTN.FETCH_START], }); - this._addSpanNetworkEvents(childSpan, corsPreFlightRequest); + addSpanNetworkEvents(childSpan, corsPreFlightRequest); childSpan.end(corsPreFlightRequest[PTN.RESPONSE_END]); }); } @@ -168,27 +145,6 @@ export class XMLHttpRequestPlugin extends BasePlugin { } } - /** - * Adds Network events to the span - * @param span - * @param resource - * @private - */ - private _addSpanNetworkEvents( - span: api.Span, - resource: PerformanceResourceTiming - ) { - addSpanNetworkEvent(span, PTN.FETCH_START, resource); - addSpanNetworkEvent(span, PTN.DOMAIN_LOOKUP_START, resource); - addSpanNetworkEvent(span, PTN.DOMAIN_LOOKUP_END, resource); - addSpanNetworkEvent(span, PTN.CONNECT_START, resource); - addSpanNetworkEvent(span, PTN.SECURE_CONNECTION_START, resource); - addSpanNetworkEvent(span, PTN.CONNECT_END, resource); - addSpanNetworkEvent(span, PTN.REQUEST_START, resource); - addSpanNetworkEvent(span, PTN.RESPONSE_START, resource); - addSpanNetworkEvent(span, PTN.RESPONSE_END, resource); - } - /** * will collect information about all resources created * between "send" and "end" with additional waiting for main resource @@ -260,6 +216,7 @@ export class XMLHttpRequestPlugin extends BasePlugin { // information resources = otperformance.getEntriesByType( // ts thinks this is the perf_hooks module, but it is the browser performance api + // eslint-disable-next-line @typescript-eslint/no-explicit-any 'resource' as any ) as PerformanceResourceTiming[]; } @@ -281,7 +238,7 @@ export class XMLHttpRequestPlugin extends BasePlugin { this._addChildSpan(span, corsPreFlightRequest); this._markResourceAsUsed(corsPreFlightRequest); } - this._addSpanNetworkEvents(span, mainRequest); + addSpanNetworkEvents(span, mainRequest); } } diff --git a/packages/opentelemetry-web/src/StackContextManager.ts b/packages/opentelemetry-web/src/StackContextManager.ts index 78d8d34dc3..718ec88035 100644 --- a/packages/opentelemetry-web/src/StackContextManager.ts +++ b/packages/opentelemetry-web/src/StackContextManager.ts @@ -42,7 +42,7 @@ export class StackContextManager implements ContextManager { context = Context.ROOT_CONTEXT ): T { const manager = this; - const contextWrapper = function (this: any, ...args: any[]) { + const contextWrapper = function (this: unknown, ...args: unknown[]) { return manager.with(context, () => target.apply(this, args)); }; Object.defineProperty(contextWrapper, 'length', { diff --git a/packages/opentelemetry-web/src/types.ts b/packages/opentelemetry-web/src/types.ts index 45ed1a9e03..10ee04f14e 100644 --- a/packages/opentelemetry-web/src/types.ts +++ b/packages/opentelemetry-web/src/types.ts @@ -53,3 +53,12 @@ export interface PerformanceResourceTimingInfo { corsPreFlightRequest?: PerformanceResourceTiming; mainRequest?: PerformanceResourceTiming; } + +type PropagateTraceHeaderCorsUrl = string | RegExp; + +/** + * urls which should include trace headers when origin doesn't match + */ +export type PropagateTraceHeaderCorsUrls = + | PropagateTraceHeaderCorsUrl + | PropagateTraceHeaderCorsUrl[]; diff --git a/packages/opentelemetry-web/src/utils.ts b/packages/opentelemetry-web/src/utils.ts index 1edeb64f0c..aa3dd320b6 100644 --- a/packages/opentelemetry-web/src/utils.ts +++ b/packages/opentelemetry-web/src/utils.ts @@ -14,16 +14,25 @@ * limitations under the License. */ -import { PerformanceEntries, PerformanceResourceTimingInfo } from './types'; +import { + PerformanceEntries, + PerformanceResourceTimingInfo, + PropagateTraceHeaderCorsUrls, +} from './types'; import { PerformanceTimingNames as PTN } from './enums/PerformanceTimingNames'; import * as api from '@opentelemetry/api'; -import { hrTimeToNanoseconds, timeInputToHrTime } from '@opentelemetry/core'; +import { + hrTimeToNanoseconds, + timeInputToHrTime, + urlMatches, +} from '@opentelemetry/core'; /** * Helper function to be able to use enum as typed key in type and in interface when using forEach * @param obj * @param key */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function hasKey(obj: O, key: keyof any): key is keyof O { return key in obj; } @@ -54,6 +63,26 @@ export function addSpanNetworkEvent( return undefined; } +/** + * Helper function for adding network events + * @param span + * @param resource + */ +export function addSpanNetworkEvents( + span: api.Span, + resource: PerformanceEntries +): void { + addSpanNetworkEvent(span, PTN.FETCH_START, resource); + addSpanNetworkEvent(span, PTN.DOMAIN_LOOKUP_START, resource); + addSpanNetworkEvent(span, PTN.DOMAIN_LOOKUP_END, resource); + addSpanNetworkEvent(span, PTN.CONNECT_START, resource); + addSpanNetworkEvent(span, PTN.SECURE_CONNECTION_START, resource); + addSpanNetworkEvent(span, PTN.CONNECT_END, resource); + addSpanNetworkEvent(span, PTN.REQUEST_START, resource); + addSpanNetworkEvent(span, PTN.RESPONSE_START, resource); + addSpanNetworkEvent(span, PTN.RESPONSE_END, resource); +} + /** * sort resources by startTime * @param filteredResources @@ -79,6 +108,7 @@ export function sortResources(filteredResources: PerformanceResourceTiming[]) { * @param endTimeHR * @param resources * @param ignoredResources + * @param initiatorType */ export function getResource( spanUrl: string, @@ -87,14 +117,16 @@ export function getResource( resources: PerformanceResourceTiming[], ignoredResources: WeakSet = new WeakSet< PerformanceResourceTiming - >() + >(), + initiatorType?: string ): PerformanceResourceTimingInfo { const filteredResources = filterResourcesForSpan( spanUrl, startTimeHR, endTimeHR, resources, - ignoredResources + ignoredResources, + initiatorType ); if (filteredResources.length === 0) { @@ -192,7 +224,8 @@ function filterResourcesForSpan( startTimeHR: api.HrTime, endTimeHR: api.HrTime, resources: PerformanceResourceTiming[], - ignoredResources: WeakSet + ignoredResources: WeakSet, + initiatorType?: string ) { const startTime = hrTimeToNanoseconds(startTimeHR); const endTime = hrTimeToNanoseconds(endTimeHR); @@ -205,7 +238,8 @@ function filterResourcesForSpan( ); return ( - resource.initiatorType.toLowerCase() === 'xmlhttprequest' && + resource.initiatorType.toLowerCase() === + (initiatorType || 'xmlhttprequest') && resource.name === spanUrl && resourceStartTime >= startTime && resourceEndTime <= endTime @@ -237,6 +271,7 @@ export function parseUrl(url: string): HTMLAnchorElement { * @param optimised - when id attribute of element is present the xpath can be * simplified to contain id */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function getElementXPath(target: any, optimised?: boolean) { if (target.nodeType === Node.DOCUMENT_NODE) { return '/'; @@ -312,3 +347,30 @@ function getNodeValue(target: HTMLElement, optimised?: boolean): string { } return `/${nodeValue}`; } + +/** + * Checks if trace headers should be propagated + * @param spanUrl + * @private + */ +export function shouldPropagateTraceHeaders( + spanUrl: string, + propagateTraceHeaderCorsUrls?: PropagateTraceHeaderCorsUrls +) { + let propagateTraceHeaderUrls = propagateTraceHeaderCorsUrls || []; + if ( + typeof propagateTraceHeaderUrls === 'string' || + propagateTraceHeaderUrls instanceof RegExp + ) { + propagateTraceHeaderUrls = [propagateTraceHeaderUrls]; + } + const parsedSpanUrl = parseUrl(spanUrl); + + if (parsedSpanUrl.origin === window.location.origin) { + return true; + } else { + return propagateTraceHeaderUrls.some(propagateTraceHeaderUrl => + urlMatches(spanUrl, propagateTraceHeaderUrl) + ); + } +} diff --git a/packages/opentelemetry-web/test/utils.test.ts b/packages/opentelemetry-web/test/utils.test.ts index 52a417ba42..805986de71 100644 --- a/packages/opentelemetry-web/test/utils.test.ts +++ b/packages/opentelemetry-web/test/utils.test.ts @@ -26,9 +26,11 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { addSpanNetworkEvent, + addSpanNetworkEvents, getElementXPath, getResource, PerformanceEntries, + shouldPropagateTraceHeaders, } from '../src'; import { PerformanceTimingNames as PTN } from '../src/enums/PerformanceTimingNames'; @@ -132,6 +134,31 @@ describe('utils', () => { sandbox.restore(); }); + describe('addSpanNetworkEvents', () => { + it('should add all network events to span', () => { + const addEventSpy = sinon.spy(); + const span = ({ + addEvent: addEventSpy, + } as unknown) as tracing.Span; + const entries = { + [PTN.FETCH_START]: 123, + [PTN.DOMAIN_LOOKUP_START]: 123, + [PTN.DOMAIN_LOOKUP_END]: 123, + [PTN.CONNECT_START]: 123, + [PTN.SECURE_CONNECTION_START]: 123, + [PTN.CONNECT_END]: 123, + [PTN.REQUEST_START]: 123, + [PTN.RESPONSE_START]: 123, + [PTN.RESPONSE_END]: 123, + } as PerformanceEntries; + + assert.strictEqual(addEventSpy.callCount, 0); + + addSpanNetworkEvents(span, entries); + + assert.strictEqual(addEventSpy.callCount, 9); + }); + }); describe('addSpanNetworkEvent', () => { describe('when entries contain the performance', () => { it('should add event to span', () => { @@ -508,6 +535,40 @@ describe('utils', () => { assert.strictEqual(node, getElementByXpath(element)); }); }); + + describe('shouldPropagateTraceHeaders', () => { + it('should propagate trace when url is the same as origin', () => { + const result = shouldPropagateTraceHeaders( + `${window.location.origin}/foo/bar` + ); + assert.strictEqual(result, true); + }); + it('should propagate trace when url match', () => { + const result = shouldPropagateTraceHeaders( + 'http://foo.com', + 'http://foo.com' + ); + assert.strictEqual(result, true); + }); + it('should propagate trace when url match regexp', () => { + const result = shouldPropagateTraceHeaders('http://foo.com', /foo.+/); + assert.strictEqual(result, true); + }); + it('should propagate trace when url match array of string', () => { + const result = shouldPropagateTraceHeaders('http://foo.com', [ + 'http://foo.com', + ]); + assert.strictEqual(result, true); + }); + it('should propagate trace when url match array of regexp', () => { + const result = shouldPropagateTraceHeaders('http://foo.com', [/foo.+/]); + assert.strictEqual(result, true); + }); + it("should NOT propagate trace when url doesn't match", () => { + const result = shouldPropagateTraceHeaders('http://foo.com'); + assert.strictEqual(result, false); + }); + }); }); function getElementByXpath(path: string) {