UNPKG

@embrace-io/web-sdk

Version:
259 lines (258 loc) 11.9 kB
import { getSelector } from "../../../utils/getSelector.js"; import { KEY_APP_SURFACE_LABEL, KEY_BROWSER_URL_FULL, KEY_EMB_PAGE_ID, KEY_EMB_PAGE_PATH, KEY_EMB_TYPE } from "../../../constants/attributes.js"; import { isEntryTypeSupported } from "../../../utils/performanceObserver/performanceObserver.js"; import { page } from "../../../api-page/pageAPI.js"; import { EmbraceInstrumentationBase } from "../../EmbraceInstrumentationBase/EmbraceInstrumentationBase.js"; import { KEY_BROWSER_WEB_VITAL_DELTA, KEY_BROWSER_WEB_VITAL_ID, KEY_BROWSER_WEB_VITAL_INTERACTION_ID, KEY_BROWSER_WEB_VITAL_NAME, KEY_BROWSER_WEB_VITAL_NAVIGATION_ID, KEY_BROWSER_WEB_VITAL_NAVIGATION_TYPE, KEY_BROWSER_WEB_VITAL_RATING, KEY_BROWSER_WEB_VITAL_VALUE, KEY_EMB_WEB_VITAL_ATTRIBUTION_PREFIX } from "./attributes.js"; import { ALL_WEB_VITALS, MAX_LOAF_SCRIPT_URL_LENGTH, WEB_VITALS_ID_TO_LISTENER, WEB_VITAL_EVENT_NAME } from "./constants.js"; import { SeverityNumber } from "@opentelemetry/api-logs"; import { safeExecuteInTheMiddle } from "@opentelemetry/instrumentation"; import { millisToHrTime } from "@opentelemetry/core"; //#region src/instrumentations/web-vitals/WebVitalsInstrumentation/WebVitalsInstrumentation.ts const roundClamp = (value) => Math.round(Math.max(0, value)); const roundRect = (rect) => ({ x: Math.round(rect?.x ?? 0), y: Math.round(rect?.y ?? 0), width: Math.round(rect?.width ?? 0), height: Math.round(rect?.height ?? 0) }); const isPrimitiveValue = (value) => { const type = typeof value; return type === "number" || type === "string" || type === "boolean"; }; const loafScriptsAttribution = (metric, diag) => { const attributes = {}; const attribution = metric.attribution; try { if (attribution.longAnimationFrameEntries.length > 0) { const scripts = /* @__PURE__ */ new Map(); for (const entry of attribution.longAnimationFrameEntries) for (const script of entry.scripts) { let url = script.sourceURL || "(inline)"; if (url.length > 2048) url = `${url.substring(0, MAX_LOAF_SCRIPT_URL_LENGTH)}...`; const existing = scripts.get(url); if (existing) { existing.totalDuration += script.duration; existing.styleAndLayoutDuration += script.forcedStyleAndLayoutDuration; existing.count++; } else scripts.set(url, { totalDuration: script.duration, styleAndLayoutDuration: script.forcedStyleAndLayoutDuration, count: 1 }); } if (scripts.size > 0) attributes[`${KEY_EMB_WEB_VITAL_ATTRIBUTION_PREFIX}loaf_scripts`] = JSON.stringify(Object.fromEntries([...scripts].sort((a, b) => b[1].totalDuration - a[1].totalDuration).slice(0, 250).map(([url, script]) => [url, { total_duration: Math.round(script.totalDuration), style_and_layout_duration: Math.round(script.styleAndLayoutDuration), count: script.count }]))); } } catch (e) { diag.error("error building loaf scripts for INP", e); } return attributes; }; const clsLayoutShiftsAttribution = (metric, diag) => { const attributes = {}; try { const entries = metric.entries; if (entries.length > 0) { const shifts = entries.slice(0, 50).map((entry) => { const source = entry.sources[0]; return { selector: getSelector(source?.node ?? null), startRect: roundRect(source?.previousRect), endRect: roundRect(source?.currentRect) }; }); const prefix = KEY_EMB_WEB_VITAL_ATTRIBUTION_PREFIX; attributes[`${prefix}clsLayoutShifts`] = JSON.stringify(shifts); if (entries.length > 50) attributes[`${prefix}clsLayoutShiftsDroppedCount`] = entries.length - 50; } } catch (e) { diag.error("error building CLS layout shifts", e); } return attributes; }; const inpAttribution = (metric, diag) => { const attributes = {}; try { const tagName = (metric.entries.find((entry) => entry.target)?.target)?.tagName?.toLowerCase(); if (tagName) attributes[`${KEY_EMB_WEB_VITAL_ATTRIBUTION_PREFIX}element_type`] = tagName; } catch (e) { diag.error("error building INP element type attribution", e); } return attributes; }; const ttfbSubPartsAttribution = (metric, diag) => { const attributes = {}; const entry = metric.attribution.navigationEntry; if (entry) try { const redirect = roundClamp(entry.redirectEnd - entry.redirectStart); const domainLookup = roundClamp(entry.domainLookupEnd - entry.domainLookupStart); const tcpConnection = roundClamp(entry.secureConnectionStart > 0 ? entry.secureConnectionStart - entry.connectStart : entry.connectEnd - entry.connectStart); const tlsNegotiation = roundClamp(entry.secureConnectionStart > 0 ? entry.connectEnd - entry.secureConnectionStart : 0); const effectiveResponseStart = Math.max(entry.finalResponseHeadersStart ?? 0, entry.responseStart); const serverResponse = roundClamp(effectiveResponseStart - entry.requestStart); const total = Math.round(entry.responseEnd - entry.startTime); const unattributed = roundClamp(total - redirect - domainLookup - tcpConnection - tlsNegotiation - serverResponse); const prefix = KEY_EMB_WEB_VITAL_ATTRIBUTION_PREFIX; attributes[`${prefix}redirect`] = redirect; attributes[`${prefix}domainLookup`] = domainLookup; attributes[`${prefix}tcpConnection`] = tcpConnection; attributes[`${prefix}tlsNegotiation`] = tlsNegotiation; attributes[`${prefix}serverResponse`] = serverResponse; attributes[`${prefix}unattributed`] = unattributed; } catch (e) { diag.error("error computing TTFB timing breakdown", e); } else diag.debug("TTFB navigationEntry unavailable, skipping timing breakdown"); return attributes; }; const lcpElementAttribution = (metric, diag) => { const attributes = {}; try { const element = metric.attribution.lcpEntry?.element; if (element) { const elementType = element.tagName.toLowerCase(); const elementBoundingRect = JSON.stringify(roundRect(element.getBoundingClientRect())); const prefix = KEY_EMB_WEB_VITAL_ATTRIBUTION_PREFIX; attributes[`${prefix}elementType`] = elementType; attributes[`${prefix}elementBoundingRect`] = elementBoundingRect; } } catch (e) { diag.error("error building LCP element attribution", e); } return attributes; }; var WebVitalsInstrumentation = class extends EmbraceInstrumentationBase { _listeners; _urlDocument; _urlAttribution; _includeRawAttribution; _softNavsActive; _pageManager; _attributedPage = { INP: void 0, LCP: void 0, CLS: void 0, FCP: void 0, TTFB: void 0 }; _largestShiftTargetForCLS = null; _clsMetricId = void 0; _applyCustomLogRecordData; _listenersRegistered = false; constructor({ diag, perf, listeners = WEB_VITALS_ID_TO_LISTENER, urlDocument, urlAttribution = true, includeRawAttribution = true, reportSoftNavs = true, pageManager, applyCustomLogRecordData, ...config } = {}) { super({ instrumentationName: "WebVitalsInstrumentation", instrumentationVersion: "1.0.0", diag, perf, config }); this._listeners = listeners; this._urlDocument = urlDocument ?? window.document; this._urlAttribution = urlAttribution; this._includeRawAttribution = includeRawAttribution; this._softNavsActive = reportSoftNavs && isEntryTypeSupported("soft-navigation"); this._pageManager = pageManager ?? page.getPageManager(); this._applyCustomLogRecordData = applyCustomLogRecordData; if (this._config.enabled !== false) this.enable(); } onDisable() { this._diag.debug("WebVitalsInstrumentation disabled, pausing emission"); } enable() { if (typeof PerformanceObserver !== "undefined") super.enable(); else this._diag.debug("PerformanceObserver not supported, web vitals will not be collected"); } onEnable() { if (this._listenersRegistered) { this._diag.debug("WebVitalsInstrumentation listeners already registered, resuming emission"); return; } this._listenersRegistered = true; ALL_WEB_VITALS.forEach((name) => { const reportSoftNavs = name !== "TTFB" && this._softNavsActive; if (this._urlAttribution) this._listeners[name]?.((metric) => { if (!this._isEnabled) return; if (metric.name === "CLS") { const clsMetric = metric; const target = clsMetric.attribution.largestShiftTarget; if (this._clsMetricId !== clsMetric.id) { this._clsMetricId = clsMetric.id; this._largestShiftTargetForCLS = target; this._attributedPage.CLS = this._currentAttributedPage(); } else if (this._largestShiftTargetForCLS !== target) { this._largestShiftTargetForCLS = target; this._attributedPage.CLS = this._currentAttributedPage(); } } else this._attributedPage[metric.name] = this._currentAttributedPage(); }, { reportAllChanges: true, reportSoftNavs }); this._listeners[name]?.((metric) => { if (!this._isEnabled) return; this._emitWebVital(metric); }, { reportSoftNavs }); }); } _currentAttributedPage() { return { fullURL: this._urlDocument.URL, path: this._pageManager.getCurrentRoute()?.path, pageID: this._pageManager.getCurrentPageId() ?? void 0, label: this._pageManager.getPageLabel() ?? void 0 }; } _getTimeForMetric(metric) { if (metric.name === "INP" && metric.attribution.interactionTime !== void 0) return this.perf.epochMillisFromOrigin(metric.attribution.interactionTime); if (metric.name === "CLS") { const windowStart = metric.entries.length > 0 ? Math.min(...metric.entries.map((entry) => entry.startTime)) : metric.attribution.largestShiftTime; if (windowStart !== void 0) return this.perf.epochMillisFromOrigin(windowStart); } if (metric.name === "TTFB") return this.perf.getZeroTime(); const metricStartTime = metric.entries[metric.entries.length - 1]?.startTime; if (metricStartTime !== void 0) return this.perf.epochMillisFromOrigin(metricStartTime); return this.perf.getNowMillis(); } _emitWebVital(metric) { const attributedPage = this._attributedPage[metric.name]; const logRecord = { eventName: WEB_VITAL_EVENT_NAME, severityNumber: SeverityNumber.INFO, attributes: { [KEY_EMB_TYPE]: "ux.web_vital", [KEY_BROWSER_WEB_VITAL_NAME]: metric.name.toLowerCase(), [KEY_BROWSER_WEB_VITAL_VALUE]: metric.value, [KEY_BROWSER_WEB_VITAL_DELTA]: metric.delta, [KEY_BROWSER_WEB_VITAL_RATING]: metric.rating, [KEY_BROWSER_WEB_VITAL_ID]: metric.id, [KEY_BROWSER_WEB_VITAL_NAVIGATION_TYPE]: metric.navigationType, ...metric.navigationId !== void 0 ? { [KEY_BROWSER_WEB_VITAL_NAVIGATION_ID]: metric.navigationId } : {}, ...metric.navigationInteractionId !== void 0 ? { [KEY_BROWSER_WEB_VITAL_INTERACTION_ID]: metric.navigationInteractionId } : {}, ...attributedPage ? { [KEY_BROWSER_URL_FULL]: metric.navigationURL != null && (metric.navigationType === "soft-navigation" || this._softNavsActive) ? metric.navigationURL : attributedPage.fullURL, [KEY_EMB_PAGE_PATH]: attributedPage.path, [KEY_EMB_PAGE_ID]: attributedPage.pageID, [KEY_APP_SURFACE_LABEL]: attributedPage.label } : {}, ...metric.name === "INP" ? loafScriptsAttribution(metric, this._diag) : {}, ...metric.name === "INP" ? inpAttribution(metric, this._diag) : {}, ...metric.name === "TTFB" ? ttfbSubPartsAttribution(metric, this._diag) : {}, ...metric.name === "CLS" ? clsLayoutShiftsAttribution(metric, this._diag) : {}, ...metric.name === "LCP" ? lcpElementAttribution(metric, this._diag) : {} }, body: this._includeRawAttribution && metric.attribution != null ? JSON.stringify(Object.fromEntries(Object.entries(metric.attribution).filter(([, v]) => isPrimitiveValue(v)))) : void 0, timestamp: millisToHrTime(this._getTimeForMetric(metric)) }; if (this._applyCustomLogRecordData) safeExecuteInTheMiddle(() => this._applyCustomLogRecordData?.(logRecord), (error) => { if (error) this._diag.error("applyCustomLogRecordData hook failed", error); }, true); this.logger.emit(logRecord); } }; //#endregion export { WebVitalsInstrumentation }; //# sourceMappingURL=WebVitalsInstrumentation.js.map