Skip to content

Commit 15415c1

Browse files
authored
feat: Report LCP metric on pageload transactions (getsentry#2624)
Largest Contentful Paint (LCP) is an user-centric metric for measuring perceived load speed. It marks the point in the page load timeline when the page's main content has likely loaded. Reference: https://web.dev/lcp/#measure-lcp-in-javascript
1 parent 5091fbf commit 15415c1

File tree

2 files changed

+84
-1
lines changed

2 files changed

+84
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [react] feat: Add @sentry/react package (#2631)
77
- [browser] Change XHR instrumentation order to handle `onreadystatechange` breadcrumbs correctly (#2643)
88
- [apm] fix: Re-add TraceContext for all events (#2656)
9+
- [apm] feat: Report LCP metric on pageload transactions (#2624)
910

1011
## 5.16.1
1112

packages/apm/src/integrations/tracing.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,14 @@ export class Tracing implements Integration {
155155

156156
private static _heartbeatCounter: number = 0;
157157

158+
/** Holds the latest LargestContentfulPaint value (it changes during page load). */
159+
private static _lcp?: { [key: string]: any };
160+
161+
/** Force any pending LargestContentfulPaint records to be dispatched. */
162+
private static _forceLCP = () => {
163+
/* No-op, replaced later if LCP API is available. */
164+
};
165+
158166
/**
159167
* Constructor for Tracing
160168
*
@@ -163,6 +171,7 @@ export class Tracing implements Integration {
163171
public constructor(_options?: Partial<TracingOptions>) {
164172
if (global.performance) {
165173
global.performance.mark('sentry-tracing-init');
174+
Tracing._trackLCP();
166175
}
167176
const defaults = {
168177
debug: {
@@ -450,7 +459,7 @@ export class Tracing implements Integration {
450459
}
451460

452461
/**
453-
* Finshes the current active transaction
462+
* Finishes the current active transaction
454463
*/
455464
public static finishIdleTransaction(endTimestamp: number): void {
456465
const active = Tracing._activeTransaction;
@@ -508,6 +517,16 @@ export class Tracing implements Integration {
508517

509518
Tracing._log('[Tracing] Adding & adjusting spans using Performance API');
510519

520+
// FIXME: depending on the 'op' directly is brittle.
521+
if (transactionSpan.op === 'pageload') {
522+
// Force any pending records to be dispatched.
523+
Tracing._forceLCP();
524+
if (Tracing._lcp) {
525+
// Set the last observed LCP score.
526+
transactionSpan.setData('_sentry_web_vitals', { LCP: Tracing._lcp });
527+
}
528+
}
529+
511530
const timeOrigin = Tracing._msToSec(performance.timeOrigin);
512531

513532
// tslint:disable-next-line: completed-docs
@@ -632,6 +651,69 @@ export class Tracing implements Integration {
632651
// tslint:enable: no-unsafe-any
633652
}
634653

654+
/**
655+
* Starts tracking the Largest Contentful Paint on the current page.
656+
*/
657+
private static _trackLCP(): void {
658+
// Based on reference implementation from https://web.dev/lcp/#measure-lcp-in-javascript.
659+
660+
// Use a try/catch instead of feature detecting `largest-contentful-paint`
661+
// support, since some browsers throw when using the new `type` option.
662+
// https://bugs.webkit.org/show_bug.cgi?id=209216
663+
try {
664+
// Keep track of whether (and when) the page was first hidden, see:
665+
// https://github.com/w3c/page-visibility/issues/29
666+
// NOTE: ideally this check would be performed in the document <head>
667+
// to avoid cases where the visibility state changes before this code runs.
668+
let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
669+
document.addEventListener(
670+
'visibilitychange',
671+
event => {
672+
firstHiddenTime = Math.min(firstHiddenTime, event.timeStamp);
673+
},
674+
{ once: true },
675+
);
676+
677+
const updateLCP = (entry: PerformanceEntry) => {
678+
// Only include an LCP entry if the page wasn't hidden prior to
679+
// the entry being dispatched. This typically happens when a page is
680+
// loaded in a background tab.
681+
if (entry.startTime < firstHiddenTime) {
682+
// NOTE: the `startTime` value is a getter that returns the entry's
683+
// `renderTime` value, if available, or its `loadTime` value otherwise.
684+
// The `renderTime` value may not be available if the element is an image
685+
// that's loaded cross-origin without the `Timing-Allow-Origin` header.
686+
Tracing._lcp = {
687+
// @ts-ignore
688+
...(entry.id && { elementId: entry.id }),
689+
// @ts-ignore
690+
...(entry.size && { elementSize: entry.size }),
691+
value: entry.startTime,
692+
};
693+
}
694+
};
695+
696+
// Create a PerformanceObserver that calls `updateLCP` for each entry.
697+
const po = new PerformanceObserver(entryList => {
698+
entryList.getEntries().forEach(updateLCP);
699+
});
700+
701+
// Observe entries of type `largest-contentful-paint`, including buffered entries,
702+
// i.e. entries that occurred before calling `observe()` below.
703+
po.observe({
704+
buffered: true,
705+
// @ts-ignore
706+
type: 'largest-contentful-paint',
707+
});
708+
709+
Tracing._forceLCP = () => {
710+
po.takeRecords().forEach(updateLCP);
711+
};
712+
} catch (e) {
713+
// Do nothing if the browser doesn't support this API.
714+
}
715+
}
716+
635717
/**
636718
* Sets the status of the current active transaction (if there is one)
637719
*/

0 commit comments

Comments
 (0)