UNPKG

@dash0/sdk-web

Version:

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

210 lines (209 loc) 9.36 kB
import { debug, observeResourcePerformance, win } 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 { isSameOrigin, wrap, parseUrl } from "../../utils"; 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 response = await original(input instanceof Request ? request : input, copyOfInit); addResponseData(span, response); // We use a separate promise here because this needs to happen in parallel to application code consuming the response waitForFullResponse(response) .then(() => performanceObserver.end()) .catch((e) => { performanceObserver.cancel(); endSpanOnError(span, e); }); return response; } 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 { 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)); } function waitForFullResponse(response) { return new Promise((resolve) => { const clonedResponse = response.clone(); const body = clonedResponse.body; if (!body) return resolve(); const reader = body.getReader(); const read = async () => { const { done } = await reader.read(); if (done) return resolve(); return read(); }; return read(); }); } 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); } } }