Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Adds Metrics Beta ([#5402](https://github.com/getsentry/sentry-react-native/pull/5402))
- Improves Expo Router integration to optionally include full paths to components instead of just component names ([#5414](https://github.com/getsentry/sentry-react-native/pull/5414))
- Report slow and frozen frames as TTID/TTFD span data ([#5419](https://github.com/getsentry/sentry-react-native/pull/5419))
- Report slow and frozen frames on spans created through the API ([#5420](https://github.com/getsentry/sentry-react-native/issues/5420))

### Fixes

Expand Down
110 changes: 81 additions & 29 deletions packages/core/src/js/tracing/integrations/nativeFrames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export const createNativeFramesIntegrations = (enable: boolean | undefined): Int
};

/**
* Instrumentation to add native slow/frozen frames measurements onto transactions.
* Instrumentation to add native slow/frozen frames measurements onto transactions
* 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. */
Expand All @@ -81,13 +82,11 @@ export const nativeFramesIntegration = (): Integration => {
client.on('spanEnd', fetchEndFramesForSpan);
};

const fetchStartFramesForSpan = (rootSpan: Span): void => {
if (!isRootSpan(rootSpan)) {
return;
}
const fetchStartFramesForSpan = (span: Span): void => {
const spanId = span.spanContext().spanId;
const spanType = isRootSpan(span) ? 'root' : 'child';
debug.log(`[${INTEGRATION_NAME}] Fetching frames for ${spanType} span start (${spanId}).`);

const spanId = rootSpan.spanContext().spanId;
debug.log(`[${INTEGRATION_NAME}] Fetching frames for root span start (${spanId}).`);
_spanToNativeFramesAtStartMap.set(
spanId,
new Promise<NativeFramesResponse | null>(resolve => {
Expand All @@ -101,17 +100,26 @@ export const nativeFramesIntegration = (): Integration => {
);
};

const fetchEndFramesForSpan = (span: Span): void => {
/**
* Fetches end frames for a span and attaches frame data as span attributes.
*
* Note: This makes one native bridge call per span end. While this creates O(n) calls
* for n spans, it's necessary for accuracy. Frame counts are cumulative and continuously
* incrementing, so each span needs the exact frame count at its end time. Caching would
* 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);

if (isRootSpan(span)) {
const hasStartFrames = _spanToNativeFramesAtStartMap.has(spanId);
if (!hasStartFrames) {
// We don't have start frames, won't be able to calculate the difference.
return;
}
if (!hasStartFrames) {
// We don't have start frames, won't be able to calculate the difference.
return;
}

if (isRootSpan(span)) {
// Root spans: Store end frames for transaction measurements (backward compatibility)
debug.log(`[${INTEGRATION_NAME}] Fetch frames for root span end (${spanId}).`);
_spanToNativeFramesAtEndMap.set(
spanId,
Expand All @@ -129,17 +137,45 @@ export const nativeFramesIntegration = (): Integration => {
});
}),
);
return undefined;
} else {
debug.log(`[${INTEGRATION_NAME}] Fetch frames for child span end (${spanId}).`);
fetchNativeFrames()
.then(frames => {
_lastChildSpanEndFrames = {
timestamp,
nativeFrames: frames,
};
})
.catch(error => debug.log(`[${INTEGRATION_NAME}] Error while fetching native frames.`, error));
}

// All spans (root and child): Attach frame data as span attributes
try {
const startFrames = await _spanToNativeFramesAtStartMap.get(spanId);
if (!startFrames) {
debug.log(`[${INTEGRATION_NAME}] No start frames found for span ${spanId}, skipping frame data.`);
return;
}

// NOTE: 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();

// Calculate deltas
const totalFrames = endFrames.totalFrames - startFrames.totalFrames;
const slowFrames = endFrames.slowFrames - startFrames.slowFrames;
const frozenFrames = endFrames.frozenFrames - startFrames.frozenFrames;

// Only attach if we have meaningful data
if (totalFrames > 0 || slowFrames > 0 || frozenFrames > 0) {
span.setAttribute('frames.total', totalFrames);
span.setAttribute('frames.slow', slowFrames);
span.setAttribute('frames.frozen', frozenFrames);
debug.log(
`[${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 @@ -233,21 +269,37 @@ export const nativeFramesIntegration = (): Integration => {

function fetchNativeFrames(): Promise<NativeFramesResponse> {
return new Promise<NativeFramesResponse>((resolve, reject) => {
let settled = false;

const timeoutId = setTimeout(() => {
if (!settled) {
settled = true;
reject('Fetching native frames took too long. Dropping frames.');
}
}, FETCH_FRAMES_TIMEOUT_MS);

NATIVE.fetchNativeFrames()
.then(value => {
if (settled) {
return;
}
clearTimeout(timeoutId);
settled = true;

if (!value) {
reject('Native frames response is null.');
return;
}
resolve(value);
})
.then(undefined, error => {
if (settled) {
return;
}
clearTimeout(timeoutId);
settled = true;
reject(error);
});

setTimeout(() => {
reject('Fetching native frames took too long. Dropping frames.');
}, FETCH_FRAMES_TIMEOUT_MS);
});
}

Expand Down
109 changes: 109 additions & 0 deletions packages/core/test/tracing/integrations/nativeframes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,4 +273,113 @@ describe('NativeFramesInstrumentation', () => {
}),
]);
});

it('attaches frame data 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);

await startSpan({ name: 'test' }, async () => {
startSpan({ name: 'child-span' }, () => {});
await Promise.resolve(); // Flush frame captures
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.total': 50,
'frames.slow': 5,
'frames.frozen': 2,
}),
);
});

it('does not attach frame data to child spans when deltas are zero', async () => {
const frames = {
totalFrames: 100,
slowFrames: 10,
frozenFrames: 5,
};
mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(frames); // Same frames = delta of 0

await startSpan({ name: 'test' }, async () => {
startSpan({ name: 'child-span' }, () => {});
await Promise.resolve(); // Flush frame captures
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.total');
expect(childSpan!.data).not.toHaveProperty('frames.slow');
expect(childSpan!.data).not.toHaveProperty('frames.frozen');
});

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 };
const child2StartFrames = { totalFrames: 120, slowFrames: 12, frozenFrames: 6 };
const child1EndFrames = { totalFrames: 120, slowFrames: 12, frozenFrames: 6 };
const child2EndFrames = { totalFrames: 150, slowFrames: 15, frozenFrames: 8 };
const rootEndFrames = { totalFrames: 200, slowFrames: 20, frozenFrames: 10 };

mockFunction(NATIVE.fetchNativeFrames)
.mockResolvedValueOnce(rootStartFrames)
.mockResolvedValueOnce(child1StartFrames)
.mockResolvedValueOnce(child2StartFrames)
.mockResolvedValueOnce(child1EndFrames)
.mockResolvedValueOnce(child2EndFrames)
.mockResolvedValueOnce(rootEndFrames);

await startSpan({ name: 'test' }, async () => {
startSpan({ name: 'child-span-1' }, () => {});
startSpan({ name: 'child-span-2' }, () => {});

await Promise.resolve(); // Flush all frame captures
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

await client.flush();

expect(client.event).toBeDefined();

const childSpan1 = client.event!.spans!.find(s => s.description === 'child-span-1');
expect(childSpan1).toBeDefined();
expect(childSpan1!.data).toEqual(
expect.objectContaining({
'frames.total': 20,
'frames.slow': 2,
'frames.frozen': 1,
}),
);

const childSpan2 = client.event!.spans!.find(s => s.description === 'child-span-2');
expect(childSpan2).toBeDefined();
expect(childSpan2!.data).toEqual(
expect.objectContaining({
'frames.total': 30,
'frames.slow': 3,
'frames.frozen': 2,
}),
);
});
});
Loading