UNPKG

@opentelemetry/instrumentation-xml-http-request

Version:
451 lines 19.1 kB
/* * 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 { SemconvStability, semconvStabilityFromStr, isWrapped, InstrumentationBase, safeExecuteInTheMiddle, } from '@opentelemetry/instrumentation'; import { hrTime, isUrlIgnored, otperformance } from '@opentelemetry/core'; import { addSpanNetworkEvents, getResource, PerformanceTimingNames as PTN, shouldPropagateTraceHeaders, parseUrl, } from '@opentelemetry/sdk-trace-web'; import { ATTR_ERROR_TYPE, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_REQUEST_METHOD_ORIGINAL, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, } from '@opentelemetry/semantic-conventions'; import { ATTR_HTTP_HOST, ATTR_HTTP_METHOD, ATTR_HTTP_SCHEME, ATTR_HTTP_STATUS_CODE, ATTR_HTTP_URL, ATTR_HTTP_USER_AGENT, ATTR_HTTP_REQUEST_BODY_SIZE, ATTR_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, } from './semconv'; import { EventNames } from './enums/EventNames'; import { normalizeHttpRequestMethod, serverPortFromUrl, getXHRBodyLength, } from './utils'; import { VERSION } from './version'; import { AttributeNames } from './enums/AttributeNames'; // 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; /** * This class represents a XMLHttpRequest plugin for auto instrumentation */ export class XMLHttpRequestInstrumentation extends InstrumentationBase { component = 'xml-http-request'; version = VERSION; moduleName = this.component; _tasksCount = 0; _xhrMem = new WeakMap(); _usedResources = new WeakSet(); _semconvStability; constructor(config = {}) { super('@opentelemetry/instrumentation-xml-http-request', VERSION, config); this._semconvStability = semconvStabilityFromStr('http', config?.semconvStabilityOptIn); } init() { } /** * Adds custom headers to XMLHttpRequest * @param xhr * @param spanUrl * @private */ _addHeaders(xhr, spanUrl) { const url = parseUrl(spanUrl).href; if (!shouldPropagateTraceHeaders(url, this.getConfig().propagateTraceHeaderCorsUrls)) { const headers = {}; api.propagation.inject(api.context.active(), headers); if (Object.keys(headers).length > 0) { this._diag.debug('headers inject skipped due to CORS policy'); } return; } const headers = {}; api.propagation.inject(api.context.active(), headers); Object.keys(headers).forEach(key => { xhr.setRequestHeader(key, String(headers[key])); }); } /** * Add cors pre flight child span * @param span * @param corsPreFlightRequest * @private */ _addChildSpan(span, corsPreFlightRequest) { api.context.with(api.trace.setSpan(api.context.active(), span), () => { const childSpan = this.tracer.startSpan('CORS Preflight', { startTime: corsPreFlightRequest[PTN.FETCH_START], }); const skipOldSemconvContentLengthAttrs = !(this._semconvStability & SemconvStability.OLD); addSpanNetworkEvents(childSpan, corsPreFlightRequest, this.getConfig().ignoreNetworkEvents, undefined, skipOldSemconvContentLengthAttrs); childSpan.end(corsPreFlightRequest[PTN.RESPONSE_END]); }); } /** * Add attributes when span is going to end * @param span * @param xhr * @param spanUrl * @private */ _addFinalSpanAttributes(span, xhrMem, spanUrl) { if (this._semconvStability & SemconvStability.OLD) { if (xhrMem.status !== undefined) { span.setAttribute(ATTR_HTTP_STATUS_CODE, xhrMem.status); } if (xhrMem.statusText !== undefined) { span.setAttribute(AttributeNames.HTTP_STATUS_TEXT, xhrMem.statusText); } if (typeof spanUrl === 'string') { const parsedUrl = parseUrl(spanUrl); span.setAttribute(ATTR_HTTP_HOST, parsedUrl.host); span.setAttribute(ATTR_HTTP_SCHEME, parsedUrl.protocol.replace(':', '')); } // @TODO do we want to collect this or it will be collected earlier once only or // maybe when parent span is not available ? span.setAttribute(ATTR_HTTP_USER_AGENT, navigator.userAgent); } if (this._semconvStability & SemconvStability.STABLE) { if (xhrMem.status) { // Intentionally exclude status=0, because XHR uses 0 for before a // response is received and semconv says to only add the attribute if // received a response. span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, xhrMem.status); } } } _applyAttributesAfterXHR(span, xhr) { const applyCustomAttributesOnSpan = this.getConfig().applyCustomAttributesOnSpan; if (typeof applyCustomAttributesOnSpan === 'function') { safeExecuteInTheMiddle(() => applyCustomAttributesOnSpan(span, xhr), error => { if (!error) { return; } this._diag.error('applyCustomAttributesOnSpan', error); }, true); } } /** * will collect information about all resources created * between "send" and "end" with additional waiting for main resource * @param xhr * @param spanUrl * @private */ _addResourceObserver(xhr, spanUrl) { const xhrMem = this._xhrMem.get(xhr); if (!xhrMem || typeof PerformanceObserver !== 'function' || typeof PerformanceResourceTiming !== 'function') { return; } xhrMem.createdResources = { observer: new PerformanceObserver(list => { const entries = list.getEntries(); const parsedUrl = parseUrl(spanUrl); entries.forEach(entry => { if (entry.initiatorType === 'xmlhttprequest' && entry.name === parsedUrl.href) { if (xhrMem.createdResources) { xhrMem.createdResources.entries.push(entry); } } }); }), entries: [], }; xhrMem.createdResources.observer.observe({ entryTypes: ['resource'], }); } /** * Clears the resource timings and all resources assigned with spans * when {@link XMLHttpRequestInstrumentationConfig.clearTimingResources} is * set to true (default false) * @private */ _clearResources() { if (this._tasksCount === 0 && this.getConfig().clearTimingResources) { otperformance.clearResourceTimings(); this._xhrMem = new WeakMap(); this._usedResources = new WeakSet(); } } /** * Finds appropriate resource and add network events to the span * @param span */ _findResourceAndAddNetworkEvents(xhrMem, span, spanUrl, startTime, endTime) { if (!spanUrl || !startTime || !endTime || !xhrMem.createdResources) { return; } let resources = xhrMem.createdResources.entries; if (!resources || !resources.length) { // fallback - either Observer is not available or it took longer // then OBSERVER_WAIT_TIME_MS and observer didn't collect enough // information // ts thinks this is the perf_hooks module, but it is the browser performance api resources = otperformance.getEntriesByType('resource'); } const resource = getResource(parseUrl(spanUrl).href, startTime, endTime, resources, this._usedResources); if (resource.mainRequest) { const mainRequest = resource.mainRequest; this._markResourceAsUsed(mainRequest); const corsPreFlightRequest = resource.corsPreFlightRequest; if (corsPreFlightRequest) { this._addChildSpan(span, corsPreFlightRequest); this._markResourceAsUsed(corsPreFlightRequest); } const skipOldSemconvContentLengthAttrs = !(this._semconvStability & SemconvStability.OLD); addSpanNetworkEvents(span, mainRequest, this.getConfig().ignoreNetworkEvents, undefined, skipOldSemconvContentLengthAttrs); } } /** * Removes the previous information about span. * This might happened when the same xhr is used again. * @param xhr * @private */ _cleanPreviousSpanInformation(xhr) { const xhrMem = this._xhrMem.get(xhr); if (xhrMem) { const callbackToRemoveEvents = xhrMem.callbackToRemoveEvents; if (callbackToRemoveEvents) { callbackToRemoveEvents(); } this._xhrMem.delete(xhr); } } /** * Creates a new span when method "open" is called * @param xhr * @param url * @param method * @private */ _createSpan(xhr, url, method) { if (isUrlIgnored(url, this.getConfig().ignoreUrls)) { this._diag.debug('ignoring span as url matches ignored url'); return; } let name = ''; const parsedUrl = parseUrl(url); const attributes = {}; if (this._semconvStability & SemconvStability.OLD) { name = method.toUpperCase(); attributes[ATTR_HTTP_METHOD] = method; attributes[ATTR_HTTP_URL] = parsedUrl.toString(); } if (this._semconvStability & SemconvStability.STABLE) { const origMethod = method; const normMethod = normalizeHttpRequestMethod(method); if (!name) { // The "old" span name wins if emitting both old and stable semconv // ('http/dup'). name = normMethod; } attributes[ATTR_HTTP_REQUEST_METHOD] = normMethod; if (normMethod !== origMethod) { attributes[ATTR_HTTP_REQUEST_METHOD_ORIGINAL] = origMethod; } attributes[ATTR_URL_FULL] = parsedUrl.toString(); attributes[ATTR_SERVER_ADDRESS] = parsedUrl.hostname; const serverPort = serverPortFromUrl(parsedUrl); if (serverPort) { attributes[ATTR_SERVER_PORT] = serverPort; } } const currentSpan = this.tracer.startSpan(name, { kind: api.SpanKind.CLIENT, attributes, }); currentSpan.addEvent(EventNames.METHOD_OPEN); this._cleanPreviousSpanInformation(xhr); this._xhrMem.set(xhr, { span: currentSpan, spanUrl: url, }); return currentSpan; } /** * 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) { this._usedResources.add(resource); } /** * Patches the method open * @private */ _patchOpen() { return (original) => { const plugin = this; return function patchOpen(...args) { const method = args[0]; const url = args[1]; plugin._createSpan(this, url, method); return original.apply(this, args); }; }; } /** * Patches the method send * @private */ _patchSend() { const plugin = this; function endSpanTimeout(eventName, xhrMem, performanceEndTime, endTime) { const callbackToRemoveEvents = xhrMem.callbackToRemoveEvents; if (typeof callbackToRemoveEvents === 'function') { callbackToRemoveEvents(); } const { span, spanUrl, sendStartTime } = xhrMem; if (span) { plugin._findResourceAndAddNetworkEvents(xhrMem, span, spanUrl, sendStartTime, performanceEndTime); span.addEvent(eventName, endTime); plugin._addFinalSpanAttributes(span, xhrMem, spanUrl); span.end(endTime); plugin._tasksCount--; } plugin._clearResources(); } function endSpan(eventName, xhr, isError, errorType) { const xhrMem = plugin._xhrMem.get(xhr); if (!xhrMem) { return; } xhrMem.status = xhr.status; xhrMem.statusText = xhr.statusText; plugin._xhrMem.delete(xhr); if (xhrMem.span) { const span = xhrMem.span; plugin._applyAttributesAfterXHR(span, xhr); if (plugin._semconvStability & SemconvStability.STABLE) { if (isError) { if (errorType) { span.setStatus({ code: api.SpanStatusCode.ERROR, message: errorType, }); span.setAttribute(ATTR_ERROR_TYPE, errorType); } } else if (xhrMem.status && xhrMem.status >= 400) { span.setStatus({ code: api.SpanStatusCode.ERROR }); span.setAttribute(ATTR_ERROR_TYPE, String(xhrMem.status)); } } } const performanceEndTime = hrTime(); const endTime = Date.now(); // the timeout is needed as observer doesn't have yet information // when event "load" is called. Also the time may differ depends on // browser and speed of computer setTimeout(() => { endSpanTimeout(eventName, xhrMem, performanceEndTime, endTime); }, OBSERVER_WAIT_TIME_MS); } function onError() { endSpan(EventNames.EVENT_ERROR, this, true, 'error'); } function onAbort() { endSpan(EventNames.EVENT_ABORT, this, false); } function onTimeout() { endSpan(EventNames.EVENT_TIMEOUT, this, true, 'timeout'); } function onLoad() { if (this.status < 299) { endSpan(EventNames.EVENT_LOAD, this, false); } else { endSpan(EventNames.EVENT_ERROR, this, false); } } function unregister(xhr) { xhr.removeEventListener('abort', onAbort); xhr.removeEventListener('error', onError); xhr.removeEventListener('load', onLoad); xhr.removeEventListener('timeout', onTimeout); const xhrMem = plugin._xhrMem.get(xhr); if (xhrMem) { xhrMem.callbackToRemoveEvents = undefined; } } return (original) => { return function patchSend(...args) { const xhrMem = plugin._xhrMem.get(this); if (!xhrMem) { return original.apply(this, args); } const currentSpan = xhrMem.span; const spanUrl = xhrMem.spanUrl; if (currentSpan && spanUrl) { if (plugin.getConfig().measureRequestSize && args?.[0]) { const body = args[0]; const bodyLength = getXHRBodyLength(body); if (bodyLength !== undefined) { if (plugin._semconvStability & SemconvStability.OLD) { currentSpan.setAttribute(ATTR_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, bodyLength); } if (plugin._semconvStability & SemconvStability.STABLE) { currentSpan.setAttribute(ATTR_HTTP_REQUEST_BODY_SIZE, bodyLength); } } } api.context.with(api.trace.setSpan(api.context.active(), currentSpan), () => { plugin._tasksCount++; xhrMem.sendStartTime = hrTime(); currentSpan.addEvent(EventNames.METHOD_SEND); this.addEventListener('abort', onAbort); this.addEventListener('error', onError); this.addEventListener('load', onLoad); this.addEventListener('timeout', onTimeout); xhrMem.callbackToRemoveEvents = () => { unregister(this); if (xhrMem.createdResources) { xhrMem.createdResources.observer.disconnect(); } }; plugin._addHeaders(this, spanUrl); plugin._addResourceObserver(this, spanUrl); }); } return original.apply(this, args); }; }; } /** * implements enable function */ enable() { this._diag.debug('applying patch to', this.moduleName, this.version); if (isWrapped(XMLHttpRequest.prototype.open)) { this._unwrap(XMLHttpRequest.prototype, 'open'); this._diag.debug('removing previous patch from method open'); } if (isWrapped(XMLHttpRequest.prototype.send)) { this._unwrap(XMLHttpRequest.prototype, 'send'); this._diag.debug('removing previous patch from method send'); } this._wrap(XMLHttpRequest.prototype, 'open', this._patchOpen()); this._wrap(XMLHttpRequest.prototype, 'send', this._patchSend()); } /** * implements disable function */ disable() { this._diag.debug('removing patch from', this.moduleName, this.version); this._unwrap(XMLHttpRequest.prototype, 'open'); this._unwrap(XMLHttpRequest.prototype, 'send'); this._tasksCount = 0; this._xhrMem = new WeakMap(); this._usedResources = new WeakSet(); } } //# sourceMappingURL=xhr.js.map