Skip to content

Commit 3df6c17

Browse files
antonisclaude
andcommitted
fix(tracing): Use root span ID for imperative TTFD and guard duplicate spans
- Use `getRootSpan` in `reportFullyDisplayed()` so the stored key matches the integration's `rootSpanId` lookup, even when called inside a nested child span. - Add early return in `addTimeToFullDisplay` when an existing ok TTFD span is found, preventing duplicate `ui.load.full_display` spans. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ca7eefc commit 3df6c17

3 files changed

Lines changed: 46 additions & 3 deletions

File tree

packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ async function addTimeToFullDisplay({
236236

237237
let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY);
238238

239+
if (ttfdSpan && (ttfdSpan.status === undefined || ttfdSpan.status === 'ok')) {
240+
debug.log(`[${INTEGRATION_NAME}] Ttfd span already exists and is ok.`, ttfdSpan);
241+
return ttfdSpan;
242+
}
243+
239244
let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds;
240245
const ttfdIsBeforeTtid = ttidSpan.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp;
241246
if (ttfdIsBeforeTtid && ttidSpan.timestamp) {

packages/core/src/js/tracing/timetodisplay.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
debug,
66
fill,
77
getActiveSpan,
8+
getRootSpan,
89
getSpanDescendants,
910
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1011
SPAN_STATUS_ERROR,
@@ -439,9 +440,10 @@ export function reportFullyDisplayed(): void {
439440
debug.warn('[TimeToDisplay] No active span found to report full display.');
440441
return;
441442
}
442-
const spanId = spanToJSON(activeSpan).span_id;
443-
if (spanId && !_imperativeTtfdTimestamps.has(spanId)) {
444-
_imperativeTtfdTimestamps.set(spanId, Date.now() / 1000);
443+
const rootSpan = getRootSpan(activeSpan);
444+
const rootSpanId = spanToJSON(rootSpan).span_id;
445+
if (rootSpanId && !_imperativeTtfdTimestamps.has(rootSpanId)) {
446+
_imperativeTtfdTimestamps.set(rootSpanId, Date.now() / 1000);
445447
}
446448
}
447449

packages/core/test/tracing/timetodisplay.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getIsolationScope,
88
setCurrentClient,
99
spanToJSON,
10+
startSpan,
1011
startSpanManual,
1112
} from '@sentry/core';
1213

@@ -1117,6 +1118,41 @@ describe('reportFullyDisplayed', () => {
11171118
expect(getFullDisplaySpanJSON(client.event!.spans!)).toBeUndefined();
11181119
});
11191120

1121+
test('works when called inside a nested child span', async () => {
1122+
const ttidTimestamp = nowInSeconds();
1123+
1124+
startSpanManual(
1125+
{
1126+
name: 'Root Manual Span',
1127+
startTime: secondAgoTimestampMs(),
1128+
},
1129+
(activeSpan: Span | undefined) => {
1130+
render(<TimeToInitialDisplay record={true} />);
1131+
1132+
mockRecordedTimeToDisplay({
1133+
ttid: {
1134+
[spanToJSON(activeSpan).span_id]: ttidTimestamp,
1135+
},
1136+
});
1137+
1138+
startSpan({ name: 'child-span' }, () => {
1139+
reportFullyDisplayed();
1140+
});
1141+
1142+
activeSpan?.end();
1143+
},
1144+
);
1145+
1146+
await jest.runOnlyPendingTimersAsync();
1147+
await client.flush();
1148+
1149+
const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!);
1150+
expect(ttfdSpan).toBeDefined();
1151+
expect(ttfdSpan!.op).toBe('ui.load.full_display');
1152+
expect(ttfdSpan!.status).toBe('ok');
1153+
expectFullDisplayMeasurementOnSpan(client.event!);
1154+
});
1155+
11201156
test('second call is ignored', async () => {
11211157
startSpanManual(
11221158
{

0 commit comments

Comments
 (0)