Skip to content

Commit

Permalink
feat(core): Exclude Dev Server and Sentry Dsn request from Breadcrumbs (
Browse files Browse the repository at this point in the history
#4240)

* Adds breadcrumb origin in RNSentryBreadcrumb dictionary parsing

* Use 8.38.0-beta.1 Cocoa SDK that has the new origin field

* Adds changelog

* Fixes sentry-java breaking changes

* Adds origin native tests

* Adds Capture exception with breadcrumb in the sample

* Set react native as event origin

* Filter out events with react-native origin from the native layer

* Merge event breadcrumbs with native context

* Lint: removes empty line

* Use predicate to filter breadcrumbs

* Respect max breadcrumbs limit

* Updates changelog

* Update test names

* Fixes lint issue

* Filter out DevServer and DSN related breadcrumbs

* Adds changelog

* Keep the last maxBreadcrumbs (default 100) when merging native and js breadcrumbs

* Use client from function parameter

* Refactor and test RNSentryModuleImpl.fetchNativeDeviceContexts (#4253)

* Refactor fetchNativeDeviceContexts for testability

* Test fetchNativeDeviceContexts

* Adds new line at the end

* Revert "Filter out DevServer and DSN related breadcrumbs"

This reverts commit 87bdc77.

* Passes development server url as an option to the native sdks

* Filter out Dev Server and Sentry Dsn breadcrumbs on Android

* Filter out Dev Server and Sentry Dsn breadcrumbs on iOS

* Filter out Dev Server and Sentry Dsn breadcrumbs on JS

* Adds Java tests

* Adds Cocoa tests

* Adds JS tests

* Sets correct spacing in import

Co-authored-by: LucasZF <[email protected]>

* Fixes changelog typo

Co-authored-by: LucasZF <[email protected]>

* Handles undefined dev server urls

Co-authored-by: LucasZF <[email protected]>

* Handles undefined dsns

* Adds tests for undefined dev servers and dsns

* Handles undefined dev server urls in native code

* Updates test cases

* Uses the url from the passed dsn to filter breadcrumbs

* Handles nil dsn though this state should never be reached due to SentryOptions validation

* Use startsWith to check url matching

Co-authored-by: LucasZF <[email protected]>

* Check url with prefix instead of contains for efficiency

* Fix comment typo

Co-authored-by: LucasZF <[email protected]>

* Fix comment typo

Co-authored-by: LucasZF <[email protected]>

* Fix comment typo

Co-authored-by: LucasZF <[email protected]>

* Update CHANGELOG.md

* Safely parses dsn url

* Adds test case for url exception

---------

Co-authored-by: LucasZF <[email protected]>
Co-authored-by: Krystof Woldrich <[email protected]>
  • Loading branch information
3 people authored Nov 18, 2024
1 parent fa2ef81 commit 112c4f8
Show file tree
Hide file tree
Showing 10 changed files with 495 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
### Fixes

- Prevents exception capture context from being overwritten by native scope sync ([#4124](https://github.com/getsentry/sentry-react-native/pull/4124))
- Excludes Dev Server and Sentry Dsn requests from Breadcrumbs ([#4240](https://github.com/getsentry/sentry-react-native/pull/4240))

### Dependencies

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.common.JavascriptException
import io.sentry.Breadcrumb
import io.sentry.ILogger
import io.sentry.SentryLevel
import io.sentry.android.core.SentryAndroidOptions
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
Expand Down Expand Up @@ -134,4 +136,109 @@ class RNSentryModuleImplTest {
module.getSentryAndroidOptions(actualOptions, JavaOnlyMap.of(), logger)
assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
}

@Test
fun `beforeBreadcrumb callback filters out Sentry DSN requests breadcrumbs`() {
val options = SentryAndroidOptions()
val rnOptions = JavaOnlyMap.of(
"dsn", "https://[email protected]/1234567",
"devServerUrl", "http://localhost:8081",
)
module.getSentryAndroidOptions(options, rnOptions, logger)

val breadcrumb = Breadcrumb().apply {
type = "http"
setData("url", "https://def.ingest.sentry.io/1234567")
}

val result = options.beforeBreadcrumb?.execute(breadcrumb, mock())

assertNull("Breadcrumb should be filtered out", result)
}

@Test
fun `beforeBreadcrumb callback filters out dev server breadcrumbs`() {
val mockDevServerUrl = "http://localhost:8081"
val options = SentryAndroidOptions()
val rnOptions = JavaOnlyMap.of(
"dsn", "https://[email protected]/1234567",
"devServerUrl", mockDevServerUrl,
)
module.getSentryAndroidOptions(options, rnOptions, logger)

val breadcrumb = Breadcrumb().apply {
type = "http"
setData("url", mockDevServerUrl)
}

val result = options.beforeBreadcrumb?.execute(breadcrumb, mock())

assertNull("Breadcrumb should be filtered out", result)
}

@Test
fun `beforeBreadcrumb callback does not filter out non dev server or dsn breadcrumbs`() {
val options = SentryAndroidOptions()
val rnOptions = JavaOnlyMap.of(
"dsn", "https://[email protected]/1234567",
"devServerUrl", "http://localhost:8081",
)
module.getSentryAndroidOptions(options, rnOptions, logger)

val breadcrumb = Breadcrumb().apply {
type = "http"
setData("url", "http://testurl.com/service")
}

val result = options.beforeBreadcrumb?.execute(breadcrumb, mock())

assertEquals(breadcrumb, result)
}

@Test
fun `the breadcrumb is not filtered out when the dev server url and dsn are not passed`() {
val options = SentryAndroidOptions()
module.getSentryAndroidOptions(options, JavaOnlyMap(), logger)

val breadcrumb = Breadcrumb().apply {
type = "http"
setData("url", "http://testurl.com/service")
}

val result = options.beforeBreadcrumb?.execute(breadcrumb, mock())

assertEquals(breadcrumb, result)
}

@Test
fun `the breadcrumb is not filtered out when the dev server url is not passed and the dsn does not match`() {
val options = SentryAndroidOptions()
val rnOptions = JavaOnlyMap.of("dsn", "https://[email protected]/1234567")
module.getSentryAndroidOptions(options, rnOptions, logger)

val breadcrumb = Breadcrumb().apply {
type = "http"
setData("url", "http://testurl.com/service")
}

val result = options.beforeBreadcrumb?.execute(breadcrumb, mock())

assertEquals(breadcrumb, result)
}

@Test
fun `the breadcrumb is not filtered out when the dev server url does not match and the dsn is not passed`() {
val options = SentryAndroidOptions()
val rnOptions = JavaOnlyMap.of("devServerUrl", "http://localhost:8081")
module.getSentryAndroidOptions(options, rnOptions, logger)

val breadcrumb = Breadcrumb().apply {
type = "http"
setData("url", "http://testurl.com/service")
}

val result = options.beforeBreadcrumb?.execute(breadcrumb, mock())

assertEquals(breadcrumb, result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,85 @@ - (void)testPassesErrorOnWrongDsn
XCTAssertNotNil(error, @"Did not created error on invalid dsn");
}

- (void)testBeforeBreadcrumbsCallbackFiltersOutSentryDsnRequestBreadcrumbs
{
RNSentry *rnSentry = [[RNSentry alloc] init];
NSError *error = nil;

NSDictionary *_Nonnull mockedDictionary = @{
@"dsn" : @"https://[email protected]/1234567",
@"devServerUrl" : @"http://localhost:8081"
};
SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error];

SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init];
breadcrumb.type = @"http";
breadcrumb.data = @{ @"url" : @"https://def.ingest.sentry.io/1234567" };

SentryBreadcrumb *result = options.beforeBreadcrumb(breadcrumb);

XCTAssertNil(result, @"Breadcrumb should be filtered out");
}

- (void)testBeforeBreadcrumbsCallbackFiltersOutDevServerRequestBreadcrumbs
{
RNSentry *rnSentry = [[RNSentry alloc] init];
NSError *error = nil;

NSString *mockDevServer = @"http://localhost:8081";

NSDictionary *_Nonnull mockedDictionary =
@{ @"dsn" : @"https://[email protected]/1234567", @"devServerUrl" : mockDevServer };
SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error];

SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init];
breadcrumb.type = @"http";
breadcrumb.data = @{ @"url" : mockDevServer };

SentryBreadcrumb *result = options.beforeBreadcrumb(breadcrumb);

XCTAssertNil(result, @"Breadcrumb should be filtered out");
}

- (void)testBeforeBreadcrumbsCallbackDoesNotFiltersOutNonDevServerOrDsnRequestBreadcrumbs
{
RNSentry *rnSentry = [[RNSentry alloc] init];
NSError *error = nil;

NSDictionary *_Nonnull mockedDictionary = @{
@"dsn" : @"https://[email protected]/1234567",
@"devServerUrl" : @"http://localhost:8081"
};
SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error];

SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init];
breadcrumb.type = @"http";
breadcrumb.data = @{ @"url" : @"http://testurl.com/service" };

SentryBreadcrumb *result = options.beforeBreadcrumb(breadcrumb);

XCTAssertEqual(breadcrumb, result);
}

- (void)testBeforeBreadcrumbsCallbackKeepsBreadcrumbWhenDevServerUrlIsNotPassedAndDsnDoesNotMatch
{
RNSentry *rnSentry = [[RNSentry alloc] init];
NSError *error = nil;

NSDictionary *_Nonnull mockedDictionary = @{ // dsn is always validated in SentryOptions initialization
@"dsn" : @"https://[email protected]/1234567"
};
SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error];

SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init];
breadcrumb.type = @"http";
breadcrumb.data = @{ @"url" : @"http://testurl.com/service" };

SentryBreadcrumb *result = options.beforeBreadcrumb(breadcrumb);

XCTAssertEqual(breadcrumb, result);
}

- (void)testEventFromSentryCocoaReactNativeHasOriginAndEnvironmentTags
{
RNSentry *rnSentry = [[RNSentry alloc] init];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.Iterator;
Expand Down Expand Up @@ -277,6 +279,21 @@ protected void getSentryAndroidOptions(
options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter());
}

// Exclude Dev Server and Sentry Dsn request from Breadcrumbs
String dsn = getURLFromDSN(rnOptions.getString("dsn"));
String devServerUrl = rnOptions.getString("devServerUrl");
options.setBeforeBreadcrumb(
(breadcrumb, hint) -> {
Object urlObject = breadcrumb.getData("url");
String url = urlObject instanceof String ? (String) urlObject : "";
if ("http".equals(breadcrumb.getType())
&& ((dsn != null && url.startsWith(dsn))
|| (devServerUrl != null && url.startsWith(devServerUrl)))) {
return null;
}
return breadcrumb;
});

// React native internally throws a JavascriptException.
// we want to ignore it on the native side to avoid sending it twice.
options.addIgnoredExceptionForType(JavascriptException.class);
Expand Down Expand Up @@ -1001,4 +1018,17 @@ private boolean checkAndroidXAvailability() {
private boolean isFrameMetricsAggregatorAvailable() {
return androidXAvailable && frameMetricsAggregator != null;
}

public static @Nullable String getURLFromDSN(@Nullable String dsn) {
if (dsn == null) {
return null;
}
URI uri = null;
try {
uri = new URI(dsn);
} catch (URISyntaxException e) {
return null;
}
return uri.getScheme() + "://" + uri.getHost();
}
}
25 changes: 25 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,22 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)
return nil;
}

// Exclude Dev Server and Sentry Dsn request from Breadcrumbs
NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]];
NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"];
sentryOptions.beforeBreadcrumb
= ^SentryBreadcrumb *_Nullable(SentryBreadcrumb *_Nonnull breadcrumb)
{
NSString *url = breadcrumb.data[@"url"] ?: @"";

if ([@"http" isEqualToString:breadcrumb.type]
&& ((dsn != nil && [url hasPrefix:dsn])
|| (devServerUrl != nil && [url hasPrefix:devServerUrl]))) {
return nil;
}
return breadcrumb;
};

if ([mutableOptions valueForKey:@"enableNativeCrashHandling"] != nil) {
BOOL enableNativeCrashHandling = [mutableOptions[@"enableNativeCrashHandling"] boolValue];

Expand Down Expand Up @@ -204,6 +220,15 @@ - (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)
return sentryOptions;
}

- (NSString *_Nullable)getURLFromDSN:(NSString *)dsn
{
NSURL *url = [NSURL URLWithString:dsn];
if (!url) {
return nil;
}
return [NSString stringWithFormat:@"%@://%@", url.scheme, url.host];
}

- (void)setEventOriginTag:(SentryEvent *)event
{
if (event.sdk != nil) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
import { dateTimestampInSeconds, logger, SentryError } from '@sentry/utils';
import { Alert } from 'react-native';

import { getDevServer } from './integrations/debugsymbolicatorutils';
import { defaultSdkInfo } from './integrations/sdkinfo';
import { getDefaultSidecarUrl } from './integrations/spotlight';
import type { ReactNativeClientOptions } from './options';
Expand Down Expand Up @@ -146,6 +147,7 @@ export class ReactNativeClient extends BaseClient<ReactNativeClientOptions> {
NATIVE.initNativeSdk({
...this._options,
defaultSidecarUrl: getDefaultSidecarUrl(),
devServerUrl: getDevServer()?.url || '',
mobileReplayOptions:
this._integrations[MOBILE_REPLAY_INTEGRATION_NAME] &&
'options' in this._integrations[MOBILE_REPLAY_INTEGRATION_NAME]
Expand Down
44 changes: 42 additions & 2 deletions packages/core/src/js/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import {
defaultStackParser,
makeFetchTransport,
} from '@sentry/react';
import type { Integration, Scope,UserFeedback } from '@sentry/types';
import type { Breadcrumb, BreadcrumbHint, Integration, Scope, UserFeedback } from '@sentry/types';
import { logger, stackParserFromStackParserOptions } from '@sentry/utils';
import * as React from 'react';

import { ReactNativeClient } from './client';
import { getDevServer } from './integrations/debugsymbolicatorutils';
import { getDefaultIntegrations } from './integrations/default';
import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options';
import { shouldEnableNativeNagger } from './options';
Expand Down Expand Up @@ -62,6 +63,45 @@ export function init(passedOptions: ReactNativeOptions): void {
enableSyncToNative(getIsolationScope());
}

const getURLFromDSN = (dsn: string | null): string | undefined => {
if (!dsn) {
return undefined;
}
try {
const url = new URL(dsn);
return `${url.protocol}//${url.host}`;
} catch (e) {
logger.error('Failed to extract url from DSN', e);
return undefined;
}
};

const userBeforeBreadcrumb = safeFactory(passedOptions.beforeBreadcrumb, { loggerMessage: 'The beforeBreadcrumb threw an error' });

// Exclude Dev Server and Sentry Dsn request from Breadcrumbs
const devServerUrl = getDevServer()?.url;
const dsn = getURLFromDSN(passedOptions.dsn);
const defaultBeforeBreadcrumb = (breadcrumb: Breadcrumb, _hint?: BreadcrumbHint): Breadcrumb | null => {
const type = breadcrumb.type || '';
const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : '';
if (type === 'http' && ((devServerUrl && url.startsWith(devServerUrl)) || (dsn && url.startsWith(dsn)))) {
return null;
}
return breadcrumb;
};

const chainedBeforeBreadcrumb = (breadcrumb: Breadcrumb, hint?: BreadcrumbHint): Breadcrumb | null => {
let modifiedBreadcrumb = breadcrumb;
if (userBeforeBreadcrumb) {
const result = userBeforeBreadcrumb(breadcrumb, hint);
if (result === null) {
return null;
}
modifiedBreadcrumb = result;
}
return defaultBeforeBreadcrumb(modifiedBreadcrumb, hint);
};

const options: ReactNativeClientOptions = {
...DEFAULT_OPTIONS,
...passedOptions,
Expand All @@ -81,7 +121,7 @@ export function init(passedOptions: ReactNativeOptions): void {
maxQueueSize,
integrations: [],
stackParser: stackParserFromStackParserOptions(passedOptions.stackParser || defaultStackParser),
beforeBreadcrumb: safeFactory(passedOptions.beforeBreadcrumb, { loggerMessage: 'The beforeBreadcrumb threw an error' }),
beforeBreadcrumb: chainedBeforeBreadcrumb,
initialScope: safeFactory(passedOptions.initialScope, { loggerMessage: 'The initialScope threw an error' }),
};
if ('tracesSampler' in options) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface Screenshot {
}

export type NativeSdkOptions = Partial<ReactNativeClientOptions> & {
devServerUrl: string | undefined;
defaultSidecarUrl: string | undefined;
} & {
mobileReplayOptions: MobileReplayOptions | undefined;
Expand Down
Loading

0 comments on commit 112c4f8

Please sign in to comment.