UNPKG

@opentelemetry/instrumentation-fetch

Version:
452 lines 20.2 kB
/* * Copyright The OpenTelemetry Authors * SPDX-License-Identifier: Apache-2.0 */ import { context, propagation, SpanKind, SpanStatusCode, trace, } from '@opentelemetry/api'; import { SemconvStability, semconvStabilityFromStr, InstrumentationBase, safeExecuteInTheMiddle, } from '@opentelemetry/instrumentation'; import * as core from '@opentelemetry/core'; import * as web from '@opentelemetry/sdk-trace-web'; import { AttributeNames } from './enums/AttributeNames'; import { ATTR_HTTP_STATUS_CODE, ATTR_HTTP_HOST, ATTR_HTTP_USER_AGENT, ATTR_HTTP_SCHEME, ATTR_HTTP_URL, ATTR_HTTP_METHOD, ATTR_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, ATTR_HTTP_REQUEST_BODY_SIZE, } from './semconv'; 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 { getFetchBodyLength, normalizeHttpRequestMethod, serverPortFromUrl, } from './utils'; 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; const hasBrowserPerformanceAPI = typeof PerformanceObserver !== 'undefined'; /** * This class represents a fetch plugin for auto instrumentation */ export class FetchInstrumentation extends InstrumentationBase { component = 'fetch'; version = VERSION; moduleName = this.component; _usedResources = new WeakSet(); _tasksCount = 0; _semconvStability; constructor(config = {}) { super('@opentelemetry/instrumentation-fetch', VERSION, config); this._semconvStability = semconvStabilityFromStr('http', config?.semconvStabilityOptIn); } init() { } /** * Add cors pre flight child span * @param span * @param corsPreFlightRequest */ _addChildSpan(span, corsPreFlightRequest) { const childSpan = this.tracer.startSpan('CORS Preflight', { startTime: corsPreFlightRequest[web.PerformanceTimingNames.FETCH_START], }, trace.setSpan(context.active(), span)); const skipOldSemconvContentLengthAttrs = !(this._semconvStability & SemconvStability.OLD); web.addSpanNetworkEvents(childSpan, corsPreFlightRequest, this.getConfig().ignoreNetworkEvents, undefined, skipOldSemconvContentLengthAttrs); childSpan.end(corsPreFlightRequest[web.PerformanceTimingNames.RESPONSE_END]); } /** * Adds more attributes to span just before ending it * @param span * @param response */ _addFinalSpanAttributes(span, response) { const parsedUrl = web.parseUrl(response.url); if (this._semconvStability & SemconvStability.OLD) { span.setAttribute(ATTR_HTTP_STATUS_CODE, response.status); if (response.statusText != null) { span.setAttribute(AttributeNames.HTTP_STATUS_TEXT, response.statusText); } span.setAttribute(ATTR_HTTP_HOST, parsedUrl.host); span.setAttribute(ATTR_HTTP_SCHEME, parsedUrl.protocol.replace(':', '')); if (typeof navigator !== 'undefined') { span.setAttribute(ATTR_HTTP_USER_AGENT, navigator.userAgent); } } if (this._semconvStability & SemconvStability.STABLE) { span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); // TODO: Set server.{address,port} at span creation for sampling decisions // (a "SHOULD" requirement in semconv). span.setAttribute(ATTR_SERVER_ADDRESS, parsedUrl.hostname); const serverPort = serverPortFromUrl(parsedUrl); if (serverPort) { span.setAttribute(ATTR_SERVER_PORT, serverPort); } } } /** * Add headers * @param options * @param spanUrl */ _addHeaders(options, spanUrl) { if (!web.shouldPropagateTraceHeaders(spanUrl, this.getConfig().propagateTraceHeaderCorsUrls)) { const headers = {}; propagation.inject(context.active(), headers); if (Object.keys(headers).length > 0) { this._diag.debug('headers inject skipped due to CORS policy'); } return; } if (options instanceof Request) { // This mutates `Request.headers` in-place, because it is read-only // (per https://developer.mozilla.org/en-US/docs/Web/API/Request/headers), // so we cannot (easily) replace it. propagation.inject(context.active(), options.headers, { set: (h, k, v) => h.set(k, typeof v === 'string' ? v : String(v)), }); } else { // Otherwise, create a new Headers to avoid mutating the caller's // possibly re-used headers. const headers = new Headers(options.headers); propagation.inject(context.active(), headers, { set: (h, k, v) => h.set(k, typeof v === 'string' ? v : String(v)), }); options.headers = headers; } } /** * Clears the resource timings and all resources assigned with spans * when {@link FetchPluginConfig.clearTimingResources} is * set to true (default false) * @private */ _clearResources() { if (this._tasksCount === 0 && this.getConfig().clearTimingResources) { performance.clearResourceTimings(); this._usedResources = new WeakSet(); } } /** * Creates a new span * @param url * @param options */ _createSpan(url, options = {}) { if (core.isUrlIgnored(url, this.getConfig().ignoreUrls)) { this._diag.debug('ignoring span as url matches ignored url'); return; } let name = ''; const attributes = {}; if (this._semconvStability & SemconvStability.OLD) { const method = (options.method || 'GET').toUpperCase(); name = `HTTP ${method}`; attributes[AttributeNames.COMPONENT] = this.moduleName; attributes[ATTR_HTTP_METHOD] = method; attributes[ATTR_HTTP_URL] = url; } if (this._semconvStability & SemconvStability.STABLE) { const origMethod = options.method; const normMethod = normalizeHttpRequestMethod(options.method || 'GET'); 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] = url; } return this.tracer.startSpan(name, { kind: SpanKind.CLIENT, attributes, }); } /** * Finds appropriate resource and add network events to the span * @param span * @param resourcesObserver * @param endTime */ _findResourceAndAddNetworkEvents(span, resourcesObserver, endTime) { let resources = resourcesObserver.entries; if (!resources.length) { if (!performance.getEntriesByType) { return; } // 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'); } 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); } const skipOldSemconvContentLengthAttrs = !(this._semconvStability & SemconvStability.OLD); web.addSpanNetworkEvents(span, mainRequest, this.getConfig().ignoreNetworkEvents, undefined, skipOldSemconvContentLengthAttrs); } } /** * 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 */ _markResourceAsUsed(resource) { this._usedResources.add(resource); } /** * Finish span, add attributes, network events etc. * @param span * @param spanData * @param response */ _endSpan(span, spanData, response) { const endTime = core.millisToHrTime(Date.now()); const performanceEndTime = core.hrTime(); this._addFinalSpanAttributes(span, response); if (this._semconvStability & SemconvStability.STABLE) { // https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md#status if (response.status >= 400) { span.setStatus({ code: SpanStatusCode.ERROR }); span.setAttribute(ATTR_ERROR_TYPE, String(response.status)); } } setTimeout(() => { spanData.observer?.disconnect(); this._findResourceAndAddNetworkEvents(span, spanData, performanceEndTime); this._tasksCount--; this._clearResources(); span.end(endTime); }, OBSERVER_WAIT_TIME_MS); } /** * Patches the constructor of fetch */ _patchConstructor() { return original => { const plugin = this; return function patchConstructor(...args) { if (!plugin._isEnabled) { return original.apply(this, args); } const self = this; const url = web.parseUrl(args[0] instanceof Request ? args[0].url : String(args[0])).href; // Per the Fetch spec, when fetch() is called with a Request object // and a separate init object, the init properties override the // Request's properties. Merge them into a new Request so that // downstream consumers (hooks, header injection, the actual fetch // call) see the correct final values. // See: https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#parameters let options; if (args[0] instanceof Request) { options = args[1] != null ? new Request(args[0], args[1]) : args[0]; } else { options = args[1] || {}; } const createdSpan = plugin._createSpan(url, options); if (!createdSpan) { return original.apply(this, args); } const spanData = plugin._prepareSpanData(url); if (plugin.getConfig().measureRequestSize) { getFetchBodyLength(...args) .then(bodyLength => { if (!bodyLength) return; if (plugin._semconvStability & SemconvStability.OLD) { createdSpan.setAttribute(ATTR_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED, bodyLength); } if (plugin._semconvStability & SemconvStability.STABLE) { createdSpan.setAttribute(ATTR_HTTP_REQUEST_BODY_SIZE, bodyLength); } }) .catch(error => { plugin._diag.warn('getFetchBodyLength', error); }); } function endSpanOnError(span, error) { plugin._applyAttributesAfterFetch(span, options, error); plugin._endSpan(span, spanData, { status: error.status || 0, statusText: error.message, url, }); } function endSpanOnSuccess(span, response) { plugin._applyAttributesAfterFetch(span, options, response); if (response.status >= 200 && response.status < 400) { plugin._endSpan(span, spanData, response); } else { plugin._endSpan(span, spanData, { status: response.status, statusText: response.statusText, url, }); } } function onSuccess(span, response) { try { // Clone the response and eagerly consume the clone to detect // when the body transfer completes. The original response is // returned to the caller untouched so that it passes internal // brand-checks required by APIs such as // WebAssembly.compileStreaming. // TODO: Switch to a consumer-driven model and drop `resClone`. // Keeping eager consumption here to preserve current behavior and avoid breaking existing tests. // Context: discussion in PR #5894 → https://github.com/open-telemetry/opentelemetry-js/pull/5894 const resClone = response.clone(); const body = resClone.body; if (body) { const reader = body.getReader(); const read = () => { reader.read().then(({ done }) => { if (done) { endSpanOnSuccess(span, response); } else { read(); } }, error => { endSpanOnError(span, error); }); }; read(); } else { // some older browsers don't have .body implemented endSpanOnSuccess(span, response); } } catch (error) { // Setup failed (e.g. clone() or getReader() threw). // End the span and clean up so _tasksCount doesn't leak. plugin._diag.error('Failed to read fetch response body', error); plugin._endSpan(span, spanData, { status: 0, url, }); } return response; } function onError(span, error) { try { endSpanOnError(span, error); } catch (e) { // endSpanOnError failed — fall back to ending the span // directly so _tasksCount doesn't leak. plugin._diag.error('Failed to end span on fetch error', e); plugin._endSpan(span, spanData, { status: error.status || 0, url, }); } // eslint-disable-next-line @typescript-eslint/only-throw-error throw error; } return context.with(trace.setSpan(context.active(), createdSpan), () => { // Call request hook before injection so hooks cannot tamper with propagation headers. // Also, this means the hook will see `options.headers` in the same type as passed in, // rather than as a `Headers` instance set by `_addHeaders()`. plugin._callRequestHook(createdSpan, options); plugin._addHeaders(options, url); plugin._tasksCount++; return original .apply(self, options instanceof Request ? [options] : [url, options]) .then(onSuccess.bind(self, createdSpan), onError.bind(self, createdSpan)); }); }; }; } _applyAttributesAfterFetch(span, request, result) { const applyCustomAttributesOnSpan = this.getConfig().applyCustomAttributesOnSpan; if (applyCustomAttributesOnSpan) { safeExecuteInTheMiddle(() => applyCustomAttributesOnSpan(span, request, result), error => { if (!error) { return; } this._diag.error('applyCustomAttributesOnSpan', error); }, true); } } _callRequestHook(span, request) { const requestHook = this.getConfig().requestHook; if (requestHook) { safeExecuteInTheMiddle(() => requestHook(span, request), error => { if (!error) { return; } this._diag.error('requestHook', error); }, true); } } /** * Prepares a span data - needed later for matching appropriate network * resources * @param spanUrl */ _prepareSpanData(spanUrl) { const startTime = core.hrTime(); const entries = []; if (typeof PerformanceObserver !== 'function') { return { entries, startTime, spanUrl }; } const observer = new PerformanceObserver(list => { const perfObsEntries = list.getEntries(); perfObsEntries.forEach(entry => { if (entry.initiatorType === 'fetch' && entry.name === spanUrl) { entries.push(entry); } }); }); observer.observe({ entryTypes: ['resource'], }); return { entries, observer, startTime, spanUrl }; } /** * implements enable function */ enable() { if (!hasBrowserPerformanceAPI) { this._diag.warn('this instrumentation is intended for web usage only, it does not instrument server-side fetch()'); return; } if (this._isEnabled) { return; } if (this._isFetchPatched) { this._diag.debug('fetch constructor already patched'); this._isEnabled = true; return; } try { // `_wrap` throws if a third-party script has locked globalThis.fetch via // Object.defineProperty(window, 'fetch', { writable: false, ... }). this._wrap(globalThis, 'fetch', this._patchConstructor()); this._isFetchPatched = true; this._isEnabled = true; } catch (err) { this._diag.warn('Failed to patch globalThis.fetch; instrumentation will not be enabled. ' + 'Another script may have locked globalThis.fetch via Object.defineProperty.', err); } } /** * deactivates fetch instrumentation */ disable() { if (!hasBrowserPerformanceAPI) { return; } if (!this._isEnabled) { return; } this._isEnabled = false; this._usedResources = new WeakSet(); } } //# sourceMappingURL=fetch.js.map