UNPKG

@dash0/sdk-web

Version:

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

200 lines (199 loc) 8.05 kB
import { doc, perf, win } from "./globals"; import { now } from "./time"; import { noop } from "./fn"; import { setTimeout } from "./timers"; import { addEventListener, removeEventListener } from "./listeners"; const TEN_MINUTES_IN_MILLIS = 1000 * 60 * 10; const ONE_DAY_IN_MILLIS = 1000 * 60 * 60 * 24; const OBSERVER_WAIT_TIME_MS = 300; const MAX_RESOURCES_ENTRIES = 100; export const isResourceTimingAvailable = !!(perf && perf.getEntriesByType); export const isPerformanceObserverAvailable = perf && typeof win["PerformanceObserver"] === "function" && typeof perf["now"] === "function"; export const PerformanceTimingNames = Object.freeze({ 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", }); const usedResources = new WeakSet(); export function observeResourcePerformance(opts) { if (!isPerformanceObserverAvailable) return observeWithoutPerformanceObserverSupport(opts.onEnd); // Used to calculate the duration when no resource was found. let startTime; let endTime; const resources = []; // Global resources that will need to be disposed let observer; let fallbackNoResourceFoundTimerHandle; let fallbackEndNeverCalledTimerHandle; return { start: onStart, end: onEnd, cancel: disposeGlobalResources, }; function onStart() { startTime = perf.now(); try { const PerformanceObserver = win?.PerformanceObserver; if (PerformanceObserver) { observer = new PerformanceObserver(onResourceFound); observer?.observe({ type: "resource" }); } } catch (_e) { // Some browsers may not support the passed entryTypes and decide to throw an error. // This would then result in an error with a message like: // // entryTypes only contained unsupported types // // Swallow and ignore the error. Treat it like unavailable performance observer data. } fallbackEndNeverCalledTimerHandle = setTimeout(disposeGlobalResources, TEN_MINUTES_IN_MILLIS); } function onEnd(endTs) { // If endTime is already set, we are already terminating and should not do so again if (endTime) return; endTime = endTs ?? perf.now(); cancelFallbackEndNeverCalledTimer(); if (!isWaitingAcceptable()) { return end(); } setTimeout(() => end(), Math.min(OBSERVER_WAIT_TIME_MS, opts.maxWaitForResourceMillis)); addEventListener(doc, "visibilitychange", onVisibilityChanged); fallbackNoResourceFoundTimerHandle = setTimeout(end, opts.maxWaitForResourceMillis); } function end() { disposeGlobalResources(); const resource = findBestMatchingResource(); // In some old web browsers, e.g. Chrome 31, the value provided as the duration // can be very wrong. We have seen cases where this value is measured in years. // If this does seem be the case, then we will ignore the duration property and // instead prefer our approximation. if (resource?.duration && resource.duration < ONE_DAY_IN_MILLIS) { opts.onEnd({ resource, duration: resource.duration }); } else { opts.onEnd({ resource, duration: endTime - startTime }); } } function onResourceFound(list) { list .getEntriesByType("resource") .filter((e) => { // This polymorphism is not properly represented in the api types. The cast is safe since we're only accessing the resource timings. const entry = e; return entry.startTime >= startTime && opts.resourceMatcher(entry); }) .forEach((entry) => { // Limit array size to prevent memory leaks on high-traffic pages if (resources.length >= MAX_RESOURCES_ENTRIES) { // Remove oldest entry (FIFO) // Timings are only reported once the resource is fully recorded (i.e. the request has ended), so timings ending // way before the `onEnd` is called are less likely to be good matches and we can ignore them. resources.shift(); } resources.push(entry); }); } function onVisibilityChanged() { if (!isWaitingAcceptable()) end(); } function disposeGlobalResources() { disconnectResourceObserver(); cancelFallbackNoResourceFoundTimer(); cancelFallbackEndNeverCalledTimer(); stopVisibilityObservation(); } function disconnectResourceObserver() { if (observer) { try { observer?.disconnect(); } catch (_e) { // Observer disconnect may throw when connect attempt wasn't successful. Ignore this. } observer = undefined; } } function cancelFallbackNoResourceFoundTimer() { if (fallbackNoResourceFoundTimerHandle) { clearTimeout(fallbackNoResourceFoundTimerHandle); fallbackNoResourceFoundTimerHandle = undefined; } } function cancelFallbackEndNeverCalledTimer() { if (fallbackEndNeverCalledTimerHandle) { clearTimeout(fallbackEndNeverCalledTimerHandle); fallbackEndNeverCalledTimerHandle = undefined; } } function stopVisibilityObservation() { if (!doc) return; removeEventListener(doc, "visibilitychange", onVisibilityChanged); } function findBestMatchingResource() { if (!resources.length) return undefined; let bestMatch; const matchingResources = resources.filter((res) => res.responseEnd <= endTime + opts.maxToleranceForResourceTimingsMillis && !usedResources.has(res)); if (!matchingResources.length) { return undefined; } if (matchingResources.length === 1) { bestMatch = matchingResources[0]; } let bestScore; if (!bestMatch) { for (const res of matchingResources) { const score = Math.abs(endTime - startTime - res.duration) + Math.abs(res.responseEnd - endTime); if (bestScore === undefined || score < bestScore) { bestScore = score; bestMatch = res; } } } if (!bestMatch) return; usedResources.add(bestMatch); return bestMatch; } } // We may only wait for resource data to arrive as long as the document is visible or in the process // of becoming visible. In all other cases we might lose data when waiting, e.g. when the document // is in the process of being disposed. function isWaitingAcceptable() { return doc?.visibilityState === "visible" || doc?.visibilityState === "prerender"; } function observeWithoutPerformanceObserverSupport(onEnd) { let start = 0; return { start: () => { start = now(); }, end: () => onEnd({ duration: now() - start }), cancel: noop, }; }