Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

## Unreleased

### Features

- 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))

### Fixes

- 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))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package io.sentry.react;

import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.jetbrains.annotations.Nullable;

/**
* Collects per-frame delay data from {@link SentryFrameMetricsCollector} and provides a method to
* query the accumulated delay within a given time range.
*
* <p>This is a temporary solution until sentry-java exposes a queryable API for frames delay
* (similar to sentry-cocoa's getFramesDelaySPI).
*/
public class RNSentryFrameDelayCollector
implements SentryFrameMetricsCollector.FrameMetricsCollectorListener {

private static final long MAX_FRAME_AGE_NANOS = 5L * 60 * 1_000_000_000L; // 5 minutes

private final List<FrameRecord> frames = new CopyOnWriteArrayList<>();

private @Nullable String listenerId;
private @Nullable SentryFrameMetricsCollector collector;

/**
* Starts collecting frame delay data from the given collector.
*
* @return true if collection was started successfully
*/
public boolean start(@Nullable SentryFrameMetricsCollector frameMetricsCollector) {
if (frameMetricsCollector == null) {
return false;
}
stop();
this.collector = frameMetricsCollector;
this.listenerId = frameMetricsCollector.startCollection(this);
return this.listenerId != null;
}
Comment thread
antonis marked this conversation as resolved.

/** Stops collecting frame delay data. */
public void stop() {
if (collector != null && listenerId != null) {
collector.stopCollection(listenerId);
listenerId = null;
collector = null;
}
frames.clear();
}

@Override
public void onFrameMetricCollected(
long frameStartNanos,
long frameEndNanos,
long durationNanos,
long delayNanos,
boolean isSlow,
boolean isFrozen,
float refreshRate) {
if (delayNanos <= 0) {
return;
}
frames.add(new FrameRecord(frameStartNanos, frameEndNanos, delayNanos));
pruneOldFrames(frameEndNanos);
}

/**
* Returns the total frames delay in seconds for the given time range.
*
* <p>Handles partial overlap: if a frame's delay period partially falls within the query range,
* only the overlapping portion is counted.
*
* @param startNanos start of the query range in system nanos (e.g., System.nanoTime())
* @param endNanos end of the query range in system nanos
* @return delay in seconds, or -1 if no data is available
*/
public double getFramesDelay(long startNanos, long endNanos) {
if (startNanos >= endNanos) {
return -1;
}

long totalDelayNanos = 0;

for (FrameRecord frame : frames) {
if (frame.endNanos <= startNanos) {
continue;
}
if (frame.startNanos >= endNanos) {
break;
}

// The delay portion of a frame is at the end of the frame duration.
// delayStart = frameEnd - delay, delayEnd = frameEnd
long delayStart = frame.endNanos - frame.delayNanos;
long delayEnd = frame.endNanos;

// Intersect the delay interval with the query range
long overlapStart = Math.max(delayStart, startNanos);
long overlapEnd = Math.min(delayEnd, endNanos);

if (overlapEnd > overlapStart) {
totalDelayNanos += (overlapEnd - overlapStart);
}
}

return totalDelayNanos / 1e9;
Comment thread
antonis marked this conversation as resolved.
}

private void pruneOldFrames(long currentNanos) {
long cutoff = currentNanos - MAX_FRAME_AGE_NANOS;
// Remove from the front one-by-one. CopyOnWriteArrayList.remove(0) is O(n) per call,
// but old frames are pruned incrementally so typically only 0-1 entries are removed.
while (!frames.isEmpty() && frames.get(0).endNanos < cutoff) {
frames.remove(0);
}
}
Comment thread
antonis marked this conversation as resolved.

private static class FrameRecord {
final long startNanos;
final long endNanos;
final long delayNanos;

FrameRecord(long startNanos, long endNanos, long delayNanos) {
this.startNanos = startNanos;
this.endNanos = endNanos;
this.delayNanos = delayNanos;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public class RNSentryModuleImpl {
private final ReactApplicationContext reactApplicationContext;
private final PackageInfo packageInfo;
private FrameMetricsAggregator frameMetricsAggregator = null;
private final RNSentryFrameDelayCollector frameDelayCollector = new RNSentryFrameDelayCollector();
private boolean androidXAvailable;

@VisibleForTesting static long lastStartTimestampMs = -1;
Expand Down Expand Up @@ -379,6 +380,36 @@ public void fetchNativeFrames(Promise promise) {
}
}

public void fetchNativeFramesDelay(
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
try {
// Convert wall-clock seconds to System.nanoTime() based nanos
long nowNanos = System.nanoTime();
double nowSeconds = System.currentTimeMillis() / 1e3;

double startOffsetSeconds = nowSeconds - startTimestampSeconds;
double endOffsetSeconds = nowSeconds - endTimestampSeconds;

if (startOffsetSeconds < 0 || endOffsetSeconds < 0) {
promise.resolve(null);
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.

long startNanos = nowNanos - (long) (startOffsetSeconds * 1e9);
long endNanos = nowNanos - (long) (endOffsetSeconds * 1e9);

double delaySeconds = frameDelayCollector.getFramesDelay(startNanos, endNanos);
if (delaySeconds >= 0) {
promise.resolve(delaySeconds);
} else {
promise.resolve(null);
}
} catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
logger.log(SentryLevel.WARNING, "Error fetching native frames delay.");
promise.resolve(null);
}
}

public void captureReplay(boolean isHardCrash, Promise promise) {
Sentry.getCurrentScopes().getOptions().getReplayController().captureReplay(isHardCrash);
promise.resolve(getCurrentReplayId());
Expand Down Expand Up @@ -693,13 +724,27 @@ public void enableNativeFramesTracking() {
} else {
logger.log(SentryLevel.WARNING, "androidx.core' isn't available as a dependency.");
}

try {
final SentryOptions options = Sentry.getCurrentScopes().getOptions();
if (options instanceof SentryAndroidOptions) {
final SentryFrameMetricsCollector collector =
((SentryAndroidOptions) options).getFrameMetricsCollector();
if (frameDelayCollector.start(collector)) {
logger.log(SentryLevel.INFO, "RNSentryFrameDelayCollector installed.");
}
}
} catch (Throwable ignored) { // NOPMD - We don't want to crash in any case
logger.log(SentryLevel.WARNING, "Error starting RNSentryFrameDelayCollector.");
}
}

public void disableNativeFramesTracking() {
if (isFrameMetricsAggregatorAvailable()) {
frameMetricsAggregator.stop();
frameMetricsAggregator = null;
}
frameDelayCollector.stop();
}

public void getNewScreenTimeToDisplay(Promise promise) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ public void fetchNativeFrames(Promise promise) {
this.impl.fetchNativeFrames(promise);
}

@Override
public void fetchNativeFramesDelay(
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
this.impl.fetchNativeFramesDelay(startTimestampSeconds, endTimestampSeconds, promise);
}

@Override
public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) {
this.impl.captureEnvelope(rawBytes, options, promise);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ public void fetchNativeFrames(Promise promise) {
this.impl.fetchNativeFrames(promise);
}

@ReactMethod
public void fetchNativeFramesDelay(
double startTimestampSeconds, double endTimestampSeconds, Promise promise) {
this.impl.fetchNativeFramesDelay(startTimestampSeconds, endTimestampSeconds, promise);
}

@ReactMethod
public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) {
this.impl.captureEnvelope(rawBytes, options, promise);
Expand Down
43 changes: 43 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,49 @@ - (void)handleShakeDetected
#endif
}

RCT_EXPORT_METHOD(fetchNativeFramesDelay : (double)startTimestampSeconds endTimestampSeconds : (
double)endTimestampSeconds resolve : (RCTPromiseResolveBlock)
resolve rejecter : (RCTPromiseRejectBlock)reject)
{
#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST
SentryFramesTracker *framesTracker = [[SentryDependencyContainer sharedInstance] framesTracker];

if (!framesTracker.isRunning) {
resolve(nil);
return;
}

id<SentryCurrentDateProvider> dateProvider =
[SentryDependencyContainer sharedInstance].dateProvider;
uint64_t currentSystemTime = [dateProvider systemTime];
NSTimeInterval currentWallClock = [[dateProvider date] timeIntervalSince1970];

double startOffsetSeconds = currentWallClock - startTimestampSeconds;
double endOffsetSeconds = currentWallClock - endTimestampSeconds;

if (startOffsetSeconds < 0 || endOffsetSeconds < 0
|| (uint64_t)(startOffsetSeconds * 1e9) > currentSystemTime
|| (uint64_t)(endOffsetSeconds * 1e9) > currentSystemTime) {
resolve(nil);
return;
}

uint64_t startSystemTime = currentSystemTime - (uint64_t)(startOffsetSeconds * 1e9);
uint64_t endSystemTime = currentSystemTime - (uint64_t)(endOffsetSeconds * 1e9);

SentryFramesDelayResultSPI *result = [framesTracker getFramesDelaySPI:startSystemTime
endSystemTimestamp:endSystemTime];

if (result != nil && result.delayDuration >= 0) {
resolve(@(result.delayDuration));
} else {
resolve(nil);
Comment thread
antonis marked this conversation as resolved.
Outdated
}
#else
resolve(nil);
#endif
}

RCT_EXPORT_METHOD(
fetchNativeRelease : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject)
{
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface Spec extends TurboModule {
fetchNativeLogAttributes(): Promise<NativeDeviceContextsResponse | null>;
fetchNativeAppStart(): Promise<NativeAppStartResponse | null>;
fetchNativeFrames(): Promise<NativeFramesResponse | null>;
fetchNativeFramesDelay(startTimestampSeconds: number, endTimestampSeconds: number): Promise<number | null>;
initNativeSdk(options: UnsafeObject): Promise<boolean>;
setUser(defaultUserKeys: UnsafeObject | null, otherUserKeys: UnsafeObject | null): void;
setContext(key: string, value: UnsafeObject | null): void;
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/js/tracing/integrations/appStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,19 @@ export const appStartIntegration = ({
attachFrameDataToSpan(appStartSpanJSON, appStartEndData.endFrames);
}

try {
const framesDelay = await Promise.race([
NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds),
new Promise<null>(resolve => setTimeout(() => resolve(null), 2_000)),
]);
if (framesDelay != null) {
appStartSpanJSON.data = appStartSpanJSON.data || {};
appStartSpanJSON.data['frames.delay'] = framesDelay;
}
} catch (error) {
debug.log('[AppStart] Error while fetching frames delay for app start span.', error);
}

const jsExecutionSpanJSON = createJSExecutionStartSpan(appStartSpanJSON, rootComponentCreationTimestampMs);

const appStartSpans = [
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/js/tracing/integrations/nativeFrames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ export const nativeFramesIntegration = (): Integration => {
`[${INTEGRATION_NAME}] Attached frame data to span ${spanId}: total=${totalFrames}, slow=${slowFrames}, frozen=${frozenFrames}`,
);
}

const spanJson = spanToJSON(span);
if (spanJson.start_timestamp && spanJson.timestamp) {
try {
const delay = await fetchNativeFramesDelay(spanJson.start_timestamp, spanJson.timestamp);
if (delay != null) {
span.setAttribute('frames.delay', delay);
}
} catch (delayError) {
debug.log(`[${INTEGRATION_NAME}] Error while fetching frames delay for span ${spanId}.`, delayError);
}
}
} catch (error) {
debug.log(`[${INTEGRATION_NAME}] Error while capturing end frames for span ${spanId}.`, error);
}
Expand Down Expand Up @@ -285,6 +297,37 @@ export const nativeFramesIntegration = (): Integration => {
};
};

function withNativeBridgeTimeout<T>(promise: PromiseLike<T>, timeoutMessage: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;

const timeoutId = setTimeout(() => {
if (!settled) {
settled = true;
reject(timeoutMessage);
}
}, FETCH_FRAMES_TIMEOUT_MS);

promise
.then(value => {
if (settled) {
return;
}
clearTimeout(timeoutId);
settled = true;
resolve(value);
})
.then(undefined, error => {
if (settled) {
return;
}
clearTimeout(timeoutId);
settled = true;
reject(error);
});
});
}
Comment thread
antonis marked this conversation as resolved.

function fetchNativeFrames(): Promise<NativeFramesResponse> {
return new Promise<NativeFramesResponse>((resolve, reject) => {
let settled = false;
Expand Down Expand Up @@ -321,6 +364,13 @@ function fetchNativeFrames(): Promise<NativeFramesResponse> {
});
}

function fetchNativeFramesDelay(startTimestampSeconds: number, endTimestampSeconds: number): Promise<number | null> {
return withNativeBridgeTimeout(
NATIVE.fetchNativeFramesDelay(startTimestampSeconds, endTimestampSeconds),
'Fetching native frames delay took too long.',
);
}

function isClose(t1: number, t2: number): boolean {
return Math.abs(t1 - t2) < MARGIN_OF_ERROR_SECONDS;
}
Loading
Loading