import { browserPerformanceTimeOrigin, spanToJSON, setMeasurement, getActiveSpan, parseUrl, stringMatchesSomePattern, debug, isPrimitive, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, getComponentName } from '@sentry/core'; import { htmlTreeAsString } from '../htmlTreeAsString.js'; import { WINDOW } from '../types.js'; import { trackClsAsStandaloneSpan } from './cls.js'; import { addPerformanceInstrumentationHandler, addLcpInstrumentationHandler, addClsInstrumentationHandler, addTtfbInstrumentationHandler } from './instrument.js'; import { trackLcpAsStandaloneSpan, isValidLcpMetric } from './lcp.js'; import { resourceTimingToSpanAttributes } from './resourceTiming.js'; import { getBrowserPerformanceAPI, msToSec, startAndEndSpan, isMeasurementValue } from './utils.js'; import { getActivationStart } from './web-vitals/lib/getActivationStart.js'; import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry.js'; import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher.js'; import { DEBUG_BUILD } from '../debug-build.js'; const MAX_INT_AS_BYTES = 2147483647; let _performanceCursor = 0; let _measurements = {}; let _lcpEntry; let _clsEntry; function startTrackingWebVitals({ recordClsStandaloneSpans, recordLcpStandaloneSpans, client }) { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin()) { if (performance.mark) { WINDOW.performance.mark("sentry-tracing-init"); } const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : recordLcpStandaloneSpans === false ? _trackLCP() : void 0; const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : recordClsStandaloneSpans === false ? _trackCLS() : void 0; const ttfbCleanupCallback = _trackTtfb(); return () => { ttfbCleanupCallback(); lcpCleanupCallback?.(); clsCleanupCallback?.(); }; } return () => void 0; } function startTrackingLongTasks() { addPerformanceInstrumentationHandler("longtask", ({ entries }) => { const parent = getActiveSpan(); if (!parent) { return; } const { op: parentOp, start_timestamp: parentStartTimestamp } = spanToJSON(parent); for (const entry of entries) { const startTime = msToSec(browserPerformanceTimeOrigin() + entry.startTime); const duration = msToSec(entry.duration); if (parentOp === "navigation" && parentStartTimestamp && startTime < parentStartTimestamp) { continue; } startAndEndSpan(parent, startTime, startTime + duration, { name: "Main UI thread blocked", op: "ui.long-task", attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: "auto.ui.browser.metrics" } }); } }); } function startTrackingLongAnimationFrames() { const observer = new PerformanceObserver((list) => { const parent = getActiveSpan(); if (!parent) { return; } for (const entry of list.getEntries()) { if (!entry.scripts[0]) { continue; } const startTime = msToSec(browserPerformanceTimeOrigin() + entry.startTime); const { start_timestamp: parentStartTimestamp, op: parentOp } = spanToJSON(parent); if (parentOp === "navigation" && parentStartTimestamp && startTime < parentStartTimestamp) { continue; } const duration = msToSec(entry.duration); const attributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: "auto.ui.browser.metrics" }; const initialScript = entry.scripts[0]; const { invoker, invokerType, sourceURL, sourceFunctionName, sourceCharPosition } = initialScript; attributes["browser.script.invoker"] = invoker; attributes["browser.script.invoker_type"] = invokerType; if (sourceURL) { attributes["code.filepath"] = sourceURL; } if (sourceFunctionName) { attributes["code.function"] = sourceFunctionName; } if (sourceCharPosition !== -1) { attributes["browser.script.source_char_position"] = sourceCharPosition; } startAndEndSpan(parent, startTime, startTime + duration, { name: "Main UI thread blocked", op: "ui.long-animation-frame", attributes }); } }); observer.observe({ type: "long-animation-frame", buffered: true }); } function startTrackingInteractions() { addPerformanceInstrumentationHandler("event", ({ entries }) => { const parent = getActiveSpan(); if (!parent) { return; } for (const entry of entries) { if (entry.name === "click") { const startTime = msToSec(browserPerformanceTimeOrigin() + entry.startTime); const duration = msToSec(entry.duration); const spanOptions = { name: htmlTreeAsString(entry.target), op: `ui.interaction.${entry.name}`, startTime, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: "auto.ui.browser.metrics" } }; const componentName = getComponentName(entry.target); if (componentName) { spanOptions.attributes["ui.component_name"] = componentName; } startAndEndSpan(parent, startTime, startTime + duration, spanOptions); } } }); } function _trackCLS() { return addClsInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1]; if (!entry) { return; } _measurements["cls"] = { value: metric.value, unit: "" }; _clsEntry = entry; }, true); } function _trackLCP() { return addLcpInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1]; if (!entry || !isValidLcpMetric(metric.value)) { return; } _measurements["lcp"] = { value: metric.value, unit: "millisecond" }; _lcpEntry = entry; }, true); } function _trackTtfb() { return addTtfbInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1]; if (!entry) { return; } _measurements["ttfb"] = { value: metric.value, unit: "millisecond" }; }); } function addPerformanceEntries(span, options) { const performance = getBrowserPerformanceAPI(); const origin = browserPerformanceTimeOrigin(); if (!performance?.getEntries || !origin) { return; } const { spanStreamingEnabled, ignorePerformanceApiSpans, ignoreResourceSpans, recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; const timeOrigin = msToSec(origin); const performanceEntries = performance.getEntries(); const { op, start_timestamp: transactionStartTime } = spanToJSON(span); performanceEntries.slice(_performanceCursor).forEach((entry) => { const startTime = msToSec(entry.startTime); const duration = msToSec( // Inexplicably, Chrome sometimes emits a negative duration. We need to work around this. // There is a SO post attempting to explain this, but it leaves one with open questions: https://stackoverflow.com/questions/23191918/peformance-getentries-and-negative-duration-display // The way we clamp the value is probably not accurate, since we have observed this happen for things that may take a while to load, like for example the replay worker. // TODO: Investigate why this happens and how to properly mitigate. For now, this is a workaround to prevent transactions being dropped due to negative duration spans. Math.max(0, entry.duration) ); if (op === "navigation" && transactionStartTime && timeOrigin + startTime < transactionStartTime) { return; } switch (entry.entryType) { case "navigation": { _addNavigationSpans(span, entry, timeOrigin); break; } case "mark": case "paint": case "measure": { _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans); const firstHidden = getVisibilityWatcher(); const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; if (entry.name === "first-paint" && shouldRecord) { _measurements["fp"] = { value: entry.startTime, unit: "millisecond" }; } if (entry.name === "first-contentful-paint" && shouldRecord) { _measurements["fcp"] = { value: entry.startTime, unit: "millisecond" }; } break; } case "resource": { _addResourceSpans( span, entry, entry.name, startTime, duration, timeOrigin, ignoreResourceSpans ); break; } } }); _performanceCursor = Math.max(performanceEntries.length - 1, 0); _trackNavigator(span, spanStreamingEnabled); if (op === "pageload") { _addTtfbRequestTimeToMeasurements(_measurements); if (spanStreamingEnabled) { const setAttr = (shortWebVitalName, value, customAttrName) => { const attrKey = customAttrName ?? `browser.web_vital.${shortWebVitalName}.value`; span.setAttribute(attrKey, value); DEBUG_BUILD && debug.log("Setting web vital attribute", { [attrKey]: value }, "on pageload span"); }; ["ttfb", "fp", "fcp"].forEach((measurementName) => { if (_measurements[measurementName]) { setAttr(measurementName, _measurements[measurementName].value); } }); if (_measurements["ttfb.requestTime"]) { setAttr("ttfb.requestTime", _measurements["ttfb.requestTime"].value, "browser.web_vital.ttfb.request_time"); } } else { if (!recordClsOnPageloadSpan) { delete _measurements.cls; } if (!recordLcpOnPageloadSpan) { delete _measurements.lcp; } Object.entries(_measurements).forEach(([measurementName, measurement]) => { setMeasurement(measurementName, measurement.value, measurement.unit); }); _setWebVitalAttributes(span, options); } span.setAttribute(spanStreamingEnabled ? "browser.performance.time_origin" : "performance.timeOrigin", timeOrigin); span.setAttribute( spanStreamingEnabled ? "browser.performance.navigation.activation_start" : "performance.activationStart", getActivationStart() ); } _lcpEntry = void 0; _clsEntry = void 0; _measurements = {}; } function isReact19MeasureEntry(entry) { if (entry?.entryType !== "measure") { return; } try { return entry.detail.devtools.track === "Components \u269B"; } catch { return; } } function _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans) { if (isReact19MeasureEntry(entry)) { return; } if (["mark", "measure"].includes(entry.entryType) && stringMatchesSomePattern(entry.name, ignorePerformanceApiSpans)) { return; } const navEntry = getNavigationEntry(false); const requestTime = msToSec(navEntry ? navEntry.requestStart : 0); const measureStartTimestamp = timeOrigin + Math.max(startTime, requestTime); const startTimeStamp = timeOrigin + startTime; const measureEndTimestamp = startTimeStamp + duration; const attributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: "auto.resource.browser.metrics" }; if (measureStartTimestamp !== startTimeStamp) { attributes["sentry.browser.measure_happened_before_request"] = true; attributes["sentry.browser.measure_start_time"] = measureStartTimestamp; } _addDetailToSpanAttributes(attributes, entry); if (measureStartTimestamp <= measureEndTimestamp) { startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { name: entry.name, op: entry.entryType, attributes }); } } function _addDetailToSpanAttributes(attributes, performanceMeasure) { try { const detail = performanceMeasure.detail; if (!detail) { return; } if (typeof detail === "object") { for (const [key, value] of Object.entries(detail)) { if (value && isPrimitive(value)) { attributes[`sentry.browser.measure.detail.${key}`] = value; } else if (value !== void 0) { try { attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value); } catch { } } } return; } if (isPrimitive(detail)) { attributes["sentry.browser.measure.detail"] = detail; return; } try { attributes["sentry.browser.measure.detail"] = JSON.stringify(detail); } catch { } } catch { } } function _addNavigationSpans(span, entry, timeOrigin) { ["unloadEvent", "redirect", "domContentLoadedEvent", "loadEvent", "connect"].forEach((event) => { _addPerformanceNavigationTiming(span, entry, event, timeOrigin); }); _addPerformanceNavigationTiming(span, entry, "secureConnection", timeOrigin, "TLS/SSL"); _addPerformanceNavigationTiming(span, entry, "fetch", timeOrigin, "cache"); _addPerformanceNavigationTiming(span, entry, "domainLookup", timeOrigin, "DNS"); _addRequest(span, entry, timeOrigin); } function _addPerformanceNavigationTiming(span, entry, event, timeOrigin, name = event) { const eventEnd = _getEndPropertyNameForNavigationTiming(event); const end = entry[eventEnd]; const start = entry[`${event}Start`]; if (!start || !end) { return; } startAndEndSpan(span, timeOrigin + msToSec(start), timeOrigin + msToSec(end), { op: `browser.${name}`, name: entry.name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: "auto.ui.browser.metrics", ...event === "redirect" && entry.redirectCount != null ? { "http.redirect_count": entry.redirectCount } : {} } }); } function _getEndPropertyNameForNavigationTiming(event) { if (event === "secureConnection") { return "connectEnd"; } if (event === "fetch") { return "domainLookupStart"; } return `${event}End`; } function _addRequest(span, entry, timeOrigin) { const requestStartTimestamp = timeOrigin + msToSec(entry.requestStart); const responseEndTimestamp = timeOrigin + msToSec(entry.responseEnd); const responseStartTimestamp = timeOrigin + msToSec(entry.responseStart); if (entry.responseEnd) { startAndEndSpan(span, requestStartTimestamp, responseEndTimestamp, { op: "browser.request", name: entry.name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: "auto.ui.browser.metrics" } }); startAndEndSpan(span, responseStartTimestamp, responseEndTimestamp, { op: "browser.response", name: entry.name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: "auto.ui.browser.metrics" } }); } } function _addResourceSpans(span, entry, resourceUrl, startTime, duration, timeOrigin, ignoredResourceSpanOps) { if (entry.initiatorType === "xmlhttprequest" || entry.initiatorType === "fetch") { return; } const op = entry.initiatorType ? `resource.${entry.initiatorType}` : "resource.other"; if (ignoredResourceSpanOps?.includes(op)) { return; } const attributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: "auto.resource.browser.metrics" }; const parsedUrl = parseUrl(resourceUrl); if (parsedUrl.protocol) { attributes["url.scheme"] = parsedUrl.protocol.split(":").pop(); } if (parsedUrl.host) { attributes["server.address"] = parsedUrl.host; } attributes["url.same_origin"] = resourceUrl.includes(WINDOW.location.origin); _setResourceRequestAttributes(entry, attributes, [ // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus ["responseStatus", "http.response.status_code"], ["transferSize", "http.response_transfer_size"], ["encodedBodySize", "http.response_content_length"], ["decodedBodySize", "http.decoded_response_content_length"], // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/renderBlockingStatus ["renderBlockingStatus", "resource.render_blocking_status"], // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/deliveryType ["deliveryType", "http.response_delivery_type"] ]); const attributesWithResourceTiming = { ...attributes, ...resourceTimingToSpanAttributes(entry) }; const startTimestamp = timeOrigin + startTime; const endTimestamp = startTimestamp + duration; startAndEndSpan(span, startTimestamp, endTimestamp, { name: resourceUrl.replace(WINDOW.location.origin, ""), op, attributes: attributesWithResourceTiming }); } function _trackNavigator(span, spanStreamingEnabled) { const navigator = WINDOW.navigator; if (!navigator) { return; } const connection = navigator.connection; if (connection) { if (connection.effectiveType) { span.setAttribute( spanStreamingEnabled ? "network.connection.effective_type" : "effectiveConnectionType", connection.effectiveType ); } if (connection.type) { span.setAttribute(spanStreamingEnabled ? "network.connection.type" : "connectionType", connection.type); } if (isMeasurementValue(connection.rtt)) { _measurements["connection.rtt"] = { value: connection.rtt, unit: "millisecond" }; if (spanStreamingEnabled) { span.setAttribute("network.connection.rtt", connection.rtt); } } } if (isMeasurementValue(navigator.deviceMemory)) { if (spanStreamingEnabled) { span.setAttribute("device.memory.estimated_capacity", navigator.deviceMemory); } else { span.setAttribute("deviceMemory", `${navigator.deviceMemory} GB`); } } if (isMeasurementValue(navigator.hardwareConcurrency)) { if (spanStreamingEnabled) { span.setAttribute("device.processor_count", navigator.hardwareConcurrency); } else { span.setAttribute("hardwareConcurrency", String(navigator.hardwareConcurrency)); } } } function _setWebVitalAttributes(span, options) { if (_lcpEntry && options.recordLcpOnPageloadSpan) { if (_lcpEntry.element) { span.setAttribute("lcp.element", htmlTreeAsString(_lcpEntry.element)); } if (_lcpEntry.id) { span.setAttribute("lcp.id", _lcpEntry.id); } if (_lcpEntry.url) { span.setAttribute("lcp.url", _lcpEntry.url.trim().slice(0, 200)); } if (_lcpEntry.loadTime != null) { span.setAttribute("lcp.loadTime", _lcpEntry.loadTime); } if (_lcpEntry.renderTime != null) { span.setAttribute("lcp.renderTime", _lcpEntry.renderTime); } span.setAttribute("lcp.size", _lcpEntry.size); } if (_clsEntry?.sources && options.recordClsOnPageloadSpan) { _clsEntry.sources.forEach( (source, index) => span.setAttribute(`cls.source.${index + 1}`, htmlTreeAsString(source.node)) ); } } function _setResourceRequestAttributes(entry, attributes, properties) { properties.forEach(([entryKey, attributeKey]) => { const entryVal = entry[entryKey]; if (entryVal != null && (typeof entryVal === "number" && entryVal < MAX_INT_AS_BYTES || typeof entryVal === "string")) { attributes[attributeKey] = entryVal; } }); } function _addTtfbRequestTimeToMeasurements(_measurements2) { const navEntry = getNavigationEntry(false); if (!navEntry) { return; } const { responseStart, requestStart } = navEntry; if (requestStart <= responseStart) { _measurements2["ttfb.requestTime"] = { value: responseStart - requestStart, unit: "millisecond" }; } } export { _addMeasureSpans, _addNavigationSpans, _addResourceSpans, _setResourceRequestAttributes, addPerformanceEntries, startTrackingInteractions, startTrackingLongAnimationFrames, startTrackingLongTasks, startTrackingWebVitals }; //# sourceMappingURL=browserMetrics.js.map