Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,24 @@ sentryTest(

// Check image-fast span (this is served with a 100ms delay)
const imageFastSpan = elementTimingSpans?.find(({ description }) => description === 'element[image-fast]');
const imageFastRenderTime = imageFastSpan?.data['element.render_time'];
const imageFastLoadTime = imageFastSpan?.data['element.load_time'];
const imageFastRenderTime = imageFastSpan?.data['ui.element.render_time'];
const imageFastLoadTime = imageFastSpan?.data['ui.element.load_time'];
const duration = imageFastSpan!.timestamp! - imageFastSpan!.start_timestamp;

expect(imageFastSpan).toBeDefined();
expect(imageFastSpan?.data).toEqual({
'sentry.op': 'ui.elementtiming',
'sentry.origin': 'auto.ui.browser.elementtiming',
'sentry.source': 'component',
'sentry.span_start_time_source': 'load-time',
'element.id': 'image-fast-id',
'element.identifier': 'image-fast',
'element.type': 'img',
'element.size': '600x179',
'element.url': 'https://sentry-test-site.example/path/to/image-fast.png',
'element.render_time': expect.any(Number),
'element.load_time': expect.any(Number),
'element.paint_type': 'image-paint',
'ui.element.id': 'image-fast-id',
'ui.element.identifier': 'image-fast',
'ui.element.type': 'img',
'ui.element.width': 600,
'ui.element.height': 179,
'ui.element.url': 'https://sentry-test-site.example/path/to/image-fast.png',
'ui.element.render_time': expect.any(Number),
'ui.element.load_time': expect.any(Number),
'ui.element.paint_type': 'image-paint',
'sentry.transaction_name': '/index.html',
});
expect(imageFastRenderTime).toBeGreaterThan(90);
Expand All @@ -55,22 +55,23 @@ sentryTest(
expect(duration).toBeLessThan(20);

// Check text1 span
const text1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'text1');
const text1RenderTime = text1Span?.data['element.render_time'];
const text1LoadTime = text1Span?.data['element.load_time'];
const text1Span = elementTimingSpans?.find(({ data }) => data?.['ui.element.identifier'] === 'text1');
const text1RenderTime = text1Span?.data['ui.element.render_time'];
const text1LoadTime = text1Span?.data['ui.element.load_time'];
const text1Duration = text1Span!.timestamp! - text1Span!.start_timestamp;
expect(text1Span).toBeDefined();
expect(text1Span?.data).toEqual({
'sentry.op': 'ui.elementtiming',
'sentry.origin': 'auto.ui.browser.elementtiming',
'sentry.source': 'component',
'sentry.span_start_time_source': 'render-time',
'element.id': 'text1-id',
'element.identifier': 'text1',
'element.type': 'p',
'element.render_time': expect.any(Number),
'element.load_time': expect.any(Number),
'element.paint_type': 'text-paint',
'ui.element.id': 'text1-id',
'ui.element.identifier': 'text1',
'ui.element.type': 'p',
'ui.element.width': 0,
'ui.element.height': 0,
'ui.element.render_time': expect.any(Number),
'ui.element.load_time': expect.any(Number),
'ui.element.paint_type': 'text-paint',
'sentry.transaction_name': '/index.html',
});
expect(text1RenderTime).toBeGreaterThan(0);
Expand All @@ -80,35 +81,35 @@ sentryTest(
expect(text1Duration).toBe(0);

// Check button1 span (no need for a full assertion)
const button1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'button1');
const button1Span = elementTimingSpans?.find(({ data }) => data?.['ui.element.identifier'] === 'button1');
expect(button1Span).toBeDefined();
expect(button1Span?.data).toMatchObject({
'element.identifier': 'button1',
'element.type': 'button',
'element.paint_type': 'text-paint',
'ui.element.identifier': 'button1',
'ui.element.type': 'button',
'ui.element.paint_type': 'text-paint',
'sentry.transaction_name': '/index.html',
});

// Check image-slow span
const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow');
const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['ui.element.identifier'] === 'image-slow');
expect(imageSlowSpan).toBeDefined();
expect(imageSlowSpan?.data).toEqual({
'element.id': '',
'element.identifier': 'image-slow',
'element.type': 'img',
'element.size': '600x179',
'element.url': 'https://sentry-test-site.example/path/to/image-slow.png',
'element.paint_type': 'image-paint',
'element.render_time': expect.any(Number),
'element.load_time': expect.any(Number),
'ui.element.id': '',
'ui.element.identifier': 'image-slow',
'ui.element.type': 'img',
'ui.element.width': 600,
'ui.element.height': 179,
'ui.element.url': 'https://sentry-test-site.example/path/to/image-slow.png',
'ui.element.paint_type': 'image-paint',
'ui.element.render_time': expect.any(Number),
'ui.element.load_time': expect.any(Number),
'sentry.op': 'ui.elementtiming',
'sentry.origin': 'auto.ui.browser.elementtiming',
'sentry.source': 'component',
'sentry.span_start_time_source': 'load-time',
'sentry.transaction_name': '/index.html',
});
const imageSlowRenderTime = imageSlowSpan?.data['element.render_time'];
const imageSlowLoadTime = imageSlowSpan?.data['element.load_time'];
const imageSlowRenderTime = imageSlowSpan?.data['ui.element.render_time'];
const imageSlowLoadTime = imageSlowSpan?.data['ui.element.load_time'];
const imageSlowDuration = imageSlowSpan!.timestamp! - imageSlowSpan!.start_timestamp;
expect(imageSlowRenderTime).toBeGreaterThan(1400);
expect(imageSlowRenderTime).toBeLessThan(2000);
Expand All @@ -118,25 +119,25 @@ sentryTest(
expect(imageSlowDuration).toBeLessThan(20);

// Check lazy-image span
const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-image');
const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['ui.element.identifier'] === 'lazy-image');
expect(lazyImageSpan).toBeDefined();
expect(lazyImageSpan?.data).toEqual({
'element.id': '',
'element.identifier': 'lazy-image',
'element.type': 'img',
'element.size': '600x179',
'element.url': 'https://sentry-test-site.example/path/to/image-lazy.png',
'element.paint_type': 'image-paint',
'element.render_time': expect.any(Number),
'element.load_time': expect.any(Number),
'ui.element.id': '',
'ui.element.identifier': 'lazy-image',
'ui.element.type': 'img',
'ui.element.width': 600,
'ui.element.height': 179,
'ui.element.url': 'https://sentry-test-site.example/path/to/image-lazy.png',
'ui.element.paint_type': 'image-paint',
'ui.element.render_time': expect.any(Number),
'ui.element.load_time': expect.any(Number),
'sentry.op': 'ui.elementtiming',
'sentry.origin': 'auto.ui.browser.elementtiming',
'sentry.source': 'component',
'sentry.span_start_time_source': 'load-time',
'sentry.transaction_name': '/index.html',
});
const lazyImageRenderTime = lazyImageSpan?.data['element.render_time'];
const lazyImageLoadTime = lazyImageSpan?.data['element.load_time'];
const lazyImageRenderTime = lazyImageSpan?.data['ui.element.render_time'];
const lazyImageLoadTime = lazyImageSpan?.data['ui.element.load_time'];
const lazyImageDuration = lazyImageSpan!.timestamp! - lazyImageSpan!.start_timestamp;
expect(lazyImageRenderTime).toBeGreaterThan(1000);
expect(lazyImageRenderTime).toBeLessThan(1500);
Expand All @@ -146,15 +147,15 @@ sentryTest(
expect(lazyImageDuration).toBeLessThan(20);

// Check lazy-text span
const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-text');
const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['ui.element.identifier'] === 'lazy-text');
expect(lazyTextSpan?.data).toMatchObject({
'element.id': '',
'element.identifier': 'lazy-text',
'element.type': 'p',
'ui.element.id': '',
'ui.element.identifier': 'lazy-text',
'ui.element.type': 'p',
'sentry.transaction_name': '/index.html',
});
const lazyTextRenderTime = lazyTextSpan?.data['element.render_time'];
const lazyTextLoadTime = lazyTextSpan?.data['element.load_time'];
const lazyTextRenderTime = lazyTextSpan?.data['ui.element.render_time'];
const lazyTextLoadTime = lazyTextSpan?.data['ui.element.load_time'];
const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp;
expect(lazyTextRenderTime).toBeGreaterThan(1000);
expect(lazyTextRenderTime).toBeLessThan(1500);
Expand Down Expand Up @@ -202,15 +203,15 @@ sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl,

// Image started loading after navigation, but render-time and load-time still start from the time origin
// of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec)
expect((imageSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
expect((imageSpan!.data['ui.element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
navigationStartTime,
);
expect((imageSpan!.data['element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
expect((imageSpan!.data['ui.element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
navigationStartTime,
);

expect(textSpan?.data['element.load_time']).toBe(0);
expect((textSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
expect(textSpan?.data['ui.element.load_time']).toBe(0);
expect((textSpan!.data['ui.element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
navigationStartTime,
);
});
Expand Down
100 changes: 42 additions & 58 deletions packages/browser-utils/src/metrics/elementTiming.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { SpanAttributes } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
getActiveSpan,
Expand All @@ -9,13 +8,12 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
spanToJSON,
startSpan,
timestampInSeconds,
} from '@sentry/core';
import { addPerformanceInstrumentationHandler } from './instrument';
import { getBrowserPerformanceAPI, msToSec } from './utils';

// ElementTiming interface based on the W3C spec
interface PerformanceElementTiming extends PerformanceEntry {
export interface PerformanceElementTiming extends PerformanceEntry {
renderTime: number;
loadTime: number;
intersectionRect: DOMRectReadOnly;
Expand Down Expand Up @@ -49,72 +47,58 @@ export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }):
? spanToJSON(rootSpan).description
: getCurrentScope().getScopeData().transactionName;

const timeOrigin = browserPerformanceTimeOrigin();
if (!timeOrigin) {
// If there's no reliable time origin, we might as well not record the spans here
// as their data will be unreliable.
return;
}

entries.forEach(entry => {
const elementEntry = entry as PerformanceElementTiming;
const { naturalWidth, naturalHeight, url, identifier, name, renderTime, loadTime, startTime, id, element } =
entry as PerformanceElementTiming;

// Skip entries without identifier (elementtiming attribute)
if (!elementEntry.identifier) {
// Skip:
// - entries without identifier (elementtiming attribute)
// - entries without startTime (e.g. 3rd party Image nodes w/o Timing-Allow-Origin header returned instantly from cache)
if (!identifier || !startTime) {
return;
}

// `name` contains the type of the element paint. Can be `'image-paint'` or `'text-paint'`.
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming#instance_properties
const paintType = elementEntry.name as 'image-paint' | 'text-paint' | undefined;

const renderTime = elementEntry.renderTime;
const loadTime = elementEntry.loadTime;

// starting the span at:
// - `loadTime` if available (should be available for all "image-paint" entries, 0 otherwise)
// - `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
? [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;

const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.elementtiming',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.elementtiming',
// name must be user-entered, so we can assume low cardinality
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
// recording the source of the span start time, as it varies depending on available data
'sentry.span_start_time_source': spanStartTimeSource,
'sentry.transaction_name': transactionName,
'element.id': elementEntry.id,
'element.type': elementEntry.element?.tagName?.toLowerCase() || 'unknown',
'element.size':
elementEntry.naturalWidth && elementEntry.naturalHeight
? `${elementEntry.naturalWidth}x${elementEntry.naturalHeight}`
: undefined,
'element.render_time': renderTime,
'element.load_time': loadTime,
// `url` is `0`(number) for text paints (hence we fall back to undefined)
'element.url': elementEntry.url || undefined,
'element.identifier': elementEntry.identifier,
'element.paint_type': paintType,
};
// Span durations
// Case 1: Text nodes: point-in-time spans at `renderTime`
// Case 2: Image nodes: spans from `loadTime` to `renderTime` (i.e. "effective render time")
// Case 3: 3rd party Image nodes w/o Timing-Allow-Origin header: point-in-time spans at `loadTime`
// Case 4: Both times are 0 is already covered by the `startTime` check above
const relativeStartTime = loadTime > 0 ? loadTime : renderTime;
const relativeEndTime = renderTime > 0 ? renderTime : loadTime;
Comment on lines +73 to +74
Copy link
Member

Choose a reason for hiding this comment

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

Q: I checked the startTime property and I think I'm getting confused on how the span duration is being determined, it seems like it represents the opposite of what we are doing here for the start time?

I think what you have here make sense, but was wondering if it is consistently correct.

Copy link
Member Author

@Lms24 Lms24 Mar 17, 2026

Choose a reason for hiding this comment

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

If I understand the definition of startTime correctly, the prop always returns the "longest" time available ( i.e. renderTime if !== 0, otherwise loadTime. My idea for using the shorter of the two is that we can show a timespan for images representing the relative rendering time. Does this make sense? Happy to switch to startTime, but unless I'm missing something, we'd always generate point-in-time spans. WDYT?

Copy link
Member

@logaretm logaretm Mar 18, 2026

Choose a reason for hiding this comment

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

Never been so confused by a spec lol, okay so here is what I understand:

For text: we don't know the startTime of the span, we know the render time tho. So in this case relativeStartTime = renderTime and relativeEndTime = renderTime. Which makes them equal.

For images: Each value tells a different story because they aren't related, load time is the timestamp it took to be loaded and attached to the element, while rendering should happen after. So kinda each represent an "endTime" of two different spans, a load span and a render span, in case of text we don't have a "load" span.

The question here is if renderTime and loadTime are both end times for each respective span, what's the start time? I don't think we have that information here. Which makes me think point-in-time spans here make sense for these cases, or a metric even.

There could be a long winded way to guess the start time with resource timing and try to match the resource with the image element but ehhh, I don't know if it is worth it.

Does that make any sense or did I confuse myself 😂 I will approve anyways to not drag this any longer, but just wanted to see what you think first.

Copy link
Member Author

@Lms24 Lms24 Mar 18, 2026

Choose a reason for hiding this comment

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

For text: ... Which makes them equal.

Yes, correct, text element timing will always be duration 0 (for better or worse)

For images: ... Each value tells a different story because they aren't related

Hmm I just assumed that rendering would happen right after loading completed, so my thinking was, instead of having yet again no duration at all for the span, we use the relative rendering time (i.e. renderTime - loadTime) for the span duration. But maybe that assumption is wrong?

... So kinda each represent an "endTime" of two different spans

So you mean we should create two spans? We could do that (tho both would have duration 0 then) but it would increase quota usage/billing accordingly.

The question here is if renderTime and loadTime are both end times for each respective span, what's the start time?

I think theoretically, it should be performance.timeOrigin, right? but if we let the span start from there, it would mess up the trace waterfall if an elementTiming span was added to e.g. a navigation span, rather than the pageload. I think the root issue is that ElementTiming, like classic web vitals, just doesn't play well with
SPAs :(

Which makes me think point-in-time spans here make sense for these cases

Yeah, the SPA limitation basically was my reasoning for rather having point-in-time spans than potentially super long ones.

There could be a long winded way to guess the start time with resource timing and try to match the resource with the image element but ehhh, I don't know if it is worth it.

Interesting idea! We could give it a try though I believe the timing would be weird again, simply because ElementTiming measures its values from performance.timeOrigin. So if we find out when we actually started loading the image via the ResourceTiming, and let the span start from this time on, the semantics around the span duration would somehow not tell us much either (?). Also, assuming we get a ResourceTiming entry, our SDK should be able to collect a resource.img span anyway.

or a metric even.

Hmm that's a good point! Radical idea: What if we just delete all of the element timing span logic and create a browserMetricsIntegration (or a more specific one)? Tracking Element timing spans wasn't documented, it never worked and the only thing we'd need to leave is the enableElementTiming option.
Though, before we do this, we need to think which metrics we'd actually collect.

Copy link
Member

@logaretm logaretm Mar 18, 2026

Choose a reason for hiding this comment

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

Radical idea: What if we just delete all of the element timing span logic and create a browserMetricsIntegration

I'm in favor of this actually, for one we start breaking up the tracing integration, secondly, does that mean we could re-work into a metric instead? Happy to take that on if you have too much on your plate.

I think you are right on the other points, the span timing no matter where we assign its start and end doesn't make full sense, and then matching the same resource in two event pipelines is just asking for non-ending spans or spans with no start time.

Copy link
Member Author

Choose a reason for hiding this comment

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

@logaretm if you could take a look at this and think about how we could track ET as metrics instead of spans that would be greatly appreciated! I'll hold off from merging this for the moment.


startSpan(
{
name: `element[${elementEntry.identifier}]`,
attributes,
startTime: spanStartTime,
name: `element[${identifier}]`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.elementtiming',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.elementtiming',
// name must be user-entered, so we can assume low cardinality
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
'sentry.transaction_name': transactionName,
'ui.element.id': id,
'ui.element.type': element?.tagName?.toLowerCase() || 'unknown',
'ui.element.width': naturalWidth,
'ui.element.height': naturalHeight,
'ui.element.render_time': renderTime,
'ui.element.load_time': loadTime,
// `url` is `0`(number) for text paints (hence we fall back to undefined)
'ui.element.url': url || undefined,
'ui.element.identifier': identifier,
// `name` contains the type of the element paint. Can be `'image-paint'` or `'text-paint'`.
'ui.element.paint_type': name,
},
startTime: msToSec(timeOrigin + relativeStartTime),
onlyIfParent: true,
},
span => {
span.end(spanStartTime + duration);
span.end(msToSec(timeOrigin + relativeEndTime));
},
);
});
Expand Down
Loading
Loading