From be0f8663d079d7f16084a4a535906e003e4d1c08 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Mar 2026 14:22:42 +0100 Subject: [PATCH 1/9] feat(core): Add frames.delay data from native SDKs Fetch frames.delay from native (iOS via getFramesDelaySPI, Android via SentryFrameMetricsCollector listener) and attach it to app start spans, TTID/TTFD spans, and all JS API-started spans. Closes #4869 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../react/RNSentryFrameDelayCollector.java | 127 ++++++++++++++++++ .../io/sentry/react/RNSentryModuleImpl.java | 45 +++++++ .../java/io/sentry/react/RNSentryModule.java | 6 + .../java/io/sentry/react/RNSentryModule.java | 6 + packages/core/ios/RNSentry.mm | 43 ++++++ packages/core/src/js/NativeRNSentry.ts | 1 + .../src/js/tracing/integrations/appStart.ts | 10 ++ .../js/tracing/integrations/nativeFrames.ts | 50 +++++++ .../core/src/js/tracing/timetodisplay.tsx | 13 ++ packages/core/src/js/wrapper.ts | 12 ++ packages/core/test/mockWrapper.ts | 2 + .../tracing/integrations/appStart.test.ts | 45 +++++++ .../tracing/integrations/nativeframes.test.ts | 65 +++++++++ .../core/test/tracing/timetodisplay.test.tsx | 98 +++++++++++++- 14 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java new file mode 100644 index 0000000000..4b8f02c63f --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java @@ -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. + * + *

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 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; + } + + /** 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. + * + *

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; + } + + 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); + } + } + + 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; + } + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 5c85123aea..04eeb880a5 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -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; @@ -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; + } + + 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()); @@ -693,6 +724,19 @@ 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() { @@ -700,6 +744,7 @@ public void disableNativeFramesTracking() { frameMetricsAggregator.stop(); frameMetricsAggregator = null; } + frameDelayCollector.stop(); } public void getNewScreenTimeToDisplay(Promise promise) { diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index fe2a341844..9215c09c36 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -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); diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 499ef37f39..85fdb97a35 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -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); diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 6cb2c522fb..03a0e9b49a 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -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 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); + } +#else + resolve(nil); +#endif +} + RCT_EXPORT_METHOD( fetchNativeRelease : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index ee753e71f0..2f65ad4870 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -27,6 +27,7 @@ export interface Spec extends TurboModule { fetchNativeLogAttributes(): Promise; fetchNativeAppStart(): Promise; fetchNativeFrames(): Promise; + fetchNativeFramesDelay(startTimestampSeconds: number, endTimestampSeconds: number): Promise; initNativeSdk(options: UnsafeObject): Promise; setUser(defaultUserKeys: UnsafeObject | null, otherUserKeys: UnsafeObject | null): void; setContext(key: string, value: UnsafeObject | null): void; diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index bad40bcaa4..4cf6c4475f 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -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 = [ diff --git a/packages/core/src/js/tracing/integrations/nativeFrames.ts b/packages/core/src/js/tracing/integrations/nativeFrames.ts index 781f7c361a..1cbc88eac5 100644 --- a/packages/core/src/js/tracing/integrations/nativeFrames.ts +++ b/packages/core/src/js/tracing/integrations/nativeFrames.ts @@ -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); } @@ -285,6 +297,37 @@ export const nativeFramesIntegration = (): Integration => { }; }; +function withNativeBridgeTimeout(promise: PromiseLike, timeoutMessage: string): Promise { + return new Promise((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); + }); + }); +} + function fetchNativeFrames(): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -321,6 +364,13 @@ function fetchNativeFrames(): Promise { }); } +function fetchNativeFramesDelay(startTimestampSeconds: number, endTimestampSeconds: number): Promise { + 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; } diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 45af5d2f4e..3b18756c2a 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -485,6 +485,19 @@ async function captureEndFramesAndAttachToSpan(span: Span): Promise { attachFrameDataToSpan(span, frameData.startFrames, endFrames); + const spanStartTimestamp = spanToJSON(span).start_timestamp; + if (spanStartTimestamp) { + try { + const endTimestamp = spanToJSON(span).timestamp || Date.now() / 1000; + const framesDelay = await NATIVE.fetchNativeFramesDelay(spanStartTimestamp, endTimestamp); + if (framesDelay != null) { + span.setAttribute('frames.delay', framesDelay); + } + } catch (delayError) { + debug.log(`[TimeToDisplay] Failed to fetch frames delay for span ${spanId}.`, delayError); + } + } + 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); diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index f47ddcabd4..9537b71b60 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -90,6 +90,7 @@ interface SentryNativeWrapper { fetchNativeLogAttributes(): Promise; fetchNativeAppStart(): PromiseLike; fetchNativeFrames(): PromiseLike; + fetchNativeFramesDelay(startTimestampSeconds: number, endTimestampSeconds: number): PromiseLike; fetchNativeSdkInfo(): PromiseLike; disableNativeFramesTracking(): void; @@ -394,6 +395,17 @@ export const NATIVE: SentryNativeWrapper = { return RNSentry.fetchNativeFrames(); }, + async fetchNativeFramesDelay(startTimestampSeconds: number, endTimestampSeconds: number): Promise { + if (!this.enableNative) { + throw this._DisabledNativeError; + } + if (!this._isModuleLoaded(RNSentry)) { + throw this._NativeClientError; + } + + return RNSentry.fetchNativeFramesDelay(startTimestampSeconds, endTimestampSeconds); + }, + /** * Triggers a native crash. * Use this only for testing purposes. diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index 89c7486575..7cb440fccb 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -30,6 +30,7 @@ const NATIVE: MockInterface = { fetchNativeDeviceContexts: jest.fn(), fetchNativeAppStart: jest.fn(), fetchNativeFrames: jest.fn(), + fetchNativeFramesDelay: jest.fn(), fetchNativeSdkInfo: jest.fn(), disableNativeFramesTracking: jest.fn(), @@ -80,6 +81,7 @@ NATIVE.fetchNativeRelease.mockResolvedValue({ NATIVE.fetchNativeDeviceContexts.mockResolvedValue({}); NATIVE.fetchNativeAppStart.mockResolvedValue(null); NATIVE.fetchNativeFrames.mockResolvedValue(null); +NATIVE.fetchNativeFramesDelay.mockResolvedValue(null); NATIVE.fetchNativeSdkInfo.mockResolvedValue(null); NATIVE.fetchModules.mockResolvedValue(null); NATIVE.fetchViewHierarchy.mockResolvedValue(null); diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 0f8ecc2a51..baf3ab2511 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -48,6 +48,7 @@ jest.mock('../../../src/js/wrapper', () => { NATIVE: { fetchNativeAppStart: jest.fn(), fetchNativeFrames: jest.fn(() => Promise.resolve()), + fetchNativeFramesDelay: jest.fn(() => Promise.resolve(null)), disableNativeFramesTracking: jest.fn(() => Promise.resolve()), enableNativeFramesTracking: jest.fn(() => Promise.resolve()), enableNative: true, @@ -1212,6 +1213,50 @@ describe('Frame Data Integration', () => { (NATIVE as any).enableNative = originalEnableNative; } }); + + it('attaches frames.delay to app start span', async () => { + const mockEndFrames = { + totalFrames: 150, + slowFrames: 5, + frozenFrames: 2, + }; + + mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(mockEndFrames); + mockFunction(NATIVE.fetchNativeFramesDelay).mockResolvedValue(0.25); + + mockAppStart({ cold: true }); + + const actualEvent = await captureStandAloneAppStart(); + + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); + + expect(appStartSpan).toBeDefined(); + expect(appStartSpan!.data).toEqual( + expect.objectContaining({ + 'frames.delay': 0.25, + }), + ); + }); + + it('does not attach frames.delay when native returns null', async () => { + const mockEndFrames = { + totalFrames: 150, + slowFrames: 5, + frozenFrames: 2, + }; + + mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(mockEndFrames); + mockFunction(NATIVE.fetchNativeFramesDelay).mockResolvedValue(null); + + mockAppStart({ cold: true }); + + const actualEvent = await captureStandAloneAppStart(); + + const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); + + expect(appStartSpan).toBeDefined(); + expect(appStartSpan!.data).not.toHaveProperty('frames.delay'); + }); }); function setupIntegration() { diff --git a/packages/core/test/tracing/integrations/nativeframes.test.ts b/packages/core/test/tracing/integrations/nativeframes.test.ts index d47ed8299c..2f9f6b911d 100644 --- a/packages/core/test/tracing/integrations/nativeframes.test.ts +++ b/packages/core/test/tracing/integrations/nativeframes.test.ts @@ -9,6 +9,7 @@ jest.mock('../../../src/js/wrapper', () => { return { NATIVE: { fetchNativeFrames: jest.fn(), + fetchNativeFramesDelay: jest.fn().mockResolvedValue(null), disableNativeFramesTracking: jest.fn(), enableNative: true, enableNativeFramesTracking: jest.fn(), @@ -547,4 +548,68 @@ describe('NativeFramesInstrumentation', () => { expect(NATIVE.fetchNativeFrames).not.toHaveBeenCalled(); }); }); + + describe('frames.delay', () => { + it('attaches frames.delay to child spans', async () => { + const rootStartFrames = { totalFrames: 100, slowFrames: 10, frozenFrames: 5 }; + const childStartFrames = { totalFrames: 110, slowFrames: 11, frozenFrames: 6 }; + const childEndFrames = { totalFrames: 160, slowFrames: 16, frozenFrames: 8 }; + const rootEndFrames = { totalFrames: 200, slowFrames: 20, frozenFrames: 10 }; + + mockFunction(NATIVE.fetchNativeFrames) + .mockResolvedValueOnce(rootStartFrames) + .mockResolvedValueOnce(childStartFrames) + .mockResolvedValueOnce(childEndFrames) + .mockResolvedValueOnce(rootEndFrames); + + mockFunction(NATIVE.fetchNativeFramesDelay).mockResolvedValue(0.131674); + + await startSpan({ name: 'test' }, async () => { + startSpan({ name: 'child-span' }, () => {}); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await client.flush(); + + expect(client.event).toBeDefined(); + const childSpan = client.event!.spans!.find(s => s.description === 'child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan!.data).toEqual( + expect.objectContaining({ + 'frames.delay': 0.131674, + }), + ); + }); + + it('does not attach frames.delay when native returns null', async () => { + const rootStartFrames = { totalFrames: 100, slowFrames: 10, frozenFrames: 5 }; + const childStartFrames = { totalFrames: 110, slowFrames: 11, frozenFrames: 6 }; + const childEndFrames = { totalFrames: 160, slowFrames: 16, frozenFrames: 8 }; + const rootEndFrames = { totalFrames: 200, slowFrames: 20, frozenFrames: 10 }; + + mockFunction(NATIVE.fetchNativeFrames) + .mockResolvedValueOnce(rootStartFrames) + .mockResolvedValueOnce(childStartFrames) + .mockResolvedValueOnce(childEndFrames) + .mockResolvedValueOnce(rootEndFrames); + + mockFunction(NATIVE.fetchNativeFramesDelay).mockResolvedValue(null); + + await startSpan({ name: 'test' }, async () => { + startSpan({ name: 'child-span' }, () => {}); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await client.flush(); + + expect(client.event).toBeDefined(); + const childSpan = client.event!.spans!.find(s => s.description === 'child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan!.data).not.toHaveProperty('frames.delay'); + }); + }); }); diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index 8258dc224a..52b35c45bb 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -451,10 +451,10 @@ describe('Frame Data', () => { // Simulate native onDraw callback that triggers span end with frame capture updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); - // Allow end frame capture promise chain to complete - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + // Allow end frame capture + frames delay fetch promise chain to complete + for (let i = 0; i < 15; i++) { + await Promise.resolve(); + } activeSpan?.end(); }, @@ -626,4 +626,94 @@ describe('Frame Data', () => { // Note: Reset happens in afterEach, not here }); + + test('attaches frames.delay to initial display span', async () => { + const startFrames = { totalFrames: 100, slowFrames: 2, frozenFrames: 1 }; + const endFrames = { totalFrames: 150, slowFrames: 5, frozenFrames: 2 }; + + mockWrapper.NATIVE.fetchNativeFrames + .mockResolvedValueOnce(startFrames) + .mockResolvedValueOnce(endFrames); + mockWrapper.NATIVE.fetchNativeFramesDelay.mockResolvedValue(0.1234); + + await startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + async (activeSpan: Span | undefined) => { + const ttidSpan = startTimeToInitialDisplaySpan(); + render(); + + // Flush start frame capture + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); + + // Flush end frame capture + frames delay fetch + // The async chain is: fetchNativeFramesWithTimeout -> attachFrameDataToSpan -> fetchNativeFramesDelay + // Each step requires multiple microtask ticks to resolve + for (let i = 0; i < 15; i++) { + await Promise.resolve(); + } + + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const ttidSpan = client.event!.spans!.find((span: SpanJSON) => span.op === 'ui.load.initial_display'); + expect(ttidSpan).toBeDefined(); + expect(ttidSpan!.data).toEqual( + expect.objectContaining({ + 'frames.delay': 0.1234, + }), + ); + }); + + test('does not attach frames.delay when native returns null', async () => { + const startFrames = { totalFrames: 100, slowFrames: 2, frozenFrames: 1 }; + const endFrames = { totalFrames: 150, slowFrames: 5, frozenFrames: 2 }; + + mockWrapper.NATIVE.fetchNativeFrames + .mockResolvedValueOnce(startFrames) + .mockResolvedValueOnce(endFrames); + mockWrapper.NATIVE.fetchNativeFramesDelay.mockResolvedValue(null); + + await startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + async (activeSpan: Span | undefined) => { + const ttidSpan = startTimeToInitialDisplaySpan(); + render(); + + // Flush start frame capture + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); + + // Flush end frame capture + frames delay fetch + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const ttidSpan = client.event!.spans!.find((span: SpanJSON) => span.op === 'ui.load.initial_display'); + expect(ttidSpan).toBeDefined(); + expect(ttidSpan!.data).not.toHaveProperty('frames.delay'); + }); }); From 40181edae09f1a2763792e7a2089eccc7143e5b5 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Mar 2026 14:26:28 +0100 Subject: [PATCH 2/9] docs: Add changelog entry for frames.delay Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5c178197..f5ffa34b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) From ab915eb85a15eaa51ba1072727224187102e7fd6 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Mar 2026 14:38:40 +0100 Subject: [PATCH 3/9] fix(core): Guard against nil SPI result on iOS and leaked listener on Android - iOS: Add nil check on getFramesDelaySPI result before accessing delayDuration (messaging nil returns 0 in ObjC, causing false frames.delay: 0) - Android: Call stop() before start() in RNSentryFrameDelayCollector to prevent leaked listeners on repeated initialization (e.g. JS bundle reload) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/io/sentry/react/RNSentryFrameDelayCollector.java | 1 + packages/core/ios/RNSentry.mm | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java index 4b8f02c63f..a3295ed4b4 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryFrameDelayCollector.java @@ -31,6 +31,7 @@ public boolean start(@Nullable SentryFrameMetricsCollector frameMetricsCollector if (frameMetricsCollector == null) { return false; } + stop(); this.collector = frameMetricsCollector; this.listenerId = frameMetricsCollector.startCollection(this); return this.listenerId != null; diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 03a0e9b49a..4847928c48 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -572,7 +572,7 @@ - (void)handleShakeDetected SentryFramesDelayResultSPI *result = [framesTracker getFramesDelaySPI:startSystemTime endSystemTimestamp:endSystemTime]; - if (result.delayDuration >= 0) { + if (result != nil && result.delayDuration >= 0) { resolve(@(result.delayDuration)); } else { resolve(nil); From 6d3895f8ac84bf86541a23f6240a0a237e468dc3 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Mar 2026 14:42:11 +0100 Subject: [PATCH 4/9] fix(tracing): Use actual span end timestamp for frames.delay in TTID/TTFD Pass the intended span end timestamp into captureEndFramesAndAttachToSpan instead of falling back to Date.now(). The function runs before span.end() is called, so spanToJSON(span).timestamp is always undefined, causing the delay calculation to include frame data after the span semantically ended. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/js/tracing/timetodisplay.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 3b18756c2a..702b48ebc2 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -203,9 +203,10 @@ export function startTimeToFullDisplaySpan( } fullDisplaySpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }); - captureEndFramesAndAttachToSpan(fullDisplaySpan).then(() => { + const fullDisplayEndTimestamp = spanToJSON(initialDisplaySpan).timestamp; + captureEndFramesAndAttachToSpan(fullDisplaySpan, fullDisplayEndTimestamp).then(() => { debug.log(`[TimeToDisplay] span ${fullDisplaySpan.spanContext().spanId} updated with frame data.`); - fullDisplaySpan.end(spanToJSON(initialDisplaySpan).timestamp); + fullDisplaySpan.end(fullDisplayEndTimestamp); setSpanDurationAsMeasurement('time_to_full_display', fullDisplaySpan); }).catch(() => { debug.warn(`[TimeToDisplay] Failed to capture end frames for full display span (${fullDisplaySpan.spanContext().spanId}).`); @@ -265,7 +266,7 @@ export function updateInitialDisplaySpan( return; } - captureEndFramesAndAttachToSpan(span).then(() => { + captureEndFramesAndAttachToSpan(span, frameTimestampSeconds).then(() => { span.end(frameTimestampSeconds); span.setStatus({ code: SPAN_STATUS_OK }); debug.log(`[TimeToDisplay] ${spanToJSON(span).description} span updated with end timestamp and frame data.`); @@ -322,8 +323,8 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl return; } - captureEndFramesAndAttachToSpan(span).then(() => { - const endTimestamp = initialDisplayEndTimestamp > frameTimestampSeconds ? initialDisplayEndTimestamp : frameTimestampSeconds; + const endTimestamp = initialDisplayEndTimestamp > frameTimestampSeconds ? initialDisplayEndTimestamp : frameTimestampSeconds; + captureEndFramesAndAttachToSpan(span, endTimestamp).then(() => { if (initialDisplayEndTimestamp > frameTimestampSeconds) { debug.warn('[TimeToDisplay] Using initial display end. Full display end frame timestamp is before initial display end.'); @@ -466,7 +467,7 @@ async function captureStartFramesForSpan(spanId: string): Promise { /** * Captures end frames and attaches frame data to span */ -async function captureEndFramesAndAttachToSpan(span: Span): Promise { +async function captureEndFramesAndAttachToSpan(span: Span, spanEndTimestampSeconds?: number): Promise { if (!NATIVE.enableNative) { return; } @@ -488,7 +489,7 @@ async function captureEndFramesAndAttachToSpan(span: Span): Promise { const spanStartTimestamp = spanToJSON(span).start_timestamp; if (spanStartTimestamp) { try { - const endTimestamp = spanToJSON(span).timestamp || Date.now() / 1000; + const endTimestamp = spanEndTimestampSeconds || spanToJSON(span).timestamp || Date.now() / 1000; const framesDelay = await NATIVE.fetchNativeFramesDelay(spanStartTimestamp, endTimestamp); if (framesDelay != null) { span.setAttribute('frames.delay', framesDelay); From 00ef1ed90fe1d70f6012888f93c6448c9792a28f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Mar 2026 14:55:58 +0100 Subject: [PATCH 5/9] fix(tracing): Add timeout protection for fetchNativeFramesDelay in TTID/TTFD and app start Wrap NATIVE.fetchNativeFramesDelay calls in timetodisplay.tsx and appStart.ts with Promise.race timeout (2s), matching the timeout protection already present in nativeFrames.ts. Prevents indefinite blocking if the native bridge hangs. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/js/tracing/integrations/appStart.ts | 5 ++++- packages/core/src/js/tracing/timetodisplay.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 4cf6c4475f..c0fdee2e9a 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -501,7 +501,10 @@ export const appStartIntegration = ({ } try { - const framesDelay = await NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds); + const framesDelay = await Promise.race([ + NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds), + new Promise(resolve => setTimeout(() => resolve(null), 2_000)), + ]); if (framesDelay != null) { appStartSpanJSON.data = appStartSpanJSON.data || {}; appStartSpanJSON.data['frames.delay'] = framesDelay; diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 702b48ebc2..1ee5135a05 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -490,7 +490,10 @@ async function captureEndFramesAndAttachToSpan(span: Span, spanEndTimestampSecon if (spanStartTimestamp) { try { const endTimestamp = spanEndTimestampSeconds || spanToJSON(span).timestamp || Date.now() / 1000; - const framesDelay = await NATIVE.fetchNativeFramesDelay(spanStartTimestamp, endTimestamp); + const framesDelay = await Promise.race([ + NATIVE.fetchNativeFramesDelay(spanStartTimestamp, endTimestamp), + new Promise(resolve => setTimeout(() => resolve(null), FETCH_FRAMES_TIMEOUT_MS)), + ]); if (framesDelay != null) { span.setAttribute('frames.delay', framesDelay); } From 1cbf20f6bcee6544103a2509fd37849f3354887b Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Mar 2026 15:07:46 +0100 Subject: [PATCH 6/9] fix(tracing): Only fetch frames.delay when frame count data is available Gate frames.delay fetch on appStartEndData.endFrames being present, matching the native SDK behavior where both frame counts and delay are gated on the frames tracker running. Prevents attaching frames.delay to spans that have no frames.total/slow/frozen data. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/js/tracing/integrations/appStart.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index c0fdee2e9a..5dfce05a91 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -498,19 +498,19 @@ export const appStartIntegration = ({ if (appStartEndData?.endFrames) { attachFrameDataToSpan(appStartSpanJSON, appStartEndData.endFrames); - } - try { - const framesDelay = await Promise.race([ - NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds), - new Promise(resolve => setTimeout(() => resolve(null), 2_000)), - ]); - if (framesDelay != null) { - appStartSpanJSON.data = appStartSpanJSON.data || {}; - appStartSpanJSON.data['frames.delay'] = framesDelay; + try { + const framesDelay = await Promise.race([ + NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds), + new Promise(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); } - } catch (error) { - debug.log('[AppStart] Error while fetching frames delay for app start span.', error); } const jsExecutionSpanJSON = createJSExecutionStartSpan(appStartSpanJSON, rootComponentCreationTimestampMs); From 45e169a3b500d3f695c35a378d19dd2ee53e9995 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 27 Mar 2026 15:24:18 +0100 Subject: [PATCH 7/9] fix(ios): Move frames delay logic to SentryScreenFramesWrapper to fix build The SPI types (SentryFramesTracker, SentryCurrentDateProvider, SentryFramesDelayResultSPI) are only accessible via `@import Sentry;` which SentryScreenFramesWrapper.m already uses. RNSentry.mm only imports individual headers and cannot see these Swift SPI types. Move the frames delay computation into a new +framesDelayForStartTimestamp:endTimestamp: method on SentryScreenFramesWrapper and call it from RNSentry.mm. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/ios/RNSentry.mm | 32 ++--------------- packages/core/ios/SentryScreenFramesWrapper.h | 2 ++ packages/core/ios/SentryScreenFramesWrapper.m | 36 +++++++++++++++++++ 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 4847928c48..e72d104966 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -544,39 +544,13 @@ - (void)handleShakeDetected resolve rejecter : (RCTPromiseRejectBlock)reject) { #if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - SentryFramesTracker *framesTracker = [[SentryDependencyContainer sharedInstance] framesTracker]; - - if (!framesTracker.isRunning) { - resolve(nil); - return; - } - - id 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) { + if (![SentryScreenFramesWrapper canTrackFrames]) { 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); - } + resolve([SentryScreenFramesWrapper framesDelayForStartTimestamp:startTimestampSeconds + endTimestamp:endTimestampSeconds]); #else resolve(nil); #endif diff --git a/packages/core/ios/SentryScreenFramesWrapper.h b/packages/core/ios/SentryScreenFramesWrapper.h index 4c664140e0..4094d239f8 100644 --- a/packages/core/ios/SentryScreenFramesWrapper.h +++ b/packages/core/ios/SentryScreenFramesWrapper.h @@ -8,6 +8,8 @@ + (NSNumber *)totalFrames; + (NSNumber *)frozenFrames; + (NSNumber *)slowFrames; ++ (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds + endTimestamp:(double)endTimestampSeconds; @end diff --git a/packages/core/ios/SentryScreenFramesWrapper.m b/packages/core/ios/SentryScreenFramesWrapper.m index 9df4e13070..e1857444d0 100644 --- a/packages/core/ios/SentryScreenFramesWrapper.m +++ b/packages/core/ios/SentryScreenFramesWrapper.m @@ -34,6 +34,42 @@ + (NSNumber *)slowFrames return [NSNumber numberWithLong:PrivateSentrySDKOnly.currentScreenFrames.slow]; } ++ (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds + endTimestamp:(double)endTimestampSeconds +{ + SentryFramesTracker *framesTracker = + [[SentryDependencyContainer sharedInstance] framesTracker]; + + if (!framesTracker.isRunning) { + return nil; + } + + id 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) { + return nil; + } + + 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) { + return @(result.delayDuration); + } + return nil; +} + @end #endif // TARGET_OS_IPHONE || TARGET_OS_MACCATALYST From b27237e6ea9aada3eedcf832eada57f02b1b2e46 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 30 Mar 2026 08:49:02 +0200 Subject: [PATCH 8/9] Fix lint issues --- packages/core/ios/RNSentry.mm | 2 +- packages/core/ios/SentryScreenFramesWrapper.h | 2 +- packages/core/ios/SentryScreenFramesWrapper.m | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 9bdf24ed86..a43557079c 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -550,7 +550,7 @@ - (void)handleShakeDetected } resolve([SentryScreenFramesWrapper framesDelayForStartTimestamp:startTimestampSeconds - endTimestamp:endTimestampSeconds]); + endTimestamp:endTimestampSeconds]); #else resolve(nil); #endif diff --git a/packages/core/ios/SentryScreenFramesWrapper.h b/packages/core/ios/SentryScreenFramesWrapper.h index 4094d239f8..8d6ce8df68 100644 --- a/packages/core/ios/SentryScreenFramesWrapper.h +++ b/packages/core/ios/SentryScreenFramesWrapper.h @@ -9,7 +9,7 @@ + (NSNumber *)frozenFrames; + (NSNumber *)slowFrames; + (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds - endTimestamp:(double)endTimestampSeconds; + endTimestamp:(double)endTimestampSeconds; @end diff --git a/packages/core/ios/SentryScreenFramesWrapper.m b/packages/core/ios/SentryScreenFramesWrapper.m index e1857444d0..dd79c5a214 100644 --- a/packages/core/ios/SentryScreenFramesWrapper.m +++ b/packages/core/ios/SentryScreenFramesWrapper.m @@ -37,8 +37,7 @@ + (NSNumber *)slowFrames + (NSNumber *)framesDelayForStartTimestamp:(double)startTimestampSeconds endTimestamp:(double)endTimestampSeconds { - SentryFramesTracker *framesTracker = - [[SentryDependencyContainer sharedInstance] framesTracker]; + SentryFramesTracker *framesTracker = [[SentryDependencyContainer sharedInstance] framesTracker]; if (!framesTracker.isRunning) { return nil; From 9a411b0670fdacf5e9dceacddbb86195264d356f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 30 Mar 2026 09:14:52 +0200 Subject: [PATCH 9/9] fix(tracing): Gate frames.delay on non-zero frame counts in nativeFramesIntegration Move frames.delay fetch inside the `if (totalFrames > 0 || ...)` guard so it's only attached when frame count data is also present. Prevents spans from having frames.delay without frames.total. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../js/tracing/integrations/nativeFrames.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/nativeFrames.ts b/packages/core/src/js/tracing/integrations/nativeFrames.ts index 1cbc88eac5..fdf951c17a 100644 --- a/packages/core/src/js/tracing/integrations/nativeFrames.ts +++ b/packages/core/src/js/tracing/integrations/nativeFrames.ts @@ -191,17 +191,17 @@ export const nativeFramesIntegration = (): Integration => { debug.log( `[${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); + 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 (delayError) { - debug.log(`[${INTEGRATION_NAME}] Error while fetching frames delay for span ${spanId}.`, delayError); } } } catch (error) {