Skip to content

Commit 1dc6fd9

Browse files
authored
Merge branch 'main' into antonis/fix-http-client-inflated-duration
2 parents 798f369 + f3c7863 commit 1dc6fd9

File tree

17 files changed

+547
-13
lines changed

17 files changed

+547
-13
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010

1111
### Features
1212

13+
- Add `frames.delay` span data from native SDKs to app start, TTID/TTFD, and JS API spans ([#5907](https://github.com/getsentry/sentry-react-native/pull/5907))
1314
- Rename `FeedbackWidget` to `FeedbackForm` and `showFeedbackWidget` to `showFeedbackForm` ([#5931](https://github.com/getsentry/sentry-react-native/pull/5931))
1415
- The old names are deprecated but still work
1516
- Deprecate `FeedbackButton`, `showFeedbackButton`, and `hideFeedbackButton` ([#5933](https://github.com/getsentry/sentry-react-native/pull/5933))
1617

1718
### Fixes
1819

1920
- Fix inflated `http.client` span durations on iOS when the app backgrounds during a request ([#5944](https://github.com/getsentry/sentry-react-native/pull/5944))
21+
- Fix crash caused by nullish response in supabase PostgREST handler ([#5938](https://github.com/getsentry/sentry-react-native/pull/5938))
2022
- Fix iOS crash (EXC_BAD_ACCESS) in time-to-initial-display when navigating between screens ([#5887](https://github.com/getsentry/sentry-react-native/pull/5887))
2123

2224
### Dependencies
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package io.sentry.react;
2+
3+
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
4+
import java.util.List;
5+
import java.util.concurrent.CopyOnWriteArrayList;
6+
import org.jetbrains.annotations.Nullable;
7+
8+
/**
9+
* Collects per-frame delay data from {@link SentryFrameMetricsCollector} and provides a method to
10+
* query the accumulated delay within a given time range.
11+
*
12+
* <p>This is a temporary solution until sentry-java exposes a queryable API for frames delay
13+
* (similar to sentry-cocoa's getFramesDelaySPI).
14+
*/
15+
public class RNSentryFrameDelayCollector
16+
implements SentryFrameMetricsCollector.FrameMetricsCollectorListener {
17+
18+
private static final long MAX_FRAME_AGE_NANOS = 5L * 60 * 1_000_000_000L; // 5 minutes
19+
20+
private final List<FrameRecord> frames = new CopyOnWriteArrayList<>();
21+
22+
private @Nullable String listenerId;
23+
private @Nullable SentryFrameMetricsCollector collector;
24+
25+
/**
26+
* Starts collecting frame delay data from the given collector.
27+
*
28+
* @return true if collection was started successfully
29+
*/
30+
public boolean start(@Nullable SentryFrameMetricsCollector frameMetricsCollector) {
31+
if (frameMetricsCollector == null) {
32+
return false;
33+
}
34+
stop();
35+
this.collector = frameMetricsCollector;
36+
this.listenerId = frameMetricsCollector.startCollection(this);
37+
return this.listenerId != null;
38+
}
39+
40+
/** Stops collecting frame delay data. */
41+
public void stop() {
42+
if (collector != null && listenerId != null) {
43+
collector.stopCollection(listenerId);
44+
listenerId = null;
45+
collector = null;
46+
}
47+
frames.clear();
48+
}
49+
50+
@Override
51+
public void onFrameMetricCollected(
52+
long frameStartNanos,
53+
long frameEndNanos,
54+
long durationNanos,
55+
long delayNanos,
56+
boolean isSlow,
57+
boolean isFrozen,
58+
float refreshRate) {
59+
if (delayNanos <= 0) {
60+
return;
61+
}
62+
frames.add(new FrameRecord(frameStartNanos, frameEndNanos, delayNanos));
63+
pruneOldFrames(frameEndNanos);
64+
}
65+
66+
/**
67+
* Returns the total frames delay in seconds for the given time range.
68+
*
69+
* <p>Handles partial overlap: if a frame's delay period partially falls within the query range,
70+
* only the overlapping portion is counted.
71+
*
72+
* @param startNanos start of the query range in system nanos (e.g., System.nanoTime())
73+
* @param endNanos end of the query range in system nanos
74+
* @return delay in seconds, or -1 if no data is available
75+
*/
76+
public double getFramesDelay(long startNanos, long endNanos) {
77+
if (startNanos >= endNanos) {
78+
return -1;
79+
}
80+
81+
long totalDelayNanos = 0;
82+
83+
for (FrameRecord frame : frames) {
84+
if (frame.endNanos <= startNanos) {
85+
continue;
86+
}
87+
if (frame.startNanos >= endNanos) {
88+
break;
89+
}
90+
91+
// The delay portion of a frame is at the end of the frame duration.
92+
// delayStart = frameEnd - delay, delayEnd = frameEnd
93+
long delayStart = frame.endNanos - frame.delayNanos;
94+
long delayEnd = frame.endNanos;
95+
96+
// Intersect the delay interval with the query range
97+
long overlapStart = Math.max(delayStart, startNanos);
98+
long overlapEnd = Math.min(delayEnd, endNanos);
99+
100+
if (overlapEnd > overlapStart) {
101+
totalDelayNanos += (overlapEnd - overlapStart);
102+
}
103+
}
104+
105+
return totalDelayNanos / 1e9;
106+
}
107+
108+
private void pruneOldFrames(long currentNanos) {
109+
long cutoff = currentNanos - MAX_FRAME_AGE_NANOS;
110+
// Remove from the front one-by-one. CopyOnWriteArrayList.remove(0) is O(n) per call,
111+
// but old frames are pruned incrementally so typically only 0-1 entries are removed.
112+
while (!frames.isEmpty() && frames.get(0).endNanos < cutoff) {
113+
frames.remove(0);
114+
}
115+
}
116+
117+
private static class FrameRecord {
118+
final long startNanos;
119+
final long endNanos;
120+
final long delayNanos;
121+
122+
FrameRecord(long startNanos, long endNanos, long delayNanos) {
123+
this.startNanos = startNanos;
124+
this.endNanos = endNanos;
125+
this.delayNanos = delayNanos;
126+
}
127+
}
128+
}

packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ public class RNSentryModuleImpl {
9494
private final ReactApplicationContext reactApplicationContext;
9595
private final PackageInfo packageInfo;
9696
private FrameMetricsAggregator frameMetricsAggregator = null;
97+
private final RNSentryFrameDelayCollector frameDelayCollector = new RNSentryFrameDelayCollector();
9798
private boolean androidXAvailable;
9899

99100
@VisibleForTesting static long lastStartTimestampMs = -1;
@@ -379,6 +380,39 @@ public void fetchNativeFrames(Promise promise) {
379380
}
380381
}
381382

383+
public void fetchNativeFramesDelay(
384+
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
385+
try {
386+
// Convert wall-clock seconds to System.nanoTime() based nanos
387+
long nowNanos = System.nanoTime();
388+
double nowSeconds = System.currentTimeMillis() / 1e3;
389+
390+
double startOffsetSeconds = nowSeconds - startTimestampSeconds;
391+
double endOffsetSeconds = nowSeconds - endTimestampSeconds;
392+
393+
if (startOffsetSeconds < 0
394+
|| endOffsetSeconds < 0
395+
|| (long) (startOffsetSeconds * 1e9) > nowNanos
396+
|| (long) (endOffsetSeconds * 1e9) > nowNanos) {
397+
promise.resolve(null);
398+
return;
399+
}
400+
401+
long startNanos = nowNanos - (long) (startOffsetSeconds * 1e9);
402+
long endNanos = nowNanos - (long) (endOffsetSeconds * 1e9);
403+
404+
double delaySeconds = frameDelayCollector.getFramesDelay(startNanos, endNanos);
405+
if (delaySeconds >= 0) {
406+
promise.resolve(delaySeconds);
407+
} else {
408+
promise.resolve(null);
409+
}
410+
} catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
411+
logger.log(SentryLevel.WARNING, "Error fetching native frames delay.");
412+
promise.resolve(null);
413+
}
414+
}
415+
382416
public void captureReplay(boolean isHardCrash, Promise promise) {
383417
Sentry.getCurrentScopes().getOptions().getReplayController().captureReplay(isHardCrash);
384418
promise.resolve(getCurrentReplayId());
@@ -693,13 +727,27 @@ public void enableNativeFramesTracking() {
693727
} else {
694728
logger.log(SentryLevel.WARNING, "androidx.core' isn't available as a dependency.");
695729
}
730+
731+
try {
732+
final SentryOptions options = Sentry.getCurrentScopes().getOptions();
733+
if (options instanceof SentryAndroidOptions) {
734+
final SentryFrameMetricsCollector collector =
735+
((SentryAndroidOptions) options).getFrameMetricsCollector();
736+
if (frameDelayCollector.start(collector)) {
737+
logger.log(SentryLevel.INFO, "RNSentryFrameDelayCollector installed.");
738+
}
739+
}
740+
} catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
741+
logger.log(SentryLevel.WARNING, "Error starting RNSentryFrameDelayCollector.");
742+
}
696743
}
697744

698745
public void disableNativeFramesTracking() {
699746
if (isFrameMetricsAggregatorAvailable()) {
700747
frameMetricsAggregator.stop();
701748
frameMetricsAggregator = null;
702749
}
750+
frameDelayCollector.stop();
703751
}
704752

705753
public void getNewScreenTimeToDisplay(Promise promise) {

packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ public void fetchNativeFrames(Promise promise) {
6767
this.impl.fetchNativeFrames(promise);
6868
}
6969

70+
@Override
71+
public void fetchNativeFramesDelay(
72+
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
73+
this.impl.fetchNativeFramesDelay(startTimestampSeconds, endTimestampSeconds, promise);
74+
}
75+
7076
@Override
7177
public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) {
7278
this.impl.captureEnvelope(rawBytes, options, promise);

packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ public void fetchNativeFrames(Promise promise) {
6767
this.impl.fetchNativeFrames(promise);
6868
}
6969

70+
@ReactMethod
71+
public void fetchNativeFramesDelay(
72+
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
73+
this.impl.fetchNativeFramesDelay(startTimestampSeconds, endTimestampSeconds, promise);
74+
}
75+
7076
@ReactMethod
7177
public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) {
7278
this.impl.captureEnvelope(rawBytes, options, promise);

packages/core/ios/RNSentry.mm

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,23 @@ - (void)handleShakeDetected
539539
#endif
540540
}
541541

542+
RCT_EXPORT_METHOD(fetchNativeFramesDelay : (double)startTimestampSeconds endTimestampSeconds : (
543+
double)endTimestampSeconds resolve : (RCTPromiseResolveBlock)
544+
resolve rejecter : (RCTPromiseRejectBlock)reject)
545+
{
546+
#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST
547+
if (![SentryScreenFramesWrapper canTrackFrames]) {
548+
resolve(nil);
549+
return;
550+
}
551+
552+
resolve([SentryScreenFramesWrapper framesDelayForStartTimestamp:startTimestampSeconds
553+
endTimestamp:endTimestampSeconds]);
554+
#else
555+
resolve(nil);
556+
#endif
557+
}
558+
542559
RCT_EXPORT_METHOD(
543560
fetchNativeRelease : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject)
544561
{

packages/core/ios/SentryScreenFramesWrapper.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
+ (NSNumber *)totalFrames;
99
+ (NSNumber *)frozenFrames;
1010
+ (NSNumber *)slowFrames;
11+
+ (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds
12+
endTimestamp:(double)endTimestampSeconds;
1113

1214
@end
1315

packages/core/ios/SentryScreenFramesWrapper.m

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,41 @@ + (NSNumber *)slowFrames
3434
return [NSNumber numberWithLong:PrivateSentrySDKOnly.currentScreenFrames.slow];
3535
}
3636

37+
+ (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds
38+
endTimestamp:(double)endTimestampSeconds
39+
{
40+
SentryFramesTracker *framesTracker = [[SentryDependencyContainer sharedInstance] framesTracker];
41+
42+
if (!framesTracker.isRunning) {
43+
return nil;
44+
}
45+
46+
id<SentryCurrentDateProvider> dateProvider =
47+
[SentryDependencyContainer sharedInstance].dateProvider;
48+
uint64_t currentSystemTime = [dateProvider systemTime];
49+
NSTimeInterval currentWallClock = [[dateProvider date] timeIntervalSince1970];
50+
51+
double startOffsetSeconds = currentWallClock - startTimestampSeconds;
52+
double endOffsetSeconds = currentWallClock - endTimestampSeconds;
53+
54+
if (startOffsetSeconds < 0 || endOffsetSeconds < 0
55+
|| (uint64_t)(startOffsetSeconds * 1e9) > currentSystemTime
56+
|| (uint64_t)(endOffsetSeconds * 1e9) > currentSystemTime) {
57+
return nil;
58+
}
59+
60+
uint64_t startSystemTime = currentSystemTime - (uint64_t)(startOffsetSeconds * 1e9);
61+
uint64_t endSystemTime = currentSystemTime - (uint64_t)(endOffsetSeconds * 1e9);
62+
63+
SentryFramesDelayResultSPI *result = [framesTracker getFramesDelaySPI:startSystemTime
64+
endSystemTimestamp:endSystemTime];
65+
66+
if (result != nil && result.delayDuration >= 0) {
67+
return @(result.delayDuration);
68+
}
69+
return nil;
70+
}
71+
3772
@end
3873

3974
#endif // TARGET_OS_IPHONE || TARGET_OS_MACCATALYST

packages/core/src/js/NativeRNSentry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface Spec extends TurboModule {
2929
fetchNativeLogAttributes(): Promise<NativeDeviceContextsResponse | null>;
3030
fetchNativeAppStart(): Promise<NativeAppStartResponse | null>;
3131
fetchNativeFrames(): Promise<NativeFramesResponse | null>;
32+
fetchNativeFramesDelay(startTimestampSeconds: number, endTimestampSeconds: number): Promise<number | null>;
3233
initNativeSdk(options: UnsafeObject): Promise<boolean>;
3334
setUser(defaultUserKeys: UnsafeObject | null, otherUserKeys: UnsafeObject | null): void;
3435
setContext(key: string, value: UnsafeObject | null): void;

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export function _clearRootComponentCreationTimestampMs(): void {
164164
* Attaches frame data to a span's data object.
165165
*/
166166
function attachFrameDataToSpan(span: SpanJSON, frames: NativeFramesResponse): void {
167-
if (frames.totalFrames <= 0 && frames.slowFrames <= 0 && frames.totalFrames <= 0) {
167+
if (frames.totalFrames <= 0 && frames.slowFrames <= 0 && frames.frozenFrames <= 0) {
168168
debug.warn(`[AppStart] Detected zero slow or frozen frames. Not adding measurements to spanId (${span.span_id}).`);
169169
return;
170170
}
@@ -501,6 +501,19 @@ export const appStartIntegration = ({
501501

502502
if (appStartEndData?.endFrames) {
503503
attachFrameDataToSpan(appStartSpanJSON, appStartEndData.endFrames);
504+
505+
try {
506+
const framesDelay = await Promise.race([
507+
NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds),
508+
new Promise<null>(resolve => setTimeout(() => resolve(null), 2_000)),
509+
]);
510+
if (framesDelay != null) {
511+
appStartSpanJSON.data = appStartSpanJSON.data || {};
512+
appStartSpanJSON.data['frames.delay'] = framesDelay;
513+
}
514+
} catch (error) {
515+
debug.log('[AppStart] Error while fetching frames delay for app start span.', error);
516+
}
504517
}
505518

506519
const jsExecutionSpanJSON = createJSExecutionStartSpan(appStartSpanJSON, rootComponentCreationTimestampMs);

0 commit comments

Comments
 (0)