UNPKG

@dash0/sdk-web

Version:

Dash0's Web SDK to collect telemetry from end-users' web browsers

272 lines (271 loc) 12.1 kB
import { debug, observeResourcePerformance, perf, win, setTimeout, isSameOrigin, wrap, parseUrl, clearTimeout, } from "../../utils"; import { isUrlIgnored, matchesAny } from "../../utils/ignore-rules"; import { addAttribute, setSpanStatus, addW3CTraceContextHttpHeaders, addXRayTraceContextHttpHeaders, endSpan, errorToSpanStatus, recordException, startSpan, } from "../../utils/otel"; import { ERROR_TYPE, HTTP_REQUEST_METHOD, HTTP_REQUEST_METHOD_ORIGINAL, HTTP_RESPONSE_STATUS_CODE, SPAN_STATUS_ERROR, SPAN_STATUS_UNSET, } from "../../semantic-conventions"; import { vars } from "../../vars"; import { httpRequestHeaderKey, httpResponseHeaderKey } from "../../utils/otel/http"; import { sendSpan } from "../../transport"; import { addResourceNetworkEvents, addResourceSize, HTTP_METHOD_OTHER, isWellKnownHttpMethod } from "./utils"; import { addCommonAttributes, addUrlAttributes } from "../../attributes"; export function instrumentFetch() { if (!win || !win.fetch || !win.Request) { debug("Browser does not support the Fetch API, skipping instrumentation"); return; } wrap(win, "fetch", wrapFetch); } // eslint-disable-next-line no-restricted-globals -- only used as type here function wrapFetch(original) { return async function fetchWithInstrumentation(input, init) { let copyOfInit = init ? Object.assign({}, init) : init; let body = null; if (copyOfInit?.body) { body = copyOfInit.body; copyOfInit.body = undefined; } const request = new Request(input, copyOfInit); if (body && copyOfInit) { copyOfInit.body = body; } const url = request.url; if (isUrlIgnored(url)) { debug(`Not creating span for fetch call because the url is ignored, URL: ${url}`); return original(input instanceof Request ? request : input, init); } // https://fetch.spec.whatwg.org/#concept-request-method // We'll match methods case insensitive here to make the user experience a bit less painful const originalMethod = request.method ?? "GET"; const isWellKnownMethod = isWellKnownHttpMethod(originalMethod); const isWellKnownMethodMatchingLeniently = isWellKnownHttpMethod(originalMethod.toUpperCase()); const method = isWellKnownMethodMatchingLeniently ? originalMethod.toUpperCase() : HTTP_METHOD_OTHER; const span = startSpan(`HTTP ${method}`); addCommonAttributes(span.attributes); addUrlAttributes(span.attributes, url); addGraphQlProperties(input, init, span); addAttribute(span.attributes, HTTP_REQUEST_METHOD, method); if (!isWellKnownMethod) { addAttribute(span.attributes, HTTP_REQUEST_METHOD_ORIGINAL, originalMethod); } const propagatorTypes = determinePropagatorTypes(url); const shouldSetCorrelationHeaders = propagatorTypes.length > 0; if (shouldSetCorrelationHeaders) { if (copyOfInit?.headers) { // ensure we have a unified container for the headers copyOfInit.headers = new Headers(copyOfInit.headers); addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes); } else if (input instanceof Request) { addTraceContextHttpHeaders(request.headers.append, request.headers, span, propagatorTypes); } else { if (!copyOfInit) { copyOfInit = {}; } copyOfInit.headers = new Headers(); addTraceContextHttpHeaders(copyOfInit.headers.append, copyOfInit.headers, span, propagatorTypes); } } tryCaptureHttpHeaders(request.headers, span, (k) => httpRequestHeaderKey(k)); const performanceObserver = observeResourcePerformance({ // We match on both fetch and XHR here to support polyfills resourceMatcher: ({ initiatorType, name }) => (initiatorType === "fetch" || initiatorType === "xmlhttprequest") && name === parseUrl(url).href, maxWaitForResourceMillis: vars.maxWaitForResourceTimingsMillis, maxToleranceForResourceTimingsMillis: vars.maxToleranceForResourceTimingsMillis, onEnd: ({ duration, resource }) => { if (resource) { addResourceNetworkEvents(span, resource); addResourceSize(span, resource); } // duration is millis we need to convert to nanos sendSpan(endSpan(span, undefined, duration * 1000000)); }, }); performanceObserver.start(); try { const origResponse = await original(input instanceof Request ? request : input, copyOfInit); addResponseData(span, origResponse); return wrapResponse(origResponse, vars.maxToleranceForResourceTimingsMillis, () => performanceObserver.end(), (e) => { performanceObserver.cancel(); endSpanOnError(span, e); }); } catch (e) { performanceObserver.cancel(); endSpanOnError(span, e); throw e; } }; } // @ts-expect-error -- WIP // eslint-disable-next-line @typescript-eslint/no-unused-vars -- WIP function addGraphQlProperties(input, init, span) { try { if (!isGraphQLQuery(input, init)) return; } catch (e) { debug("failed to analyze request for GraphQL insights", e, input, init); } } // eslint-disable-next-line @typescript-eslint/no-unused-vars -- WIP function isGraphQLQuery(input, init) { /** * TODO: Add GraphQL support. * GraphQL queries are either POST or GET requests. * Identified by either setting Accept=application/graphql-response+json or the url matching a config field for graphql urls. * See: https://graphql.github.io/graphql-over-http/draft/ * GET requests are queries carrying a query search param * POST requests are either queries or mutations, See: https://graphql.org/learn/serving-over-http/#post-request-and-body * and https://github.com/instana/weasel/blob/main/lib/hooks/Fetch.ts#L201 */ return false; } function tryCaptureHttpHeaders(headers, span, getAttributeKey) { try { if (!vars.headersToCapture.length) return; headers.forEach((value, key) => { if (vars.headersToCapture.some((rxp) => rxp.test(key))) { addAttribute(span.attributes, getAttributeKey(key), value); } }); } catch (_e) { debug("unable to capture http headers due to CORS policy"); } } function addResponseData(span, response) { const status = response.status; setSpanStatus(span, status >= 200 && status < 400 ? SPAN_STATUS_UNSET : SPAN_STATUS_ERROR); if (status === 0) { addAttribute(span.attributes, ERROR_TYPE, response.type); } addAttribute(span.attributes, HTTP_RESPONSE_STATUS_CODE, String(status)); tryCaptureHttpHeaders(response.headers, span, (k) => httpResponseHeaderKey(k)); } /** * Wraps the response to be able to detect when it is fully read * @param originalResponse * @param readTimeoutMs Timeout applied between reading of response chunks, if exceeded the response is considered abandoned and onDone is called. * @param onDone Called when the response is completely read or with "fallbackEndTs" when reading timed out. * @param onError */ function wrapResponse(originalResponse, readTimeoutMs, onDone, onError) { // When the response was wrapped (i.e. first available to js) we use this to replace or find the actual end timestamp // in case the response body is never read by js let fallbackTs = perf.now(); const body = originalResponse.body; // For some reason browsers return a response body on responses that can't be constructed with one. In that case we can't wrap the body stream if (!body || !responseCanHaveBody(originalResponse)) { onDone(); return originalResponse; } let cbCalled = false; const handleDone = (fallbackEndTs) => { if (cbCalled) return; onDone(fallbackEndTs); cbCalled = true; }; const handleError = (e) => { if (cbCalled) return; onError(e); cbCalled = true; }; let bodyNeverCompletelyReadTimeout = setTimeout(() => handleDone(fallbackTs), readTimeoutMs); const reader = body.getReader(); const stream = new ReadableStream({ async pull(controller) { try { clearTimeout(bodyNeverCompletelyReadTimeout); const { value, done } = await reader.read(); if (done) { reader.releaseLock(); controller.close(); handleDone(); } else { fallbackTs = perf.now(); bodyNeverCompletelyReadTimeout = setTimeout(() => handleDone(fallbackTs), readTimeoutMs); controller.enqueue(value); } } catch (e) { handleError(e); controller.error(e); try { reader.releaseLock(); } catch { // Spec reference: // https://streams.spec.whatwg.org/#default-reader-release-lock // // releaseLock() only throws if called on an invalid reader // (i.e. reader.[[stream]] is undefined, meaning the lock is already released // or the reader was never associated). In normal use this cannot happen. // This catch is defensive only. } } }, cancel(reason) { clearTimeout(bodyNeverCompletelyReadTimeout); handleDone(); return reader.cancel(reason); }, }); return new Response(stream, { status: originalResponse.status, statusText: originalResponse.statusText, headers: originalResponse.headers, }); } function endSpanOnError(span, error) { recordException(span, error); sendSpan(endSpan(span, errorToSpanStatus(error), undefined)); } function determinePropagatorTypes(url) { const matchingTypes = []; const isUrlSameOrigin = isSameOrigin(url); // For same-origin requests, always include traceparent + all configured propagators if (isUrlSameOrigin) { // Always add traceparent for same-origin requests matchingTypes.push("traceparent"); // Add all other configured propagator types for same-origin requests if (vars.propagators) { for (const propagator of vars.propagators) { if (propagator.type !== "traceparent" && !matchingTypes.includes(propagator.type)) { matchingTypes.push(propagator.type); } } } return matchingTypes; } // For cross-origin requests, use new propagators config if available if (vars.propagators) { for (const propagator of vars.propagators) { if (matchesAny(propagator.match, url)) { // Avoid duplicates if (!matchingTypes.includes(propagator.type)) { matchingTypes.push(propagator.type); } } } return matchingTypes; } return []; } function addTraceContextHttpHeaders(fn, ctx, span, types) { for (const type of types) { if (type === "xray") { addXRayTraceContextHttpHeaders(fn, ctx, span); } else { addW3CTraceContextHttpHeaders(fn, ctx, span); } } } function responseCanHaveBody(response) { const status = response.status; return status >= 200 && status != 204 && status != 205 && status != 304; }