UNPKG

@sentry/browser

Version:
359 lines (313 loc) 12.8 kB
import { addXhrInstrumentationHandler, addPerformanceInstrumentationHandler, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; import { addFetchEndInstrumentationHandler, addFetchInstrumentationHandler, instrumentFetchRequest, parseUrl, spanToJSON, browserPerformanceTimeOrigin, hasTracingEnabled, setHttpStatus, getActiveSpan, startInactiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_OP, SentryNonRecordingSpan, getTraceData, stringMatchesSomePattern } from '@sentry/core'; import { WINDOW } from '../helpers.js'; /** Options for Request Instrumentation */ const responseToSpanId = new WeakMap(); const spanIdToEndTimestamp = new Map(); const defaultRequestInstrumentationOptions = { traceFetch: true, traceXHR: true, enableHTTPTimings: true, trackFetchStreamPerformance: false, }; /** Registers span creators for xhr and fetch requests */ function instrumentOutgoingRequests(client, _options) { const { traceFetch, traceXHR, trackFetchStreamPerformance, shouldCreateSpanForRequest, enableHTTPTimings, tracePropagationTargets, } = { traceFetch: defaultRequestInstrumentationOptions.traceFetch, traceXHR: defaultRequestInstrumentationOptions.traceXHR, trackFetchStreamPerformance: defaultRequestInstrumentationOptions.trackFetchStreamPerformance, ..._options, }; const shouldCreateSpan = typeof shouldCreateSpanForRequest === 'function' ? shouldCreateSpanForRequest : (_) => true; const shouldAttachHeadersWithTargets = (url) => shouldAttachHeaders(url, tracePropagationTargets); const spans = {}; if (traceFetch) { // Keeping track of http requests, whose body payloads resolved later than the initial resolved request // e.g. streaming using server sent events (SSE) client.addEventProcessor(event => { if (event.type === 'transaction' && event.spans) { event.spans.forEach(span => { if (span.op === 'http.client') { const updatedTimestamp = spanIdToEndTimestamp.get(span.span_id); if (updatedTimestamp) { span.timestamp = updatedTimestamp / 1000; spanIdToEndTimestamp.delete(span.span_id); } } }); } return event; }); if (trackFetchStreamPerformance) { addFetchEndInstrumentationHandler(handlerData => { if (handlerData.response) { const span = responseToSpanId.get(handlerData.response); if (span && handlerData.endTimestamp) { spanIdToEndTimestamp.set(span, handlerData.endTimestamp); } } }); } addFetchInstrumentationHandler(handlerData => { const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); if (handlerData.response && handlerData.fetchData.__span) { responseToSpanId.set(handlerData.response, handlerData.fetchData.__span); } // We cannot use `window.location` in the generic fetch instrumentation, // but we need it for reliable `server.address` attribute. // so we extend this in here if (createdSpan) { const fullUrl = getFullURL(handlerData.fetchData.url); const host = fullUrl ? parseUrl(fullUrl).host : undefined; createdSpan.setAttributes({ 'http.url': fullUrl, 'server.address': host, }); } if (enableHTTPTimings && createdSpan) { addHTTPTimings(createdSpan); } }); } if (traceXHR) { addXhrInstrumentationHandler(handlerData => { const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans); if (enableHTTPTimings && createdSpan) { addHTTPTimings(createdSpan); } }); } } function isPerformanceResourceTiming(entry) { return ( entry.entryType === 'resource' && 'initiatorType' in entry && typeof (entry ).nextHopProtocol === 'string' && (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') ); } /** * Creates a temporary observer to listen to the next fetch/xhr resourcing timings, * so that when timings hit their per-browser limit they don't need to be removed. * * @param span A span that has yet to be finished, must contain `url` on data. */ function addHTTPTimings(span) { const { url } = spanToJSON(span).data || {}; if (!url || typeof url !== 'string') { return; } const cleanup = addPerformanceInstrumentationHandler('resource', ({ entries }) => { entries.forEach(entry => { if (isPerformanceResourceTiming(entry) && entry.name.endsWith(url)) { const spanData = resourceTimingEntryToSpanData(entry); spanData.forEach(data => span.setAttribute(...data)); // In the next tick, clean this handler up // We have to wait here because otherwise this cleans itself up before it is fully done setTimeout(cleanup); } }); }); } /** * Converts ALPN protocol ids to name and version. * * (https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids) * @param nextHopProtocol PerformanceResourceTiming.nextHopProtocol */ function extractNetworkProtocol(nextHopProtocol) { let name = 'unknown'; let version = 'unknown'; let _name = ''; for (const char of nextHopProtocol) { // http/1.1 etc. if (char === '/') { [name, version] = nextHopProtocol.split('/') ; break; } // h2, h3 etc. if (!isNaN(Number(char))) { name = _name === 'h' ? 'http' : _name; version = nextHopProtocol.split(_name)[1] ; break; } _name += char; } if (_name === nextHopProtocol) { // webrtc, ftp, etc. name = _name; } return { name, version }; } function getAbsoluteTime(time = 0) { return ((browserPerformanceTimeOrigin || performance.timeOrigin) + time) / 1000; } function resourceTimingEntryToSpanData(resourceTiming) { const { name, version } = extractNetworkProtocol(resourceTiming.nextHopProtocol); const timingSpanData = []; timingSpanData.push(['network.protocol.version', version], ['network.protocol.name', name]); if (!browserPerformanceTimeOrigin) { return timingSpanData; } return [ ...timingSpanData, ['http.request.redirect_start', getAbsoluteTime(resourceTiming.redirectStart)], ['http.request.fetch_start', getAbsoluteTime(resourceTiming.fetchStart)], ['http.request.domain_lookup_start', getAbsoluteTime(resourceTiming.domainLookupStart)], ['http.request.domain_lookup_end', getAbsoluteTime(resourceTiming.domainLookupEnd)], ['http.request.connect_start', getAbsoluteTime(resourceTiming.connectStart)], ['http.request.secure_connection_start', getAbsoluteTime(resourceTiming.secureConnectionStart)], ['http.request.connection_end', getAbsoluteTime(resourceTiming.connectEnd)], ['http.request.request_start', getAbsoluteTime(resourceTiming.requestStart)], ['http.request.response_start', getAbsoluteTime(resourceTiming.responseStart)], ['http.request.response_end', getAbsoluteTime(resourceTiming.responseEnd)], ]; } /** * A function that determines whether to attach tracing headers to a request. * We only export this function for testing purposes. */ function shouldAttachHeaders( targetUrl, tracePropagationTargets, ) { // window.location.href not being defined is an edge case in the browser but we need to handle it. // Potentially dangerous situations where it may not be defined: Browser Extensions, Web Workers, patching of the location obj const href = WINDOW.location && WINDOW.location.href; if (!href) { // If there is no window.location.origin, we default to only attaching tracing headers to relative requests, i.e. ones that start with `/` // BIG DISCLAIMER: Users can call URLs with a double slash (fetch("//example.com/api")), this is a shorthand for "send to the same protocol", // so we need a to exclude those requests, because they might be cross origin. const isRelativeSameOriginRequest = !!targetUrl.match(/^\/(?!\/)/); if (!tracePropagationTargets) { return isRelativeSameOriginRequest; } else { return stringMatchesSomePattern(targetUrl, tracePropagationTargets); } } else { let resolvedUrl; let currentOrigin; // URL parsing may fail, we default to not attaching trace headers in that case. try { resolvedUrl = new URL(targetUrl, href); currentOrigin = new URL(href).origin; } catch (e) { return false; } const isSameOriginRequest = resolvedUrl.origin === currentOrigin; if (!tracePropagationTargets) { return isSameOriginRequest; } else { return ( stringMatchesSomePattern(resolvedUrl.toString(), tracePropagationTargets) || (isSameOriginRequest && stringMatchesSomePattern(resolvedUrl.pathname, tracePropagationTargets)) ); } } } /** * Create and track xhr request spans * * @returns Span if a span was created, otherwise void. */ function xhrCallback( handlerData, shouldCreateSpan, shouldAttachHeaders, spans, ) { const xhr = handlerData.xhr; const sentryXhrData = xhr && xhr[SENTRY_XHR_DATA_KEY]; if (!xhr || xhr.__sentry_own_request__ || !sentryXhrData) { return undefined; } const shouldCreateSpanResult = hasTracingEnabled() && shouldCreateSpan(sentryXhrData.url); // check first if the request has finished and is tracked by an existing span which should now end if (handlerData.endTimestamp && shouldCreateSpanResult) { const spanId = xhr.__sentry_xhr_span_id__; if (!spanId) return; const span = spans[spanId]; if (span && sentryXhrData.status_code !== undefined) { setHttpStatus(span, sentryXhrData.status_code); span.end(); // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } return undefined; } const fullUrl = getFullURL(sentryXhrData.url); const host = fullUrl ? parseUrl(fullUrl).host : undefined; const hasParent = !!getActiveSpan(); const span = shouldCreateSpanResult && hasParent ? startInactiveSpan({ name: `${sentryXhrData.method} ${sentryXhrData.url}`, attributes: { type: 'xhr', 'http.method': sentryXhrData.method, 'http.url': fullUrl, url: sentryXhrData.url, 'server.address': host, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', }, }) : new SentryNonRecordingSpan(); xhr.__sentry_xhr_span_id__ = span.spanContext().spanId; spans[xhr.__sentry_xhr_span_id__] = span; if (shouldAttachHeaders(sentryXhrData.url)) { addTracingHeadersToXhrRequest( xhr, // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred hasTracingEnabled() && hasParent ? span : undefined, ); } return span; } function addTracingHeadersToXhrRequest(xhr, span) { const { 'sentry-trace': sentryTrace, baggage } = getTraceData({ span }); if (sentryTrace) { setHeaderOnXhr(xhr, sentryTrace, baggage); } } function setHeaderOnXhr( xhr, sentryTraceHeader, sentryBaggageHeader, ) { try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion xhr.setRequestHeader('sentry-trace', sentryTraceHeader); if (sentryBaggageHeader) { // From MDN: "If this method is called several times with the same header, the values are merged into one single request header." // We can therefore simply set a baggage header without checking what was there before // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader // eslint-disable-next-line @typescript-eslint/no-non-null-assertion xhr.setRequestHeader('baggage', sentryBaggageHeader); } } catch (_) { // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. } } function getFullURL(url) { try { // By adding a base URL to new URL(), this will also work for relative urls // If `url` is a full URL, the base URL is ignored anyhow const parsed = new URL(url, WINDOW.location.origin); return parsed.href; } catch (e2) { return undefined; } } export { defaultRequestInstrumentationOptions, extractNetworkProtocol, instrumentOutgoingRequests, shouldAttachHeaders, xhrCallback }; //# sourceMappingURL=request.js.map