Skip to content
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

feat(ttd): Measure time to display on screen focus #4665

Merged
merged 16 commits into from
Mar 25, 2025
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
});
```

- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665))
- 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))

```js
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export {
startIdleNavigationSpan,
startIdleSpan,
getDefaultIdleNavigationSpanOptions,
createTimeToFullDisplay,
createTimeToInitialDisplay,
} from './tracing';

export type { TimeToDisplayProps } from './tracing';
Expand Down
55 changes: 54 additions & 1 deletion packages/core/src/js/tracing/timetodisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Span,StartSpanOptions } from '@sentry/core';
import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan } from '@sentry/core';
import * as React from 'react';
import { useState } from 'react';

import { isTurboModuleEnabled } from '../utils/environment';
import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin';
import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative';
import type {RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types';
import type { RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types';
import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils';

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

setSpanDurationAsMeasurement('time_to_full_display', span);
}

/**
* Creates a new TimeToFullDisplay component which triggers the full display recording every time the component is focused.
*/
export function createTimeToFullDisplay({
useFocusEffect,
}: {
/**
* `@react-navigation/native` useFocusEffect hook.
*/
useFocusEffect: (callback: () => void) => void
}): React.ComponentType<TimeToDisplayProps> {
return createTimeToDisplay({ useFocusEffect, Component: TimeToFullDisplay });
}

/**
* Creates a new TimeToInitialDisplay component which triggers the initial display recording every time the component is focused.
*/
export function createTimeToInitialDisplay({
useFocusEffect,
}: {
useFocusEffect: (callback: () => void) => void
}): React.ComponentType<TimeToDisplayProps> {
return createTimeToDisplay({ useFocusEffect, Component: TimeToInitialDisplay });
}

function createTimeToDisplay({
useFocusEffect,
Component,
}: {
/**
* `@react-navigation/native` useFocusEffect hook.
*/
useFocusEffect: (callback: () => void) => void;
Component: typeof TimeToFullDisplay | typeof TimeToInitialDisplay;
}): React.ComponentType<TimeToDisplayProps> {
const TimeToDisplayWrapper = (props: TimeToDisplayProps): React.ReactElement => {
const [focused, setFocused] = useState(false);

useFocusEffect(() => {
setFocused(true);
return () => {
setFocused(false);
};
});

return <Component {...props} record={focused && props.record} />;
};

TimeToDisplayWrapper.displayName = `TimeToDisplayWrapper`;
return TimeToDisplayWrapper;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,21 @@ import {
import { getItemOfTypeFrom } from './utils/event';
import { maestro } from './utils/maestro';

describe('Capture transaction', () => {
describe('Capture Errors Screen Transaction', () => {
let sentryServer = createSentryServer();
sentryServer.start();

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

const getTrackerEnvelope = () =>
sentryServer.getEnvelope(containingTransactionWithName('Tracker'));

beforeAll(async () => {
const waitForTrackerTx = sentryServer.waitForEnvelope(
containingTransactionWithName('Tracker'), // The last created and sent transaction
);
const waitForErrorsTx = sentryServer.waitForEnvelope(
containingTransactionWithName('Errors'), // The last created and sent transaction
);

await maestro('captureTransaction.test.yml');
await maestro('captureErrorsScreenTransaction.test.yml');

await Promise.all([waitForTrackerTx, waitForErrorsTx]);
await waitForErrorsTx;
});

afterAll(async () => {
Expand Down Expand Up @@ -137,54 +131,4 @@ describe('Capture transaction', () => {
}),
);
});

it('contains time to display measurements', async () => {
const item = getItemOfTypeFrom<EventItem>(
getTrackerEnvelope(),
'transaction',
);

expect(item?.[1]).toEqual(
expect.objectContaining({
measurements: expect.objectContaining({
time_to_initial_display: {
unit: 'millisecond',
value: expect.any(Number),
},
time_to_full_display: {
unit: 'millisecond',
value: expect.any(Number),
},
}),
}),
);
});

it('contains at least one xhr breadcrumb of request to the tracker endpoint', async () => {
const item = getItemOfTypeFrom<EventItem>(
getTrackerEnvelope(),
'transaction',
);

expect(item?.[1]).toEqual(
expect.objectContaining({
breadcrumbs: expect.arrayContaining([
expect.objectContaining({
category: 'xhr',
data: {
end_timestamp: expect.any(Number),
method: 'GET',
response_body_size: expect.any(Number),
start_timestamp: expect.any(Number),
status_code: expect.any(Number),
url: expect.stringContaining('api.covid19api.com/summary'),
},
level: 'info',
timestamp: expect.any(Number),
type: 'http',
}),
]),
}),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, beforeAll, expect, afterAll } from '@jest/globals';
import { Envelope, EventItem } from '@sentry/core';
import {
createSentryServer,
containingTransactionWithName,
takeSecond,
} from './utils/mockedSentryServer';

import { getItemOfTypeFrom } from './utils/event';
import { maestro } from './utils/maestro';

describe('Capture Spaceflight News Screen Transaction', () => {
let sentryServer = createSentryServer();
sentryServer.start();

let envelopes: Envelope[] = [];

const getFirstTransactionEnvelopeItem = () =>
getItemOfTypeFrom<EventItem>(envelopes[0], 'transaction');

const getSecondTransactionEnvelopeItem = () =>
getItemOfTypeFrom<EventItem>(envelopes[1], 'transaction');

beforeAll(async () => {
const containingNewsScreen = containingTransactionWithName(
'SpaceflightNewsScreen',
);
const waitForSpaceflightNewsTx = sentryServer.waitForEnvelope(
takeSecond(containingNewsScreen),
);

await maestro('captureSpaceflightNewsScreenTransaction.test.yml');

await waitForSpaceflightNewsTx;

envelopes = sentryServer.getAllEnvelopes(containingNewsScreen);
});

afterAll(async () => {
await sentryServer.close();
});

it('first received new screen transaction was created before the second visit', async () => {
const first = getFirstTransactionEnvelopeItem();
const second = getSecondTransactionEnvelopeItem();

expect(first?.[1].timestamp).toBeDefined();
expect(second?.[1].timestamp).toBeDefined();
expect(first![1].timestamp!).toBeLessThan(second![1].timestamp!);
});

it('contains time to display measurements on the first visit', async () => {
expectToContainTimeToDisplayMeasurements(getFirstTransactionEnvelopeItem());
});

it('contains time to display measurements on the second visit', async () => {
expectToContainTimeToDisplayMeasurements(
getSecondTransactionEnvelopeItem(),
);
});

function expectToContainTimeToDisplayMeasurements(
item: EventItem | undefined,
) {
expect(item?.[1]).toEqual(
expect.objectContaining({
measurements: expect.objectContaining({
time_to_initial_display: {
unit: 'millisecond',
value: expect.any(Number),
},
time_to_full_display: {
unit: 'millisecond',
value: expect.any(Number),
},
}),
}),
);
}

it('contains at least one xhr breadcrumb of request to the news endpoint', async () => {
const item = getFirstTransactionEnvelopeItem();

expect(item?.[1]).toEqual(
expect.objectContaining({
breadcrumbs: expect.arrayContaining([
expect.objectContaining({
category: 'xhr',
data: {
end_timestamp: expect.any(Number),
method: 'GET',
response_body_size: expect.any(Number),
start_timestamp: expect.any(Number),
status_code: expect.any(Number),
url: expect.stringContaining(
'api.spaceflightnewsapi.net/v4/articles',
),
},
level: 'info',
timestamp: expect.any(Number),
type: 'http',
}),
]),
}),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
appId: io.sentry.reactnative.sample
---
- launchApp:
# We expect cold start
clearState: true
stopApp: true
arguments:
isE2ETest: true

# For unknown reasons tapOn: "Performance" does not work on iOS
- tapOn:
id: "performance-tab-icon"
- tapOn: "Open Spaceflight News"

- scrollUntilVisible:
element: "Load More Articles"
# On iOS the visibility is resolved when the button only peaks from the bottom tabs
# this causes Maestro to click the bottom tab instead of the button
# thus the extra scroll is needed to make the button visible
- scroll
- tapOn: "Load More Articles"
- scrollUntilVisible:
element: "Load More Articles"

- tapOn:
id: "errors-tab-icon"

# The tab keeps News Screen open, but the data are updated on the next visit
- tapOn:
id: "performance-tab-icon"
29 changes: 25 additions & 4 deletions samples/react-native/e2e/utils/mockedSentryServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function createSentryServer({ port = 8961 } = {}): {
close: () => Promise<void>;
start: () => void;
getEnvelope: (predicate: (envelope: Envelope) => boolean) => Envelope;
getAllEnvelopes: (predicate: (envelope: Envelope) => boolean) => Envelope[];
} {
const nextRequestCallbacks: (typeof onNextRequestCallback)[] = [];
let onNextRequestCallback: (request: RecordedRequest) => void = (
Expand Down Expand Up @@ -55,6 +56,12 @@ export function createSentryServer({ port = 8961 } = {}): {
});
});

const getAllEnvelopes = (predicate: (envelope: Envelope) => boolean) => {
return requests
.filter(request => request.envelope && predicate(request.envelope))
.map(request => request.envelope);
};

return {
start: () => {
server.listen(port);
Expand Down Expand Up @@ -82,16 +89,14 @@ export function createSentryServer({ port = 8961 } = {}): {
});
},
getEnvelope: (predicate: (envelope: Envelope) => boolean) => {
const envelope = requests.find(
request => request.envelope && predicate(request.envelope),
)?.envelope;

const [envelope] = getAllEnvelopes(predicate);
if (!envelope) {
throw new Error('Envelope not found');
}

return envelope;
},
getAllEnvelopes,
};
}

Expand Down Expand Up @@ -131,6 +136,22 @@ export function containingTransactionWithName(name: string) {
);
}

export function takeSecond(predicate: (envelope: Envelope) => boolean) {
const take = 2;
let counter = 0;
return (envelope: Envelope) => {
if (predicate(envelope)) {
counter++;
}

if (counter === take) {
return true;
}

return false;
};
}

export function itemBodyIsEvent(itemBody: EnvelopeItem[1]): itemBody is Event {
return typeof itemBody === 'object' && 'event_id' in itemBody;
}
Expand Down
2 changes: 1 addition & 1 deletion samples/react-native/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const isMobileOs = Platform.OS === 'android' || Platform.OS === 'ios';
const reactNavigationIntegration = Sentry.reactNavigationIntegration({
routeChangeTimeoutMs: 500, // How long it will wait for the route change to complete. Default is 1000ms
enableTimeToInitialDisplay: isMobileOs,
ignoreEmptyBackNavigationTransactions: true,
ignoreEmptyBackNavigationTransactions: false,
enableTimeToInitialDisplayForPreloadedRoutes: true,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ export default function NewsScreen() {

useFocusEffect(
useCallback(() => {
if (articles.length) {
console.log('Articles are already loaded');
return;
}

fetchArticles(1, true);
}, [])
}, [articles])
);

const handleLoadMore = () => {
Expand Down
Loading
Loading