Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## Unreleased

### Fixes

- Fix native frames measurements being dropped due to race condition ([#5813](https://github.com/getsentry/sentry-react-native/pull/5813))

## 8.4.0

### Fixes
Expand Down
56 changes: 37 additions & 19 deletions packages/core/src/js/tracing/integrations/nativeFrames.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Client, Event, Integration, Measurements, MeasurementUnit, Span } from '@sentry/core';
import { debug, timestampInSeconds } from '@sentry/core';
import { debug, getRootSpan, spanToJSON, timestampInSeconds } from '@sentry/core';
import type { NativeFramesResponse } from '../../NativeRNSentry';
import { AsyncExpiringMap } from '../../utils/AsyncExpiringMap';
import { isRootSpan } from '../../utils/span';
Expand Down Expand Up @@ -58,8 +58,11 @@ export const createNativeFramesIntegrations = (enable: boolean | undefined): Int
* and frame data (frames.total, frames.slow, frames.frozen) onto all spans.
*/
export const nativeFramesIntegration = (): Integration => {
/** The native frames at the finish time of the most recent span. */
let _lastChildSpanEndFrames: NativeFramesResponseWithTimestamp | null = null;
/** The native frames at the finish time of the most recent child span, keyed by root span ID.
* Stores promises so the data is available for processEvent to await even before
* the async native bridge call completes. */
const _lastChildSpanEndFramesByRootSpan: AsyncExpiringMap<string, NativeFramesResponseWithTimestamp | null> =
new AsyncExpiringMap({ ttl: START_FRAMES_TIMEOUT_MS });
const _spanToNativeFramesAtStartMap: AsyncExpiringMap<string, NativeFramesResponse | null> = new AsyncExpiringMap({
ttl: START_FRAMES_TIMEOUT_MS,
});
Expand Down Expand Up @@ -109,7 +112,6 @@ export const nativeFramesIntegration = (): Integration => {
* produce incorrect deltas. The native bridge calls are async and non-blocking.
*/
const fetchEndFramesForSpan = async (span: Span): Promise<void> => {
const timestamp = timestampInSeconds();
const spanId = span.spanContext().spanId;
const hasStartFrames = _spanToNativeFramesAtStartMap.has(spanId);

Expand All @@ -118,8 +120,31 @@ export const nativeFramesIntegration = (): Integration => {
return;
}

// For child spans: immediately store a promise for fallback end frames before any awaits,
// so processEvent can find and await it even if this async function hasn't completed yet.
// Uses the actual span timestamp (not wall-clock time) so it matches the trimmed event.timestamp
// for idle transactions. Scoped per root span to avoid concurrent transaction interference.
let childEndFramesPromise: Promise<NativeFramesResponse> | undefined;
if (!isRootSpan(span)) {
const rootSpanId = getRootSpan(span).spanContext().spanId;
const spanTimestamp = spanToJSON(span).timestamp;
if (spanTimestamp) {
childEndFramesPromise = fetchNativeFrames();
_lastChildSpanEndFramesByRootSpan.set(
rootSpanId,
childEndFramesPromise
.then(frames => ({ timestamp: spanTimestamp, nativeFrames: frames }))
.then(undefined, error => {
debug.log(`[${INTEGRATION_NAME}] Error while fetching child span end frames.`, error);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this and line 195 generated duplicated error logs?

  1. Line 138: "Error while fetching child span end frames."                                                   
  2. Line 195: "Error while capturing end frames for span ${spanId}."                                          

return null;
}),
);
}
}

if (isRootSpan(span)) {
// Root spans: Store end frames for transaction measurements (backward compatibility)
const timestamp = timestampInSeconds();
debug.log(`[${INTEGRATION_NAME}] Fetch frames for root span end (${spanId}).`);
_spanToNativeFramesAtEndMap.set(
spanId,
Expand Down Expand Up @@ -147,10 +172,10 @@ export const nativeFramesIntegration = (): Integration => {
return;
}

// NOTE: For root spans, this is the second call to fetchNativeFrames() for the same span.
// For child spans, reuse the already-kicked-off promise to avoid a redundant native bridge call.
// For root spans, this is the second call to fetchNativeFrames() for the same span.
// The calls are very close together (microseconds apart), so inconsistency is minimal.
// Future optimization: reuse the first call's promise to avoid redundant native bridge call.
const endFrames = await fetchNativeFrames();
const endFrames = childEndFramesPromise ? await childEndFramesPromise : await fetchNativeFrames();

// Calculate deltas
const totalFrames = endFrames.totalFrames - startFrames.totalFrames;
Expand All @@ -166,14 +191,6 @@ export const nativeFramesIntegration = (): Integration => {
`[${INTEGRATION_NAME}] Attached frame data to span ${spanId}: total=${totalFrames}, slow=${slowFrames}, frozen=${frozenFrames}`,
);
}

// Update last child span end frames for root span fallback logic
if (!isRootSpan(span)) {
_lastChildSpanEndFrames = {
timestamp,
nativeFrames: endFrames,
};
}
} catch (error) {
debug.log(`[${INTEGRATION_NAME}] Error while capturing end frames for span ${spanId}.`, error);
}
Expand Down Expand Up @@ -202,17 +219,18 @@ export const nativeFramesIntegration = (): Integration => {
}

const endFrames = await _spanToNativeFramesAtEndMap.pop(spanId);
const lastChildFrames = await _lastChildSpanEndFramesByRootSpan.pop(spanId);
let finalEndFrames: NativeFramesResponse | undefined;

if (endFrames && isClose(endFrames.timestamp, event.timestamp)) {
// Must be in the margin of error of the actual transaction finish time (finalEndTimestamp)
debug.log(`[${INTEGRATION_NAME}] Using frames from root span end (spanId, ${spanId}).`);
finalEndFrames = endFrames.nativeFrames;
} else if (_lastChildSpanEndFrames && isClose(_lastChildSpanEndFrames.timestamp, event.timestamp)) {
// Fallback to the last span finish if it is within the margin of error of the actual finish timestamp.
// This should be the case for trimEnd.
} else if (lastChildFrames && isClose(lastChildFrames.timestamp, event.timestamp)) {
// Fallback to the last child span finish if it is within the margin of error of the actual finish timestamp.
// This handles idle transactions where event.timestamp is trimmed to the last child span's end time.
debug.log(`[${INTEGRATION_NAME}] Using native frames from last child span end (spanId, ${spanId}).`);
finalEndFrames = _lastChildSpanEndFrames.nativeFrames;
finalEndFrames = lastChildFrames.nativeFrames;
} else {
debug.warn(
`[${INTEGRATION_NAME}] Frames were collected within larger than margin of error delay for spanId (${spanId}). Dropping the inaccurate values.`,
Expand Down
65 changes: 65 additions & 0 deletions packages/core/test/tracing/integrations/nativeframes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,71 @@ describe('NativeFramesInstrumentation', () => {
expect(childSpan!.data).not.toHaveProperty('frames.frozen');
});

it('falls back to last child span end frames when root span end timestamp does not match event timestamp (idle transaction trim)', async () => {
// Simulate idle transaction trimming: an event processor before NativeFrames shifts
// event.timestamp back to the child span's end time. This makes the root span's end frames
// timestamp (captured at idle timeout) no longer match within the 50ms margin of error,
// forcing processEvent to fall back to the child span's end frames.
let childEndTimestamp: number | undefined;
asyncProcessorBeforeNativeFrames = async (event: Event) => {
if (event.timestamp && childEndTimestamp) {
event.timestamp = childEndTimestamp; // Trim to child span end time (simulates idle trimEnd)
}
return event;
};

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) // root span start
.mockResolvedValueOnce(childStartFrames) // child span start
.mockResolvedValueOnce(childEndFrames) // child span end (fallback + span attributes)
.mockResolvedValueOnce(rootEndFrames) // root span end (stored in endMap)
.mockResolvedValueOnce(rootEndFrames); // root span end (for span attributes)

await startSpan({ name: 'idle-transaction' }, async () => {
startSpan({ name: 'child-activity' }, () => {
// Child span ends here at current mock time
childEndTimestamp = Date.now() / 1000;
});
await Promise.resolve(); // Flush frame captures
await Promise.resolve();
await Promise.resolve();

// Advance time to simulate idle timeout gap (1 second > 50ms margin)
global.Date.now = jest.fn(() => mockDate.getTime() + 1000);
// Root span ends here at the advanced time
});

await jest.runOnlyPendingTimersAsync();
await client.flush();

// The root span end frames timestamp won't match event.timestamp (off by 1s > 50ms margin),
// so processEvent falls back to the child span end frames.
// measurements = childEndFrames - rootStartFrames
expect(client.event!).toEqual(
expect.objectContaining<Partial<Event>>({
measurements: expect.objectContaining<Measurements>({
frames_total: {
value: 60, // 160 - 100
unit: 'none',
},
frames_slow: {
value: 6, // 16 - 10
unit: 'none',
},
frames_frozen: {
value: 3, // 8 - 5
unit: 'none',
},
}),
}),
);
});

it('attaches frame data to multiple child spans', async () => {
const rootStartFrames = { totalFrames: 100, slowFrames: 10, frozenFrames: 5 };
const child1StartFrames = { totalFrames: 100, slowFrames: 10, frozenFrames: 5 };
Expand Down
Loading