Skip to content

Commit 73fc872

Browse files
feat(ttd): Measure time to display on screen focus (#4665)
1 parent 1e804ce commit 73fc872

11 files changed

+234
-67
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
});
2323
```
2424

25+
- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665))
2526
- Add experimental flags `enableExperimentalViewRenderer` and `enableFastViewRendering` to enable up to 5x times more performance in Session Replay on iOS ([#4660](https://github.com/getsentry/sentry-react-native/pull/4660))
2627

2728
```js

packages/core/src/js/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export {
7979
startIdleNavigationSpan,
8080
startIdleSpan,
8181
getDefaultIdleNavigationSpanOptions,
82+
createTimeToFullDisplay,
83+
createTimeToInitialDisplay,
8284
} from './tracing';
8385

8486
export type { TimeToDisplayProps } from './tracing';

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

+54-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { Span,StartSpanOptions } from '@sentry/core';
22
import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan } from '@sentry/core';
33
import * as React from 'react';
4+
import { useState } from 'react';
45

56
import { isTurboModuleEnabled } from '../utils/environment';
67
import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin';
78
import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative';
8-
import type {RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types';
9+
import type { RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types';
910
import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils';
1011

1112
let nativeComponentMissingLogged = false;
@@ -296,3 +297,55 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl
296297

297298
setSpanDurationAsMeasurement('time_to_full_display', span);
298299
}
300+
301+
/**
302+
* Creates a new TimeToFullDisplay component which triggers the full display recording every time the component is focused.
303+
*/
304+
export function createTimeToFullDisplay({
305+
useFocusEffect,
306+
}: {
307+
/**
308+
* `@react-navigation/native` useFocusEffect hook.
309+
*/
310+
useFocusEffect: (callback: () => void) => void
311+
}): React.ComponentType<TimeToDisplayProps> {
312+
return createTimeToDisplay({ useFocusEffect, Component: TimeToFullDisplay });
313+
}
314+
315+
/**
316+
* Creates a new TimeToInitialDisplay component which triggers the initial display recording every time the component is focused.
317+
*/
318+
export function createTimeToInitialDisplay({
319+
useFocusEffect,
320+
}: {
321+
useFocusEffect: (callback: () => void) => void
322+
}): React.ComponentType<TimeToDisplayProps> {
323+
return createTimeToDisplay({ useFocusEffect, Component: TimeToInitialDisplay });
324+
}
325+
326+
function createTimeToDisplay({
327+
useFocusEffect,
328+
Component,
329+
}: {
330+
/**
331+
* `@react-navigation/native` useFocusEffect hook.
332+
*/
333+
useFocusEffect: (callback: () => void) => void;
334+
Component: typeof TimeToFullDisplay | typeof TimeToInitialDisplay;
335+
}): React.ComponentType<TimeToDisplayProps> {
336+
const TimeToDisplayWrapper = (props: TimeToDisplayProps): React.ReactElement => {
337+
const [focused, setFocused] = useState(false);
338+
339+
useFocusEffect(() => {
340+
setFocused(true);
341+
return () => {
342+
setFocused(false);
343+
};
344+
});
345+
346+
return <Component {...props} record={focused && props.record} />;
347+
};
348+
349+
TimeToDisplayWrapper.displayName = `TimeToDisplayWrapper`;
350+
return TimeToDisplayWrapper;
351+
}

samples/react-native/e2e/captureTransaction.test.ts samples/react-native/e2e/captureErrorsScreenTransaction.test.ts

+3-59
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,21 @@ import {
88
import { getItemOfTypeFrom } from './utils/event';
99
import { maestro } from './utils/maestro';
1010

11-
describe('Capture transaction', () => {
11+
describe('Capture Errors Screen Transaction', () => {
1212
let sentryServer = createSentryServer();
1313
sentryServer.start();
1414

1515
const getErrorsEnvelope = () =>
1616
sentryServer.getEnvelope(containingTransactionWithName('Errors'));
1717

18-
const getTrackerEnvelope = () =>
19-
sentryServer.getEnvelope(containingTransactionWithName('Tracker'));
20-
2118
beforeAll(async () => {
22-
const waitForTrackerTx = sentryServer.waitForEnvelope(
23-
containingTransactionWithName('Tracker'), // The last created and sent transaction
24-
);
2519
const waitForErrorsTx = sentryServer.waitForEnvelope(
2620
containingTransactionWithName('Errors'), // The last created and sent transaction
2721
);
2822

29-
await maestro('captureTransaction.test.yml');
23+
await maestro('captureErrorsScreenTransaction.test.yml');
3024

31-
await Promise.all([waitForTrackerTx, waitForErrorsTx]);
25+
await waitForErrorsTx;
3226
});
3327

3428
afterAll(async () => {
@@ -137,54 +131,4 @@ describe('Capture transaction', () => {
137131
}),
138132
);
139133
});
140-
141-
it('contains time to display measurements', async () => {
142-
const item = getItemOfTypeFrom<EventItem>(
143-
getTrackerEnvelope(),
144-
'transaction',
145-
);
146-
147-
expect(item?.[1]).toEqual(
148-
expect.objectContaining({
149-
measurements: expect.objectContaining({
150-
time_to_initial_display: {
151-
unit: 'millisecond',
152-
value: expect.any(Number),
153-
},
154-
time_to_full_display: {
155-
unit: 'millisecond',
156-
value: expect.any(Number),
157-
},
158-
}),
159-
}),
160-
);
161-
});
162-
163-
it('contains at least one xhr breadcrumb of request to the tracker endpoint', async () => {
164-
const item = getItemOfTypeFrom<EventItem>(
165-
getTrackerEnvelope(),
166-
'transaction',
167-
);
168-
169-
expect(item?.[1]).toEqual(
170-
expect.objectContaining({
171-
breadcrumbs: expect.arrayContaining([
172-
expect.objectContaining({
173-
category: 'xhr',
174-
data: {
175-
end_timestamp: expect.any(Number),
176-
method: 'GET',
177-
response_body_size: expect.any(Number),
178-
start_timestamp: expect.any(Number),
179-
status_code: expect.any(Number),
180-
url: expect.stringContaining('api.covid19api.com/summary'),
181-
},
182-
level: 'info',
183-
timestamp: expect.any(Number),
184-
type: 'http',
185-
}),
186-
]),
187-
}),
188-
);
189-
});
190134
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, beforeAll, expect, afterAll } from '@jest/globals';
2+
import { Envelope, EventItem } from '@sentry/core';
3+
import {
4+
createSentryServer,
5+
containingTransactionWithName,
6+
takeSecond,
7+
} from './utils/mockedSentryServer';
8+
9+
import { getItemOfTypeFrom } from './utils/event';
10+
import { maestro } from './utils/maestro';
11+
12+
describe('Capture Spaceflight News Screen Transaction', () => {
13+
let sentryServer = createSentryServer();
14+
sentryServer.start();
15+
16+
let envelopes: Envelope[] = [];
17+
18+
const getFirstTransactionEnvelopeItem = () =>
19+
getItemOfTypeFrom<EventItem>(envelopes[0], 'transaction');
20+
21+
const getSecondTransactionEnvelopeItem = () =>
22+
getItemOfTypeFrom<EventItem>(envelopes[1], 'transaction');
23+
24+
beforeAll(async () => {
25+
const containingNewsScreen = containingTransactionWithName(
26+
'SpaceflightNewsScreen',
27+
);
28+
const waitForSpaceflightNewsTx = sentryServer.waitForEnvelope(
29+
takeSecond(containingNewsScreen),
30+
);
31+
32+
await maestro('captureSpaceflightNewsScreenTransaction.test.yml');
33+
34+
await waitForSpaceflightNewsTx;
35+
36+
envelopes = sentryServer.getAllEnvelopes(containingNewsScreen);
37+
});
38+
39+
afterAll(async () => {
40+
await sentryServer.close();
41+
});
42+
43+
it('first received new screen transaction was created before the second visit', async () => {
44+
const first = getFirstTransactionEnvelopeItem();
45+
const second = getSecondTransactionEnvelopeItem();
46+
47+
expect(first?.[1].timestamp).toBeDefined();
48+
expect(second?.[1].timestamp).toBeDefined();
49+
expect(first![1].timestamp!).toBeLessThan(second![1].timestamp!);
50+
});
51+
52+
it('contains time to display measurements on the first visit', async () => {
53+
expectToContainTimeToDisplayMeasurements(getFirstTransactionEnvelopeItem());
54+
});
55+
56+
it('contains time to display measurements on the second visit', async () => {
57+
expectToContainTimeToDisplayMeasurements(
58+
getSecondTransactionEnvelopeItem(),
59+
);
60+
});
61+
62+
function expectToContainTimeToDisplayMeasurements(
63+
item: EventItem | undefined,
64+
) {
65+
expect(item?.[1]).toEqual(
66+
expect.objectContaining({
67+
measurements: expect.objectContaining({
68+
time_to_initial_display: {
69+
unit: 'millisecond',
70+
value: expect.any(Number),
71+
},
72+
time_to_full_display: {
73+
unit: 'millisecond',
74+
value: expect.any(Number),
75+
},
76+
}),
77+
}),
78+
);
79+
}
80+
81+
it('contains at least one xhr breadcrumb of request to the news endpoint', async () => {
82+
const item = getFirstTransactionEnvelopeItem();
83+
84+
expect(item?.[1]).toEqual(
85+
expect.objectContaining({
86+
breadcrumbs: expect.arrayContaining([
87+
expect.objectContaining({
88+
category: 'xhr',
89+
data: {
90+
end_timestamp: expect.any(Number),
91+
method: 'GET',
92+
response_body_size: expect.any(Number),
93+
start_timestamp: expect.any(Number),
94+
status_code: expect.any(Number),
95+
url: expect.stringContaining(
96+
'api.spaceflightnewsapi.net/v4/articles',
97+
),
98+
},
99+
level: 'info',
100+
timestamp: expect.any(Number),
101+
type: 'http',
102+
}),
103+
]),
104+
}),
105+
);
106+
});
107+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
appId: io.sentry.reactnative.sample
2+
---
3+
- launchApp:
4+
# We expect cold start
5+
clearState: true
6+
stopApp: true
7+
arguments:
8+
isE2ETest: true
9+
10+
# For unknown reasons tapOn: "Performance" does not work on iOS
11+
- tapOn:
12+
id: "performance-tab-icon"
13+
- tapOn: "Open Spaceflight News"
14+
15+
- scrollUntilVisible:
16+
element: "Load More Articles"
17+
# On iOS the visibility is resolved when the button only peaks from the bottom tabs
18+
# this causes Maestro to click the bottom tab instead of the button
19+
# thus the extra scroll is needed to make the button visible
20+
- scroll
21+
- tapOn: "Load More Articles"
22+
- scrollUntilVisible:
23+
element: "Load More Articles"
24+
25+
- tapOn:
26+
id: "errors-tab-icon"
27+
28+
# The tab keeps News Screen open, but the data are updated on the next visit
29+
- tapOn:
30+
id: "performance-tab-icon"

samples/react-native/e2e/utils/mockedSentryServer.ts

+25-4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function createSentryServer({ port = 8961 } = {}): {
1818
close: () => Promise<void>;
1919
start: () => void;
2020
getEnvelope: (predicate: (envelope: Envelope) => boolean) => Envelope;
21+
getAllEnvelopes: (predicate: (envelope: Envelope) => boolean) => Envelope[];
2122
} {
2223
const nextRequestCallbacks: (typeof onNextRequestCallback)[] = [];
2324
let onNextRequestCallback: (request: RecordedRequest) => void = (
@@ -55,6 +56,12 @@ export function createSentryServer({ port = 8961 } = {}): {
5556
});
5657
});
5758

59+
const getAllEnvelopes = (predicate: (envelope: Envelope) => boolean) => {
60+
return requests
61+
.filter(request => request.envelope && predicate(request.envelope))
62+
.map(request => request.envelope);
63+
};
64+
5865
return {
5966
start: () => {
6067
server.listen(port);
@@ -82,16 +89,14 @@ export function createSentryServer({ port = 8961 } = {}): {
8289
});
8390
},
8491
getEnvelope: (predicate: (envelope: Envelope) => boolean) => {
85-
const envelope = requests.find(
86-
request => request.envelope && predicate(request.envelope),
87-
)?.envelope;
88-
92+
const [envelope] = getAllEnvelopes(predicate);
8993
if (!envelope) {
9094
throw new Error('Envelope not found');
9195
}
9296

9397
return envelope;
9498
},
99+
getAllEnvelopes,
95100
};
96101
}
97102

@@ -131,6 +136,22 @@ export function containingTransactionWithName(name: string) {
131136
);
132137
}
133138

139+
export function takeSecond(predicate: (envelope: Envelope) => boolean) {
140+
const take = 2;
141+
let counter = 0;
142+
return (envelope: Envelope) => {
143+
if (predicate(envelope)) {
144+
counter++;
145+
}
146+
147+
if (counter === take) {
148+
return true;
149+
}
150+
151+
return false;
152+
};
153+
}
154+
134155
export function itemBodyIsEvent(itemBody: EnvelopeItem[1]): itemBody is Event {
135156
return typeof itemBody === 'object' && 'event_id' in itemBody;
136157
}

samples/react-native/src/App.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const isMobileOs = Platform.OS === 'android' || Platform.OS === 'ios';
5252
const reactNavigationIntegration = Sentry.reactNavigationIntegration({
5353
routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms
5454
enableTimeToInitialDisplay: isMobileOs,
55-
ignoreEmptyBackNavigationTransactions: true,
55+
ignoreEmptyBackNavigationTransactions: false,
5656
enableTimeToInitialDisplayForPreloadedRoutes: true,
5757
});
5858

samples/react-native/src/Screens/SpaceflightNewsScreen.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,13 @@ export default function NewsScreen() {
4747

4848
useFocusEffect(
4949
useCallback(() => {
50+
if (articles.length) {
51+
console.log('Articles are already loaded');
52+
return;
53+
}
54+
5055
fetchArticles(1, true);
51-
}, [])
56+
}, [articles])
5257
);
5358

5459
const handleLoadMore = () => {

0 commit comments

Comments
 (0)