|
| 1 | +import { getGlobalObject } from '@sentry/utils'; |
| 2 | + |
| 3 | +import { Span } from '../../span'; |
| 4 | +import { Transaction } from '../../transaction'; |
| 5 | +import { BrowserTracing } from '../browsertracing'; |
| 6 | + |
| 7 | +import { msToSec } from './utils'; |
| 8 | + |
| 9 | +const global = getGlobalObject<Window>(); |
| 10 | + |
| 11 | +/** |
| 12 | + * Adds metrics to transactions. |
| 13 | + */ |
| 14 | +// tslint:disable-next-line: no-unnecessary-class |
| 15 | +export class Metrics { |
| 16 | + private static _lcp: Record<string, any>; |
| 17 | + |
| 18 | + private static readonly _performanceCursor: number = 0; |
| 19 | + |
| 20 | + private static _forceLCP = () => { |
| 21 | + /* No-op, replaced later if LCP API is available. */ |
| 22 | + return; |
| 23 | + }; |
| 24 | + |
| 25 | + /** |
| 26 | + * Starts tracking the Largest Contentful Paint on the current page. |
| 27 | + */ |
| 28 | + private static _trackLCP(): void { |
| 29 | + // Based on reference implementation from https://web.dev/lcp/#measure-lcp-in-javascript. |
| 30 | + |
| 31 | + // Use a try/catch instead of feature detecting `largest-contentful-paint` |
| 32 | + // support, since some browsers throw when using the new `type` option. |
| 33 | + // https://bugs.webkit.org/show_bug.cgi?id=209216 |
| 34 | + try { |
| 35 | + // Keep track of whether (and when) the page was first hidden, see: |
| 36 | + // https://github.com/w3c/page-visibility/issues/29 |
| 37 | + // NOTE: ideally this check would be performed in the document <head> |
| 38 | + // to avoid cases where the visibility state changes before this code runs. |
| 39 | + let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity; |
| 40 | + document.addEventListener( |
| 41 | + 'visibilitychange', |
| 42 | + event => { |
| 43 | + firstHiddenTime = Math.min(firstHiddenTime, event.timeStamp); |
| 44 | + }, |
| 45 | + { once: true }, |
| 46 | + ); |
| 47 | + |
| 48 | + const updateLCP = (entry: PerformanceEntry) => { |
| 49 | + // Only include an LCP entry if the page wasn't hidden prior to |
| 50 | + // the entry being dispatched. This typically happens when a page is |
| 51 | + // loaded in a background tab. |
| 52 | + if (entry.startTime < firstHiddenTime) { |
| 53 | + // NOTE: the `startTime` value is a getter that returns the entry's |
| 54 | + // `renderTime` value, if available, or its `loadTime` value otherwise. |
| 55 | + // The `renderTime` value may not be available if the element is an image |
| 56 | + // that's loaded cross-origin without the `Timing-Allow-Origin` header. |
| 57 | + Metrics._lcp = { |
| 58 | + // @ts-ignore |
| 59 | + ...(entry.id && { elementId: entry.id }), |
| 60 | + // @ts-ignore |
| 61 | + ...(entry.size && { elementSize: entry.size }), |
| 62 | + value: entry.startTime, |
| 63 | + }; |
| 64 | + } |
| 65 | + }; |
| 66 | + |
| 67 | + // Create a PerformanceObserver that calls `updateLCP` for each entry. |
| 68 | + const po = new PerformanceObserver(entryList => { |
| 69 | + entryList.getEntries().forEach(updateLCP); |
| 70 | + }); |
| 71 | + |
| 72 | + // Observe entries of type `largest-contentful-paint`, including buffered entries, |
| 73 | + // i.e. entries that occurred before calling `observe()` below. |
| 74 | + po.observe({ |
| 75 | + buffered: true, |
| 76 | + // @ts-ignore |
| 77 | + type: 'largest-contentful-paint', |
| 78 | + }); |
| 79 | + |
| 80 | + Metrics._forceLCP = () => { |
| 81 | + po.takeRecords().forEach(updateLCP); |
| 82 | + }; |
| 83 | + } catch (e) { |
| 84 | + // Do nothing if the browser doesn't support this API. |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + /** |
| 89 | + * Start tracking metrics |
| 90 | + */ |
| 91 | + public static init(): void { |
| 92 | + if (global.performance) { |
| 93 | + if (global.performance.mark) { |
| 94 | + global.performance.mark('sentry-tracing-init'); |
| 95 | + } |
| 96 | + Metrics._trackLCP(); |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + /** |
| 101 | + * Adds performance related spans to a transaction |
| 102 | + */ |
| 103 | + public static addPerformanceEntries(transaction: Transaction): void { |
| 104 | + if (!global.performance || !global.performance.getEntries) { |
| 105 | + // Gatekeeper if performance API not available |
| 106 | + return; |
| 107 | + } |
| 108 | + |
| 109 | + BrowserTracing.log('[Tracing] Adding & adjusting spans using Performance API'); |
| 110 | + // FIXME: depending on the 'op' directly is brittle. |
| 111 | + if (transaction.op === 'pageload') { |
| 112 | + // Force any pending records to be dispatched. |
| 113 | + Metrics._forceLCP(); |
| 114 | + if (Metrics._lcp) { |
| 115 | + // Set the last observed LCP score. |
| 116 | + transaction.setData('_sentry_web_vitals', { LCP: Metrics._lcp }); |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + const timeOrigin = msToSec(performance.timeOrigin); |
| 121 | + |
| 122 | + // tslint:disable-next-line: completed-docs |
| 123 | + function addPerformanceNavigationTiming(parent: Span, entry: Record<string, number>, event: string): void { |
| 124 | + parent.startChild({ |
| 125 | + description: event, |
| 126 | + endTimestamp: timeOrigin + msToSec(entry[`${event}End`]), |
| 127 | + op: 'browser', |
| 128 | + startTimestamp: timeOrigin + msToSec(entry[`${event}Start`]), |
| 129 | + }); |
| 130 | + } |
| 131 | + |
| 132 | + // tslint:disable-next-line: completed-docs |
| 133 | + function addRequest(parent: Span, entry: Record<string, number>): void { |
| 134 | + parent.startChild({ |
| 135 | + description: 'request', |
| 136 | + endTimestamp: timeOrigin + msToSec(entry.responseEnd), |
| 137 | + op: 'browser', |
| 138 | + startTimestamp: timeOrigin + msToSec(entry.requestStart), |
| 139 | + }); |
| 140 | + |
| 141 | + parent.startChild({ |
| 142 | + description: 'response', |
| 143 | + endTimestamp: timeOrigin + msToSec(entry.responseEnd), |
| 144 | + op: 'browser', |
| 145 | + startTimestamp: timeOrigin + msToSec(entry.responseStart), |
| 146 | + }); |
| 147 | + } |
| 148 | + |
| 149 | + let entryScriptSrc: string | undefined; |
| 150 | + |
| 151 | + if (global.document) { |
| 152 | + // tslint:disable-next-line: prefer-for-of |
| 153 | + for (let i = 0; i < document.scripts.length; i++) { |
| 154 | + // We go through all scripts on the page and look for 'data-entry' |
| 155 | + // We remember the name and measure the time between this script finished loading and |
| 156 | + // our mark 'sentry-tracing-init' |
| 157 | + if (document.scripts[i].dataset.entry === 'true') { |
| 158 | + entryScriptSrc = document.scripts[i].src; |
| 159 | + break; |
| 160 | + } |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + let entryScriptStartEndTime: number | undefined; |
| 165 | + let tracingInitMarkStartTime: number | undefined; |
| 166 | + |
| 167 | + // tslint:disable: no-unsafe-any |
| 168 | + performance |
| 169 | + .getEntries() |
| 170 | + .slice(Metrics._performanceCursor) |
| 171 | + .forEach((entry: any) => { |
| 172 | + const startTime = msToSec(entry.startTime as number); |
| 173 | + const duration = msToSec(entry.duration as number); |
| 174 | + |
| 175 | + if (transaction.op === 'navigation' && timeOrigin + startTime < transaction.startTimestamp) { |
| 176 | + return; |
| 177 | + } |
| 178 | + |
| 179 | + switch (entry.entryType) { |
| 180 | + case 'navigation': |
| 181 | + addPerformanceNavigationTiming(transaction, entry, 'unloadEvent'); |
| 182 | + addPerformanceNavigationTiming(transaction, entry, 'domContentLoadedEvent'); |
| 183 | + addPerformanceNavigationTiming(transaction, entry, 'loadEvent'); |
| 184 | + addPerformanceNavigationTiming(transaction, entry, 'connect'); |
| 185 | + addPerformanceNavigationTiming(transaction, entry, 'domainLookup'); |
| 186 | + addRequest(transaction, entry); |
| 187 | + break; |
| 188 | + case 'mark': |
| 189 | + case 'paint': |
| 190 | + case 'measure': |
| 191 | + const measureStartTimestamp = timeOrigin + startTime; |
| 192 | + const measureEndTimestamp = measureStartTimestamp + duration; |
| 193 | + |
| 194 | + if (tracingInitMarkStartTime === undefined && entry.name === 'sentry-tracing-init') { |
| 195 | + tracingInitMarkStartTime = measureStartTimestamp; |
| 196 | + } |
| 197 | + |
| 198 | + transaction.startChild({ |
| 199 | + description: entry.name, |
| 200 | + endTimestamp: measureEndTimestamp, |
| 201 | + op: entry.entryType, |
| 202 | + startTimestamp: measureStartTimestamp, |
| 203 | + }); |
| 204 | + break; |
| 205 | + case 'resource': |
| 206 | + const resourceName = entry.name.replace(window.location.origin, ''); |
| 207 | + if (entry.initiatorType === 'xmlhttprequest' || entry.initiatorType === 'fetch') { |
| 208 | + // We need to update existing spans with new timing info |
| 209 | + if (transaction.spanRecorder) { |
| 210 | + transaction.spanRecorder.spans.map((finishedSpan: Span) => { |
| 211 | + if (finishedSpan.description && finishedSpan.description.indexOf(resourceName) !== -1) { |
| 212 | + finishedSpan.startTimestamp = timeOrigin + startTime; |
| 213 | + finishedSpan.endTimestamp = finishedSpan.startTimestamp + duration; |
| 214 | + } |
| 215 | + }); |
| 216 | + } |
| 217 | + } else { |
| 218 | + const startTimestamp = timeOrigin + startTime; |
| 219 | + const endTimestamp = startTimestamp + duration; |
| 220 | + |
| 221 | + // We remember the entry script end time to calculate the difference to the first init mark |
| 222 | + if (entryScriptStartEndTime === undefined && (entryScriptSrc || '').indexOf(resourceName) > -1) { |
| 223 | + entryScriptStartEndTime = endTimestamp; |
| 224 | + } |
| 225 | + |
| 226 | + transaction.startChild({ |
| 227 | + description: `${entry.initiatorType} ${resourceName}`, |
| 228 | + endTimestamp, |
| 229 | + op: `resource`, |
| 230 | + startTimestamp, |
| 231 | + }); |
| 232 | + } |
| 233 | + break; |
| 234 | + default: |
| 235 | + // Ignore other entry types. |
| 236 | + } |
| 237 | + }); |
| 238 | + } |
| 239 | +} |
0 commit comments