Skip to content

Commit d8406e9

Browse files
antonisclaude
andauthored
fix(core): Deduplicate native HTTP breadcrumbs (#6132)
* fix(core): Deduplicate native HTTP breadcrumbs that duplicate JS XHR/fetch breadcrumbs Closes #3045 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: Add changelog entry for duplicate HTTP breadcrumbs fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(core): Handle missing status_code in breadcrumb deduplication Network errors, aborted requests, and CORS failures produce breadcrumbs without a status_code. Treat both sides being null/undefined as a match. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(core): Handle numeric timestamps in breadcrumbFromObject Previously only string timestamps were parsed; numeric timestamps (seconds since epoch) were silently dropped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix changelog --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 52fd836 commit d8406e9

6 files changed

Lines changed: 426 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
### Fixes
2222

23+
- Deduplicate native HTTP breadcrumbs that duplicate JS XHR/fetch breadcrumbs ([#6132](https://github.com/getsentry/sentry-react-native/pull/6132))
2324
- Fix duplicate JS error reporting on iOS New Architecture when the native SDK is initialized early via `sentry.options.json` ("Capture App Start Errors"). It's done by applying the `ExceptionsManager.reportException` C++ wrapper filter in both init paths ([#6145](https://github.com/getsentry/sentry-react-native/pull/6145))
2425
- Fix boolean options from `sentry.options.json` being ignored on Android when using `RNSentrySDK.init` ([#6130](https://github.com/getsentry/sentry-react-native/pull/6130))
2526
- Fix `sentry-expo-upload-sourcemaps` failing for projects with `devEngines.packageManager` set to non-npm managers ([#6155](https://github.com/getsentry/sentry-react-native/pull/6155))

packages/core/src/js/breadcrumb.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export function breadcrumbFromObject(candidate: BreadcrumbCandidate): Breadcrumb
3737
if (!isNaN(timestampSeconds)) {
3838
breadcrumb.timestamp = timestampSeconds;
3939
}
40+
} else if (typeof candidate.timestamp === 'number' && !isNaN(candidate.timestamp)) {
41+
breadcrumb.timestamp = candidate.timestamp;
4042
}
4143

4244
return breadcrumb;

packages/core/src/js/integrations/breadcrumbs.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,8 @@ interface BreadcrumbsOptions {
5757

5858
export const breadcrumbsIntegration = (options: Partial<BreadcrumbsOptions> = {}): Integration => {
5959
const _options: BreadcrumbsOptions = {
60-
// FIXME: In mobile environment XHR is implemented by native APIs, which are instrumented by the Native SDK.
61-
// This will cause duplicates in React Native. On iOS `NSURLSession` is instrumented by default. On Android
62-
// `OkHttp` is only instrumented by SAGP.
60+
// In mobile environment XHR is implemented by native APIs, which are instrumented by the Native SDK.
61+
// Duplicates from JS and native HTTP breadcrumbs are deduplicated in `deviceContextIntegration`.
6362
xhr: true,
6463
console: true,
6564
sentry: true,

packages/core/src/js/integrations/devicecontext.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* oxlint-disable eslint(complexity) */
2-
import type { Client, Event, EventHint, Integration } from '@sentry/core';
2+
import type { Breadcrumb, Client, Event, EventHint, Integration } from '@sentry/core';
33

44
import { debug, severityLevelFromString } from '@sentry/core';
55
import { AppState } from 'react-native';
@@ -86,11 +86,72 @@ async function processEvent(event: Event, _hint: EventHint, client: Client): Pro
8686
: undefined;
8787
if (nativeBreadcrumbs) {
8888
const maxBreadcrumbs = client?.getOptions().maxBreadcrumbs ?? 100; // Default is 100.
89-
event.breadcrumbs = nativeBreadcrumbs
90-
.concat(event.breadcrumbs || []) // concatenate the native and js breadcrumbs
91-
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)) // sort by timestamp
92-
.slice(-maxBreadcrumbs); // keep the last maxBreadcrumbs
89+
const dedupedNativeBreadcrumbs = deduplicateNativeHttpBreadcrumbs(nativeBreadcrumbs, event.breadcrumbs || []);
90+
event.breadcrumbs = dedupedNativeBreadcrumbs
91+
.concat(event.breadcrumbs || [])
92+
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
93+
.slice(-maxBreadcrumbs);
9394
}
9495

9596
return event;
9697
}
98+
99+
const HTTP_BREADCRUMB_DEDUP_TIMESTAMP_TOLERANCE_SECONDS = 2;
100+
101+
/**
102+
* Removes native HTTP breadcrumbs that are duplicates of JS XHR/fetch breadcrumbs.
103+
*
104+
* React Native's networking (fetch/XHR) is implemented via native APIs (NSURLSession on iOS,
105+
* OkHttp on Android). Both the JS SDK and the native SDK instrument these requests independently,
106+
* resulting in duplicate breadcrumbs: a JS "xhr" breadcrumb and a native "http" breadcrumb
107+
* for the same request.
108+
*
109+
* Each JS breadcrumb can only consume one native match to avoid false positives
110+
* when there are legitimate consecutive identical requests.
111+
*/
112+
function deduplicateNativeHttpBreadcrumbs(nativeBreadcrumbs: Breadcrumb[], jsBreadcrumbs: Breadcrumb[]): Breadcrumb[] {
113+
const jsHttpBreadcrumbs = jsBreadcrumbs.filter(
114+
b => b.type === 'http' && (b.category === 'xhr' || b.category === 'fetch'),
115+
);
116+
117+
if (jsHttpBreadcrumbs.length === 0) {
118+
return nativeBreadcrumbs;
119+
}
120+
121+
const consumedJsIndices = new Set<number>();
122+
123+
return nativeBreadcrumbs.filter(nativeBreadcrumb => {
124+
if (nativeBreadcrumb.type !== 'http' || nativeBreadcrumb.category !== 'http') {
125+
return true;
126+
}
127+
128+
const matchIndex = jsHttpBreadcrumbs.findIndex((jsBreadcrumb, index) => {
129+
if (consumedJsIndices.has(index)) {
130+
return false;
131+
}
132+
133+
const sameMethod = nativeBreadcrumb.data?.method === jsBreadcrumb.data?.method;
134+
const sameUrl = nativeBreadcrumb.data?.url === jsBreadcrumb.data?.url;
135+
const nativeStatus = nativeBreadcrumb.data?.status_code;
136+
const jsStatus = jsBreadcrumb.data?.status_code;
137+
const sameStatus =
138+
nativeStatus == null && jsStatus == null
139+
? true
140+
: nativeStatus != null && jsStatus != null && Number(nativeStatus) === Number(jsStatus);
141+
const withinTimeTolerance =
142+
nativeBreadcrumb.timestamp != null &&
143+
jsBreadcrumb.timestamp != null &&
144+
Math.abs(nativeBreadcrumb.timestamp - jsBreadcrumb.timestamp) <=
145+
HTTP_BREADCRUMB_DEDUP_TIMESTAMP_TOLERANCE_SECONDS;
146+
147+
return sameMethod && sameUrl && sameStatus && withinTimeTolerance;
148+
});
149+
150+
if (matchIndex !== -1) {
151+
consumedJsIndices.add(matchIndex);
152+
return false;
153+
}
154+
155+
return true;
156+
});
157+
}

packages/core/test/breadcrumb.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,31 @@ describe('Breadcrumb', () => {
4343
});
4444
});
4545

46+
it('convert plain object with numeric timestamp to a valid Breadcrumb', () => {
47+
const candidate = {
48+
type: 'test',
49+
level: 'info',
50+
timestamp: 1730985899,
51+
};
52+
const breadcrumb = breadcrumbFromObject(candidate);
53+
expect(breadcrumb).toEqual(<Breadcrumb>{
54+
type: 'test',
55+
level: 'info',
56+
timestamp: 1730985899,
57+
});
58+
});
59+
60+
it('ignores NaN numeric timestamp', () => {
61+
const candidate = {
62+
type: 'test',
63+
timestamp: NaN,
64+
};
65+
const breadcrumb = breadcrumbFromObject(candidate);
66+
expect(breadcrumb).toEqual(<Breadcrumb>{
67+
type: 'test',
68+
});
69+
});
70+
4671
it('convert empty object to a valid Breadcrumb', () => {
4772
const candidate = {};
4873
const breadcrumb = breadcrumbFromObject(candidate);

0 commit comments

Comments
 (0)