diff --git a/experimental/packages/opentelemetry-instrumentation-fetch/src/enums/PerformanceTimingNames.ts b/experimental/packages/opentelemetry-instrumentation-fetch/src/enums/PerformanceTimingNames.ts new file mode 100644 index 00000000000..ef22ed8d7bc --- /dev/null +++ b/experimental/packages/opentelemetry-instrumentation-fetch/src/enums/PerformanceTimingNames.ts @@ -0,0 +1,30 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum PerformanceTimingNames { + CONNECT_END = 'connectEnd', + CONNECT_START = 'connectStart', + DECODED_BODY_SIZE = 'decodedBodySize', + DOM_COMPLETE = 'domComplete', + DOM_CONTENT_LOADED_EVENT_END = 'domContentLoadedEventEnd', + DOM_CONTENT_LOADED_EVENT_START = 'domContentLoadedEventStart', + DOM_INTERACTIVE = 'domInteractive', + DOMAIN_LOOKUP_END = 'domainLookupEnd', + DOMAIN_LOOKUP_START = 'domainLookupStart', + ENCODED_BODY_SIZE = 'encodedBodySize', + FETCH_START = 'fetchStart', + LOAD_EVENT_END = 'loadEventEnd', + LOAD_EVENT_START = 'loadEventStart', + NAVIGATION_START = 'navigationStart', + REDIRECT_END = 'redirectEnd', + REDIRECT_START = 'redirectStart', + REQUEST_START = 'requestStart', + RESPONSE_END = 'responseEnd', + RESPONSE_START = 'responseStart', + SECURE_CONNECTION_START = 'secureConnectionStart', + START_TIME = 'startTime', + UNLOAD_EVENT_END = 'unloadEventEnd', + UNLOAD_EVENT_START = 'unloadEventStart', +} diff --git a/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts b/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts index 7c053f24e5e..29dd2bce9eb 100644 --- a/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts +++ b/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts @@ -19,8 +19,8 @@ import { safeExecuteInTheMiddle, } from '@opentelemetry/instrumentation'; import * as core from '@opentelemetry/core'; -import * as web from '@opentelemetry/sdk-trace-web'; import { AttributeNames } from './enums/AttributeNames'; +import { PerformanceTimingNames } from './enums/PerformanceTimingNames'; import { ATTR_HTTP_STATUS_CODE, ATTR_HTTP_HOST, @@ -41,6 +41,13 @@ import { ATTR_URL_FULL, } from '@opentelemetry/semantic-conventions'; import type { FetchError, FetchResponse, SpanData } from './types'; +import type { PropagateTraceHeaderCorsUrls } from './utils'; +import { + addSpanNetworkEvents, + getResource, + parseUrl, + shouldPropagateTraceHeaders, +} from './utils'; import { getFetchBodyLength, normalizeHttpRequestMethod, @@ -79,7 +86,7 @@ export interface FetchInstrumentationConfig extends InstrumentationConfig { // is not available clearTimingResources?: boolean; // urls which should include trace headers when origin doesn't match - propagateTraceHeaderCorsUrls?: web.PropagateTraceHeaderCorsUrls; + propagateTraceHeaderCorsUrls?: PropagateTraceHeaderCorsUrls; /** * URLs that partially match any regex in ignoreUrls will not be traced. * In addition, URLs that are _exact matches_ of strings in ignoreUrls will @@ -141,23 +148,21 @@ export class FetchInstrumentation extends InstrumentationBase 0) { - knownMethods = {}; - cfgMethods.forEach(m => { - knownMethods[m] = true; - }); - } else { - knownMethods = DEFAULT_KNOWN_METHODS; - } + +/** + * Normalize an HTTP request method string per `http.request.method` spec + * https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md#http-client-span + */ +export function normalizeHttpRequestMethod(method: string): string { + const knownMethods = DEFAULT_KNOWN_METHODS; + const methUpper = method.toUpperCase(); + if (methUpper in knownMethods) { + return methUpper; + } else { + return '_OTHER'; } - return knownMethods; } const HTTP_PORT_FROM_PROTOCOL: { [key: string]: string } = { @@ -226,3 +219,411 @@ export function serverPortFromUrl(url: URLLike): number | undefined { return undefined; } } + +export type PropagateTraceHeaderCorsUrls = + | string + | RegExp + | Array; +export type PerformanceEntries = { + [PerformanceTimingNames.CONNECT_END]?: number; + [PerformanceTimingNames.CONNECT_START]?: number; + [PerformanceTimingNames.DECODED_BODY_SIZE]?: number; + [PerformanceTimingNames.DOM_COMPLETE]?: number; + [PerformanceTimingNames.DOM_CONTENT_LOADED_EVENT_END]?: number; + [PerformanceTimingNames.DOM_CONTENT_LOADED_EVENT_START]?: number; + [PerformanceTimingNames.DOM_INTERACTIVE]?: number; + [PerformanceTimingNames.DOMAIN_LOOKUP_END]?: number; + [PerformanceTimingNames.DOMAIN_LOOKUP_START]?: number; + [PerformanceTimingNames.ENCODED_BODY_SIZE]?: number; + [PerformanceTimingNames.FETCH_START]?: number; + [PerformanceTimingNames.LOAD_EVENT_END]?: number; + [PerformanceTimingNames.LOAD_EVENT_START]?: number; + [PerformanceTimingNames.REDIRECT_END]?: number; + [PerformanceTimingNames.REDIRECT_START]?: number; + [PerformanceTimingNames.REQUEST_START]?: number; + [PerformanceTimingNames.RESPONSE_END]?: number; + [PerformanceTimingNames.RESPONSE_START]?: number; + [PerformanceTimingNames.SECURE_CONNECTION_START]?: number; + [PerformanceTimingNames.START_TIME]?: number; + [PerformanceTimingNames.UNLOAD_EVENT_END]?: number; + [PerformanceTimingNames.UNLOAD_EVENT_START]?: number; +}; + +/** + * Helper function for adding network events and content length attributes. + */ +export function addSpanNetworkEvents( + span: Span, + resource: PerformanceEntries, + ignoreNetworkEvents = false, + ignoreZeros?: boolean, + skipOldSemconvContentLengthAttrs?: boolean +): void { + if (ignoreZeros === undefined) { + ignoreZeros = resource[PerformanceTimingNames.START_TIME] !== 0; + } + + if (!ignoreNetworkEvents) { + addSpanNetworkEvent( + span, + PerformanceTimingNames.FETCH_START, + resource, + ignoreZeros + ); + addSpanNetworkEvent( + span, + PerformanceTimingNames.DOMAIN_LOOKUP_START, + resource, + ignoreZeros + ); + addSpanNetworkEvent( + span, + PerformanceTimingNames.DOMAIN_LOOKUP_END, + resource, + ignoreZeros + ); + addSpanNetworkEvent( + span, + PerformanceTimingNames.CONNECT_START, + resource, + ignoreZeros + ); + addSpanNetworkEvent( + span, + PerformanceTimingNames.SECURE_CONNECTION_START, + resource, + ignoreZeros + ); + addSpanNetworkEvent( + span, + PerformanceTimingNames.CONNECT_END, + resource, + ignoreZeros + ); + addSpanNetworkEvent( + span, + PerformanceTimingNames.REQUEST_START, + resource, + ignoreZeros + ); + addSpanNetworkEvent( + span, + PerformanceTimingNames.RESPONSE_START, + resource, + ignoreZeros + ); + addSpanNetworkEvent( + span, + PerformanceTimingNames.RESPONSE_END, + resource, + ignoreZeros + ); + } + + if (!skipOldSemconvContentLengthAttrs) { + // This block adds content-length-related span attributes using the + // *old* HTTP semconv (v1.7.0). + const encodedLength = resource[PerformanceTimingNames.ENCODED_BODY_SIZE]; + if (encodedLength !== undefined) { + span.setAttribute(ATTR_HTTP_RESPONSE_CONTENT_LENGTH, encodedLength); + } + + const decodedLength = resource[PerformanceTimingNames.DECODED_BODY_SIZE]; + // Spec: Not set if transport encoding not used (in which case encoded and decoded sizes match) + if (decodedLength !== undefined && encodedLength !== decodedLength) { + span.setAttribute( + ATTR_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED, + decodedLength + ); + } + } +} + +/** + * Helper function to be able to use enum as typed key in type and in interface when using forEach + * @param obj + * @param key + */ +export function hasKey( + obj: O, + key: PropertyKey +): key is keyof O { + return key in obj; +} + +/** + * Helper function for starting an event on span based on {@link PerformanceEntries} + * @param span + * @param performanceName name of performance entry for time start + * @param entries + * @param ignoreZeros + */ +export function addSpanNetworkEvent( + span: Span, + performanceName: string, + entries: PerformanceEntries, + ignoreZeros = true +): Span | undefined { + if ( + hasKey(entries, performanceName) && + typeof entries[performanceName] === 'number' && + !(ignoreZeros && entries[performanceName] === 0) + ) { + return span.addEvent(performanceName, entries[performanceName]); + } + + return undefined; +} + +/** + * The URLLike interface represents an URL and HTMLAnchorElement compatible fields. + */ +export interface URLLike { + hash: string; + host: string; + hostname: string; + href: string; + readonly origin: string; + password: string; + pathname: string; + port: string; + protocol: string; + search: string; + username: string; +} + +let urlNormalizingAnchor: HTMLAnchorElement | undefined; +/** + * Parses url using URL constructor or fallback to anchor element. + * @param url + */ +export function parseUrl(url: string): URLLike { + if (typeof URL === 'function') { + return new URL( + url, + typeof document !== 'undefined' + ? document.baseURI + : typeof location !== 'undefined' // Some JS runtimes (e.g. Deno) don't define this + ? location.href + : undefined + ); + } + + if (!urlNormalizingAnchor) { + urlNormalizingAnchor = document.createElement('a'); + } + urlNormalizingAnchor.href = url; + return urlNormalizingAnchor; +} + +/** + * Checks if trace headers should be propagated + * @param spanUrl + * @private + */ +export function shouldPropagateTraceHeaders( + spanUrl: string, + propagateTraceHeaderCorsUrls?: PropagateTraceHeaderCorsUrls +): boolean { + let propagateTraceHeaderUrls = propagateTraceHeaderCorsUrls || []; + if ( + typeof propagateTraceHeaderUrls === 'string' || + propagateTraceHeaderUrls instanceof RegExp + ) { + propagateTraceHeaderUrls = [propagateTraceHeaderUrls]; + } + const parsedSpanUrl = parseUrl(spanUrl); + + if (parsedSpanUrl.origin === location?.origin) { + return true; + } else { + return propagateTraceHeaderUrls.some(propagateTraceHeaderUrl => + urlMatches(spanUrl, propagateTraceHeaderUrl) + ); + } +} + +/** + * This interface is used in {@link getResource} function to return + * main request and it's corresponding PreFlight request + */ +export interface PerformanceResourceTimingInfo { + corsPreFlightRequest?: PerformanceResourceTiming; + mainRequest?: PerformanceResourceTiming; +} + +/** + * Filter all resources that has started and finished according to span start time and end time. + * It will return the closest resource to a start time + * @param spanUrl + * @param startTimeHR + * @param endTimeHR + * @param resources + * @param ignoredResources + */ +function filterResourcesForSpan( + spanUrl: string, + startTimeHR: HrTime, + endTimeHR: HrTime, + resources: PerformanceResourceTiming[], + ignoredResources: WeakSet, + initiatorType?: string +) { + const startTime = hrTimeToNanoseconds(startTimeHR); + const endTime = hrTimeToNanoseconds(endTimeHR); + let filteredResources = resources.filter(resource => { + const resourceStartTime = hrTimeToNanoseconds( + timeInputToHrTime(resource[PerformanceTimingNames.FETCH_START]) + ); + const resourceEndTime = hrTimeToNanoseconds( + timeInputToHrTime(resource[PerformanceTimingNames.RESPONSE_END]) + ); + + return ( + resource.initiatorType.toLowerCase() === + (initiatorType || 'xmlhttprequest') && + resource.name === spanUrl && + resourceStartTime >= startTime && + resourceEndTime <= endTime + ); + }); + + if (filteredResources.length > 0) { + filteredResources = filteredResources.filter(resource => { + return !ignoredResources.has(resource); + }); + } + + return filteredResources; +} + +/** + * sort resources by startTime + * @param filteredResources + */ +function sortResources( + filteredResources: PerformanceResourceTiming[] +): PerformanceResourceTiming[] { + return filteredResources.slice().sort((a, b) => { + const valueA = a[PerformanceTimingNames.FETCH_START]; + const valueB = b[PerformanceTimingNames.FETCH_START]; + if (valueA > valueB) { + return 1; + } else if (valueA < valueB) { + return -1; + } + return 0; + }); +} + +/** + * Will find the main request skipping the cors pre flight requests + * @param resources + * @param corsPreFlightRequestEndTime + * @param spanEndTimeHR + */ +function findMainRequest( + resources: PerformanceResourceTiming[], + corsPreFlightRequestEndTime: number, + spanEndTimeHR: HrTime +): PerformanceResourceTiming { + const spanEndTime = hrTimeToNanoseconds(spanEndTimeHR); + const minTime = hrTimeToNanoseconds( + timeInputToHrTime(corsPreFlightRequestEndTime) + ); + + let mainRequest: PerformanceResourceTiming = resources[1]; + let bestGap; + + const length = resources.length; + for (let i = 1; i < length; i++) { + const resource = resources[i]; + const resourceStartTime = hrTimeToNanoseconds( + timeInputToHrTime(resource[PerformanceTimingNames.FETCH_START]) + ); + + const resourceEndTime = hrTimeToNanoseconds( + timeInputToHrTime(resource[PerformanceTimingNames.RESPONSE_END]) + ); + + const currentGap = spanEndTime - resourceEndTime; + + if (resourceStartTime >= minTime && (!bestGap || currentGap < bestGap)) { + bestGap = currentGap; + mainRequest = resource; + } + } + return mainRequest; +} + +/** + * Get closest performance resource ignoring the resources that have been + * already used. + * @param spanUrl + * @param startTimeHR + * @param endTimeHR + * @param resources + * @param ignoredResources + * @param initiatorType + */ +export function getResource( + spanUrl: string, + startTimeHR: HrTime, + endTimeHR: HrTime, + resources: PerformanceResourceTiming[], + ignoredResources: WeakSet = new WeakSet(), + initiatorType?: string +): PerformanceResourceTimingInfo { + // de-relativize the URL before usage (does no harm to absolute URLs) + const parsedSpanUrl = parseUrl(spanUrl); + spanUrl = parsedSpanUrl.toString(); + + const filteredResources = filterResourcesForSpan( + spanUrl, + startTimeHR, + endTimeHR, + resources, + ignoredResources, + initiatorType + ); + + if (filteredResources.length === 0) { + return { + mainRequest: undefined, + }; + } + if (filteredResources.length === 1) { + return { + mainRequest: filteredResources[0], + }; + } + const sorted = sortResources(filteredResources); + + if (parsedSpanUrl.origin !== location?.origin && sorted.length > 1) { + let corsPreFlightRequest: PerformanceResourceTiming | undefined = sorted[0]; + let mainRequest: PerformanceResourceTiming = findMainRequest( + sorted, + corsPreFlightRequest[PerformanceTimingNames.RESPONSE_END], + endTimeHR + ); + + const responseEnd = + corsPreFlightRequest[PerformanceTimingNames.RESPONSE_END]; + const fetchStart = mainRequest[PerformanceTimingNames.FETCH_START]; + + // no corsPreFlightRequest + if (fetchStart < responseEnd) { + mainRequest = corsPreFlightRequest; + corsPreFlightRequest = undefined; + } + + return { + corsPreFlightRequest, + mainRequest, + }; + } else { + return { + mainRequest: filteredResources[0], + }; + } +} diff --git a/packages/opentelemetry-core/src/utils/url.ts b/packages/opentelemetry-core/src/utils/url.ts index 4e1796349ac..8c3cffb59a2 100644 --- a/packages/opentelemetry-core/src/utils/url.ts +++ b/packages/opentelemetry-core/src/utils/url.ts @@ -9,6 +9,7 @@ export function urlMatches(url: string, urlToMatch: string | RegExp): boolean { return !!url.match(urlToMatch); } } + /** * Check if {@param url} should be ignored when comparing against {@param ignoredUrls} * @param url