|
1 | 1 | /* 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'; |
3 | 3 |
|
4 | 4 | import { debug, severityLevelFromString } from '@sentry/core'; |
5 | 5 | import { AppState } from 'react-native'; |
@@ -86,11 +86,72 @@ async function processEvent(event: Event, _hint: EventHint, client: Client): Pro |
86 | 86 | : undefined; |
87 | 87 | if (nativeBreadcrumbs) { |
88 | 88 | 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); |
93 | 94 | } |
94 | 95 |
|
95 | 96 | return event; |
96 | 97 | } |
| 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 | +} |
0 commit comments