Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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;
}
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.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
10 changes: 10 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,16 @@ export const appStartIntegration = ({
attachFrameDataToSpan(appStartSpanJSON, appStartEndData.endFrames);
}

try {
const framesDelay = await NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds);
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;
}
13 changes: 13 additions & 0 deletions packages/core/src/js/tracing/timetodisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,19 @@ async function captureEndFramesAndAttachToSpan(span: Span): Promise<void> {

attachFrameDataToSpan(span, frameData.startFrames, endFrames);

const spanStartTimestamp = spanToJSON(span).start_timestamp;
if (spanStartTimestamp) {
try {
const endTimestamp = spanToJSON(span).timestamp || Date.now() / 1000;
Comment thread
antonis marked this conversation as resolved.
Outdated
const framesDelay = await NATIVE.fetchNativeFramesDelay(spanStartTimestamp, endTimestamp);
Comment thread
antonis marked this conversation as resolved.
Outdated
if (framesDelay != null) {
span.setAttribute('frames.delay', framesDelay);
}
} catch (delayError) {
debug.log(`[TimeToDisplay] Failed to fetch frames delay for span ${spanId}.`, delayError);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

debug.log(`[TimeToDisplay] Captured and attached end frames for span ${spanId}.`, endFrames);
} catch (error) {
debug.log(`[TimeToDisplay] Failed to capture end frames for span ${spanId}.`, error);
Expand Down
Loading
Loading