@opentelemetry/instrumentation-fetch
Version:
OpenTelemetry instrumentation for fetch http client in web browsers
416 lines • 18.1 kB
JavaScript
/*
* 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 * 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 '../src/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';
import { _globalThis } from '@opentelemetry/core';
// 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 isNode = typeof process === 'object' && process.release?.name === 'node';
/**
* 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],
}, api.trace.setSpan(api.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 = {};
api.propagation.inject(api.context.active(), headers);
if (Object.keys(headers).length > 0) {
this._diag.debug('headers inject skipped due to CORS policy');
}
return;
}
if (options instanceof Request) {
api.propagation.inject(api.context.active(), options.headers, {
set: (h, k, v) => h.set(k, typeof v === 'string' ? v : String(v)),
});
}
else if (options.headers instanceof Headers) {
api.propagation.inject(api.context.active(), options.headers, {
set: (h, k, v) => h.set(k, typeof v === 'string' ? v : String(v)),
});
}
else if (options.headers instanceof Map) {
api.propagation.inject(api.context.active(), options.headers, {
set: (h, k, v) => h.set(k, typeof v === 'string' ? v : String(v)),
});
}
else {
const headers = {};
api.propagation.inject(api.context.active(), 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
*/
_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: api.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: api.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) {
const self = this;
const url = web.parseUrl(args[0] instanceof Request ? args[0].url : String(args[0])).href;
const options = args[0] instanceof Request ? args[0] : 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, resolve, response) {
try {
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);
}
}
finally {
resolve(response);
}
}
function onError(span, reject, error) {
try {
endSpanOnError(span, error);
}
finally {
reject(error);
}
}
return new Promise((resolve, reject) => {
return api.context.with(api.trace.setSpan(api.context.active(), createdSpan), () => {
plugin._addHeaders(options, url);
// Important to execute "_callRequestHook" after "_addHeaders", allowing the consumer code to override the request headers.
plugin._callRequestHook(createdSpan, options);
plugin._tasksCount++;
// TypeScript complains about arrow function captured a this typed as globalThis
// ts(7041)
return original
.apply(self, options instanceof Request ? [options] : [url, options])
.then(onSuccess.bind(self, createdSpan, resolve), onError.bind(self, createdSpan, reject));
});
});
};
};
}
_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 (isNode) {
// Node.js v18+ *does* have a global `fetch()`, but this package does not
// support instrumenting it.
this._diag.warn("this instrumentation is intended for web usage only, it does not instrument Node.js's fetch()");
return;
}
if (isWrapped(fetch)) {
this._unwrap(_globalThis, 'fetch');
this._diag.debug('removing previous patch for constructor');
}
this._wrap(_globalThis, 'fetch', this._patchConstructor());
}
/**
* implements unpatch function
*/
disable() {
if (isNode) {
return;
}
this._unwrap(_globalThis, 'fetch');
this._usedResources = new WeakSet();
}
}
//# sourceMappingURL=fetch.js.map