Skip to content

feat(browser): Add ElementTiming instrumentation and spans #16589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 1, 2025

Conversation

Lms24
Copy link
Member

@Lms24 Lms24 commented Jun 16, 2025

This PR adds support for instrumenting and sending spans from ElementTiming API entries. Just like with web vitals and long tasks/animation frames, we register a PerformanceObserver and extract spans from newly emitted ET entries.

Important:

  • We'll by default emit ET spans. Users can opt out by setting enableElementTiming: false in browserTracingIntegration
  • We for now only emit an ET span, if there is an active parent span. Happy to adjust this but considering the limitations from below I'm not sure if we actually want all spans after the pageload span. For now, we'll also emit spans on other transactions that pageload (most prominently navigation spans as well). We could also go the route of only sending until the first navigation as with standalone CLS/LCP spans. Happy to accept any direction we wanna take this.

Some noteworthy findings while working on this:

  • ET is only emitted for text and image nodes.
  • For image nodes, we get the loadTime which is the relative timestamp to the browser's timeOrigin, when the image finished loading. For text nodes, loadTime is always 0, since nothing needs to be loaded.
  • For all nodes, we get renderTime which is the relative timestamp to the browser's timeOrigin, when the node finished rendering (i.e. was painted by the browser).
  • In any case, we do not get start times for rendering or loading. Consequently, the span duration is
    • renderTime - loadTime for image nodes
    • 0 for text nodes
  • The span start time is:
    • timeOrigin + loadTime for image nodes
    • timeOrigin + renderTime for text nodes

In addition to the raw span and conventional attributes, we also collect a bunch of ET-specific attributes:

  • element.type - tag name of the element (e.g. img or p)
  • element.size - width x height of the element
  • element.render-time - entry.renderTime
  • element.load-time - entry.loadTime
  • element.url - url of the loaded image (undefined for text nodes)
  • element.identifier - the identifier passed to the elementtiming=identifier HTML attribute
  • element.paint-type - the node paint type (image-paint or text-paint)

also some additional sentry-sepcific attributes:

  • route - the route name, either from the active root span (if available) or from the scope's transactionName
  • sentry.span-start-time-source - the data point we used as the span start time

More than happy to adjust any of this logic or attribute names, based on review feedback :)

closes #13675
also ref #7292

@Lms24 Lms24 force-pushed the lms/feat-browser-elementtiming branch from 7680005 to 5379c13 Compare June 16, 2025 11:25
Copy link
Contributor

github-actions bot commented Jun 16, 2025

size-limit report 📦

Path Size % Change Change
@sentry/browser 23.99 kB - -
@sentry/browser - with treeshaking flags 23.76 kB - -
@sentry/browser (incl. Tracing) 39.59 kB +1.41% +547 B 🔺
@sentry/browser (incl. Tracing, Replay) 77.69 kB +0.68% +522 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 70.78 kB +0.74% +513 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 82.45 kB +0.63% +510 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 94.57 kB +0.57% +535 B 🔺
@sentry/browser (incl. Feedback) 40.75 kB - -
@sentry/browser (incl. sendFeedback) 28.7 kB - -
@sentry/browser (incl. FeedbackAsync) 33.59 kB - -
@sentry/react 25.76 kB - -
@sentry/react (incl. Tracing) 41.58 kB +1.4% +574 B 🔺
@sentry/vue 28.37 kB - -
@sentry/vue (incl. Tracing) 41.4 kB +1.3% +530 B 🔺
@sentry/svelte 24.01 kB - -
CDN Bundle 25.5 kB - -
CDN Bundle (incl. Tracing) 39.6 kB +1.23% +479 B 🔺
CDN Bundle (incl. Tracing, Replay) 75.5 kB +0.64% +479 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 80.96 kB +0.61% +490 B 🔺
CDN Bundle - uncompressed 74.5 kB - -
CDN Bundle (incl. Tracing) - uncompressed 117.63 kB +0.82% +956 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 231.68 kB +0.42% +956 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 244.5 kB +0.4% +956 B 🔺
@sentry/nextjs (client) 43.22 kB +1.35% +574 B 🔺
@sentry/sveltekit (client) 40.04 kB +1.36% +535 B 🔺
@sentry/node 154.31 kB +0.01% +2 B 🔺
@sentry/node - without tracing 98.64 kB +0.01% +2 B 🔺
@sentry/aws-serverless 124.4 kB +0.01% +2 B 🔺

View base workflow run

@Lms24 Lms24 self-assigned this Jun 16, 2025
@Lms24 Lms24 marked this pull request as ready for review June 16, 2025 14:43
// - `renderTime` if available (available for all entries, except 3rd party images, but these should be covered by `loadTime`, 0 otherwise)
// - `timestampInSeconds()` as a safeguard
// see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming/renderTime#cross-origin_image_render_time
const { spanStartTime, spanStartTimeSource } = loadTime
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be a helper or return tuples, will help reduce bundle size of spanStartTime and spanStartTimeSource being un-minifiable.

Comment on lines 81 to 83
const activeSpan = getActiveSpan();
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
const route = rootSpan ? spanToJSON(rootSpan).description : getCurrentScope().getScopeData().transactionName;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can move these outside of the loop to avoid having to keep calling spanToJSON

@AbhiPrasad AbhiPrasad self-assigned this Jun 23, 2025
s1gr1d added a commit that referenced this pull request Jun 30, 2025
This PR implements sending standalone LCP spans as an opt-in feature. 

Behaviour-wise, it's mostly aligned with our prior implementation of
sending CLS standalone spans (#13056):

- add an `_experiments.enableStandaloneLcpSpans` option and treat it as
opt-in
- keep collecting LCP values until users soft-navigate or the page is
hidden
- then, send the LCP span once
- adds all `lcp.*` span attributes as well as the `lcp` measurement to
the span

(depending on if we merge #16589 or this first, we might need to
readjust size limit)

closes #13063

---------

Co-authored-by: s1gr1d <[email protected]>
Co-authored-by: Sigrid Huemer <[email protected]>
cursor[bot]

This comment was marked as outdated.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: ElementTiming Timing Inconsistencies

ElementTiming spans for image-paint entries have inconsistent timing due to how loadTime and renderTime are used for span start time and duration calculation, especially when one of these values is 0 (e.g., for cross-origin images without CORS). This leads to:

  • Zero-duration spans starting at loadTime when renderTime is 0 but loadTime is present.
  • Spans ending at 2 * renderTime when loadTime is 0 but renderTime is present, as duration is incorrectly calculated as renderTime.

packages/browser-utils/src/metrics/elementTiming.ts#L71-L85

// see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming/renderTime#cross-origin_image_render_time
const [spanStartTime, spanStartTimeSource] = loadTime
? [msToSec(loadTime), 'load-time']
: renderTime
? [msToSec(renderTime), 'render-time']
: [timestampInSeconds(), 'entry-emission'];
const duration =
paintType === 'image-paint'
? // for image paints, we can acually get a duration because image-paint entries also have a `loadTime`
// and `renderTime`. `loadTime` is the time when the image finished loading and `renderTime` is the
// time when the image finished rendering.
msToSec(Math.max(0, (renderTime ?? 0) - (loadTime ?? 0)))
: // for `'text-paint'` entries, we can't get a duration because the `loadTime` is always zero.
0;

Fix in Cursor


Was this report helpful? Give feedback by reacting with 👍 or 👎

@s1gr1d s1gr1d merged commit f2f8e1f into develop Jul 1, 2025
129 of 130 checks passed
@s1gr1d s1gr1d deleted the lms/feat-browser-elementtiming branch July 1, 2025 14:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support Element Timing API in Browser SDK
3 participants