From afe9d2c76448bf1da820cc773307f27874b8c2ce Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Feb 2025 12:28:16 +0100 Subject: [PATCH 1/3] Fix `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` being unset by external options (#4207) * When parsing ExternalOptions that are missing keep the value in SentryOptions for filter lists * changelog --- CHANGELOG.md | 3 +++ sentry/api/sentry.api | 1 + .../main/java/io/sentry/ExternalOptions.java | 6 ++--- .../io/sentry/config/PropertiesProvider.java | 12 +++++++++ .../java/io/sentry/ExternalOptionsTest.kt | 24 +++++++++++++++++ .../test/java/io/sentry/SentryOptionsTest.kt | 27 +++++++++++++++++++ 6 files changed, 70 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52dddc8b44..3c7fcb144b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ### Fixes - `SentryOptions.setTracePropagationTargets` is no longer marked internal ([#4170](https://github.com/getsentry/sentry-java/pull/4170)) +- Fix `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` being unset by external options like `sentry.properties` or ENV vars ([#4207](https://github.com/getsentry/sentry-java/pull/4207)) + - Whenever parsing of external options was enabled (`enableExternalConfiguration`), which is the default for many integrations, the values set on `SentryOptions` passed to `Sentry.init` would be lost + - Even if the value was not set in any external configuration it would still be set to an empty list ### Behavioural Changes diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4b6007f461..82e18268c9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4029,6 +4029,7 @@ public abstract interface class io/sentry/config/PropertiesProvider { public fun getBooleanProperty (Ljava/lang/String;)Ljava/lang/Boolean; public fun getDoubleProperty (Ljava/lang/String;)Ljava/lang/Double; public fun getList (Ljava/lang/String;)Ljava/util/List; + public fun getListOrNull (Ljava/lang/String;)Ljava/util/List; public fun getLongProperty (Ljava/lang/String;)Ljava/lang/Long; public abstract fun getMap (Ljava/lang/String;)Ljava/util/Map; public abstract fun getProperty (Ljava/lang/String;)Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index ed2b4e1103..62954a0e9b 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -128,7 +128,7 @@ public final class ExternalOptions { } options.setIdleTimeout(propertiesProvider.getLongProperty("idle-timeout")); - options.setIgnoredErrors(propertiesProvider.getList("ignored-errors")); + options.setIgnoredErrors(propertiesProvider.getListOrNull("ignored-errors")); options.setEnabled(propertiesProvider.getBooleanProperty("enabled")); @@ -138,8 +138,8 @@ public final class ExternalOptions { options.setSendModules(propertiesProvider.getBooleanProperty("send-modules")); options.setSendDefaultPii(propertiesProvider.getBooleanProperty("send-default-pii")); - options.setIgnoredCheckIns(propertiesProvider.getList("ignored-checkins")); - options.setIgnoredTransactions(propertiesProvider.getList("ignored-transactions")); + options.setIgnoredCheckIns(propertiesProvider.getListOrNull("ignored-checkins")); + options.setIgnoredTransactions(propertiesProvider.getListOrNull("ignored-transactions")); options.setEnableBackpressureHandling( propertiesProvider.getBooleanProperty("enable-backpressure-handling")); diff --git a/sentry/src/main/java/io/sentry/config/PropertiesProvider.java b/sentry/src/main/java/io/sentry/config/PropertiesProvider.java index 5dc2e36741..b30bb8edb7 100644 --- a/sentry/src/main/java/io/sentry/config/PropertiesProvider.java +++ b/sentry/src/main/java/io/sentry/config/PropertiesProvider.java @@ -38,6 +38,18 @@ default List getList(final @NotNull String property) { return value != null ? Arrays.asList(value.split(",")) : Collections.emptyList(); } + /** + * Resolves a list of values for a property given by it's name. + * + * @param property - the property name + * @return the list or null if not found + */ + @Nullable + default List getListOrNull(final @NotNull String property) { + final String value = getProperty(property); + return value != null ? Arrays.asList(value.split(",")) : null; + } + /** * Resolves property given by it's name. * diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index f32b6cf8c0..5bb0e5bae0 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -218,6 +218,14 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with null ignored errors if missing`() { + val logger = mock() + withPropertiesFile("Another .*", logger) { options -> + assertNull(options.ignoredErrors) + } + } + @Test fun `creates options with single bundle ID using external properties`() { withPropertiesFile("bundle-ids=12ea7a02-46ac-44c0-a5bb-6d1fd9586411") { options -> @@ -270,6 +278,14 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with null ignoredCheckIns if missing`() { + val logger = mock() + withPropertiesFile("Another .*", logger) { options -> + assertNull(options.ignoredCheckIns) + } + } + @Test fun `creates options with ignoredTransactions`() { withPropertiesFile("ignored-transactions=transactionName1,transactionName2") { options -> @@ -277,6 +293,14 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with null ignoredTransactions if missing`() { + val logger = mock() + withPropertiesFile("Another .*", logger) { options -> + assertNull(options.ignoredTransactions) + } + } + @Test fun `creates options with enableBackpressureHandling set to false`() { withPropertiesFile("enable-backpressure-handling=false") { options -> diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 278c351916..e2f4692357 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -668,4 +668,31 @@ class SentryOptionsTest { fun `when options is initialized, InitPriority is set to MEDIUM by default`() { assertEquals(SentryOptions().initPriority, InitPriority.MEDIUM) } + + @Test + fun `merging options when ignoredErrors is not set preserves the previous value`() { + val externalOptions = ExternalOptions() + val options = SentryOptions() + options.setIgnoredErrors(listOf("error1", "error2")) + options.merge(externalOptions) + assertEquals(listOf(FilterString("error1"), FilterString("error2")), options.ignoredErrors) + } + + @Test + fun `merging options when ignoredTransactions is not set preserves the previous value`() { + val externalOptions = ExternalOptions() + val options = SentryOptions() + options.setIgnoredTransactions(listOf("transaction1", "transaction2")) + options.merge(externalOptions) + assertEquals(listOf(FilterString("transaction1"), FilterString("transaction2")), options.ignoredTransactions) + } + + @Test + fun `merging options when ignoredCheckIns is not set preserves the previous value`() { + val externalOptions = ExternalOptions() + val options = SentryOptions() + options.setIgnoredCheckIns(listOf("checkin1", "checkin2")) + options.merge(externalOptions) + assertEquals(listOf(FilterString("checkin1"), FilterString("checkin2")), options.ignoredCheckIns) + } } From c8461d4fa418fdd73cf132d02f46e8b17be9f477 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 25 Feb 2025 15:04:04 +0100 Subject: [PATCH 2/3] Check `tracePropagationTargets` in OpenTelemetry propagator (#4191) * Check tracePropagationTargets in OpenTelemetry propagator * expose Attributes instead of ReadWriteSpan * add test for propagator * changelog * remove testing files --- CHANGELOG.md | 3 + .../api/sentry-opentelemetry-bootstrap.api | 3 + .../opentelemetry/IOtelSpanWrapper.java | 5 + .../OtelStrongRefSpanWrapper.java | 7 + .../opentelemetry/SentryWeakSpanStorage.java | 6 + .../api/sentry-opentelemetry-core.api | 2 + .../OpenTelemetryAttributesExtractor.java | 30 +- .../opentelemetry/OtelSentryPropagator.java | 17 +- .../sentry/opentelemetry/OtelSpanWrapper.java | 11 + .../OpenTelemetryAttributesExtractorTest.kt | 116 +++++++ .../test/kotlin/OtelSentryPropagatorTest.kt | 320 ++++++++++++++++++ 11 files changed, 507 insertions(+), 13 deletions(-) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c7fcb144b..b620355f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ### Fixes - `SentryOptions.setTracePropagationTargets` is no longer marked internal ([#4170](https://github.com/getsentry/sentry-java/pull/4170)) +- Check `tracePropagationTargets` in OpenTelemetry propagator ([#4191](https://github.com/getsentry/sentry-java/pull/4191)) + - If a URL can be retrieved from OpenTelemetry span attributes, we check it against `tracePropagationTargets` before attaching `sentry-trace` and `baggage` headers to outgoing requests + - If no URL can be retrieved we always attach the headers - Fix `ignoredErrors`, `ignoredTransactions` and `ignoredCheckIns` being unset by external options like `sentry.properties` or ENV vars ([#4207](https://github.com/getsentry/sentry-java/pull/4207)) - Whenever parsing of external options was enabled (`enableExternalConfiguration`), which is the default for many integrations, the values set on `SentryOptions` passed to `Sentry.init` would be lost - Even if the value was not set in any external configuration it would still be set to an empty list diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api index adb976adc0..8eeb9936f7 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api @@ -1,6 +1,7 @@ public abstract interface class io/sentry/opentelemetry/IOtelSpanWrapper : io/sentry/ISpan { public abstract fun getData ()Ljava/util/Map; public abstract fun getMeasurements ()Ljava/util/Map; + public abstract fun getOpenTelemetrySpanAttributes ()Lio/opentelemetry/api/common/Attributes; public abstract fun getScopes ()Lio/sentry/IScopes; public abstract fun getTags ()Ljava/util/Map; public abstract fun getTraceId ()Lio/sentry/protocol/SentryId; @@ -51,6 +52,7 @@ public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/ public fun getDescription ()Ljava/lang/String; public fun getFinishDate ()Lio/sentry/SentryDate; public fun getMeasurements ()Ljava/util/Map; + public fun getOpenTelemetrySpanAttributes ()Lio/opentelemetry/api/common/Attributes; public fun getOperation ()Ljava/lang/String; public fun getSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getScopes ()Lio/sentry/IScopes; @@ -177,6 +179,7 @@ public final class io/sentry/opentelemetry/SentryOtelThreadLocalStorage : io/ope } public final class io/sentry/opentelemetry/SentryWeakSpanStorage { + public fun clear ()V public static fun getInstance ()Lio/sentry/opentelemetry/SentryWeakSpanStorage; public fun getSentrySpan (Lio/opentelemetry/api/trace/SpanContext;)Lio/sentry/opentelemetry/IOtelSpanWrapper; public fun storeSentrySpan (Lio/opentelemetry/api/trace/SpanContext;Lio/sentry/opentelemetry/IOtelSpanWrapper;)V diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/IOtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/IOtelSpanWrapper.java index 0184db0eab..1eefc854a8 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/IOtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/IOtelSpanWrapper.java @@ -1,5 +1,6 @@ package io.sentry.opentelemetry; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.context.Context; import io.sentry.IScopes; import io.sentry.ISpan; @@ -47,4 +48,8 @@ public interface IOtelSpanWrapper extends ISpan { @NotNull Context storeInContext(Context context); + + @ApiStatus.Internal + @Nullable + Attributes getOpenTelemetrySpanAttributes(); } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java index f2ea37b335..7f026742e9 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java @@ -1,5 +1,6 @@ package io.sentry.opentelemetry; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Context; import io.sentry.BaggageHeader; @@ -303,4 +304,10 @@ public void setContext(@NotNull String key, @NotNull Object context) { public @NotNull ISentryLifecycleToken makeCurrent() { return delegate.makeCurrent(); } + + @ApiStatus.Internal + @Override + public @Nullable Attributes getOpenTelemetrySpanAttributes() { + return delegate.getOpenTelemetrySpanAttributes(); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java index c28d4ed7ff..5096c011e2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java @@ -7,6 +7,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; /** * Weakly references wrappers for OpenTelemetry spans meaning they'll be cleaned up when the @@ -44,4 +45,9 @@ public void storeSentrySpan( final @NotNull SpanContext otelSpan, final @NotNull IOtelSpanWrapper sentrySpan) { this.sentrySpans.put(otelSpan, sentrySpan); } + + @TestOnly + public void clear() { + sentrySpans.clear(); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index 1e9bb60416..739fc7eb1a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -1,6 +1,7 @@ public final class io/sentry/opentelemetry/OpenTelemetryAttributesExtractor { public fun ()V public fun extract (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/ISpan;Lio/sentry/IScope;)V + public fun extractUrl (Lio/opentelemetry/api/common/Attributes;)Ljava/lang/String; } public final class io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor : io/sentry/EventProcessor { @@ -60,6 +61,7 @@ public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelem public fun getDescription ()Ljava/lang/String; public fun getFinishDate ()Lio/sentry/SentryDate; public fun getMeasurements ()Ljava/util/Map; + public fun getOpenTelemetrySpanAttributes ()Lio/opentelemetry/api/common/Attributes; public fun getOperation ()Ljava/lang/String; public fun getSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getScopes ()Lio/sentry/IScopes; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java index 431b4d274e..6575776518 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryAttributesExtractor.java @@ -24,7 +24,8 @@ public void extract( addRequestAttributesToScope(attributes, scope); } - private void addRequestAttributesToScope(Attributes attributes, IScope scope) { + private void addRequestAttributesToScope( + final @NotNull Attributes attributes, final @NotNull IScope scope) { if (scope.getRequest() == null) { scope.setRequest(new Request()); } @@ -36,20 +37,13 @@ private void addRequestAttributesToScope(Attributes attributes, IScope scope) { } if (request.getUrl() == null) { - final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); - if (urlFull != null) { - final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(urlFull); + final @Nullable String url = extractUrl(attributes); + if (url != null) { + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(url); urlDetails.applyToRequest(request); } } - if (request.getUrl() == null) { - final String urlString = buildUrlString(attributes); - if (!urlString.isEmpty()) { - request.setUrl(urlString); - } - } - if (request.getQueryString() == null) { final @Nullable String query = attributes.get(UrlAttributes.URL_QUERY); if (query != null) { @@ -59,6 +53,20 @@ private void addRequestAttributesToScope(Attributes attributes, IScope scope) { } } + public @Nullable String extractUrl(final @NotNull Attributes attributes) { + final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); + if (urlFull != null) { + return urlFull; + } + + final String urlString = buildUrlString(attributes); + if (!urlString.isEmpty()) { + return urlString; + } + + return null; + } + private @NotNull String buildUrlString(final @NotNull Attributes attributes) { final @Nullable String scheme = attributes.get(UrlAttributes.URL_SCHEME); final @Nullable String serverAddress = attributes.get(ServerAttributes.SERVER_ADDRESS); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java index fc2e3d426b..c36f182992 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java @@ -2,6 +2,7 @@ import static io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.TraceFlags; @@ -32,6 +33,8 @@ public final class OtelSentryPropagator implements TextMapPropagator { Arrays.asList(SentryTraceHeader.SENTRY_TRACE_HEADER, BaggageHeader.BAGGAGE_HEADER); private final @NotNull SentryWeakSpanStorage spanStorage = SentryWeakSpanStorage.getInstance(); private final @NotNull IScopes scopes; + private final @NotNull OpenTelemetryAttributesExtractor attributesExtractor = + new OpenTelemetryAttributesExtractor(); public OtelSentryPropagator() { this(ScopesAdapter.getInstance()); @@ -73,9 +76,11 @@ public void inject(final Context context, final C carrier, final TextMapSett return; } - // TODO can we use traceIfAllowed? do we have the URL here? need to access span attrs + final @Nullable String url = getUrl(sentrySpan); final @Nullable TracingUtils.TracingHeaders tracingHeaders = - TracingUtils.trace(scopes, Collections.emptyList(), sentrySpan); + url == null + ? TracingUtils.trace(scopes, Collections.emptyList(), sentrySpan) + : TracingUtils.traceIfAllowed(scopes, url, Collections.emptyList(), sentrySpan); if (tracingHeaders != null) { final @NotNull SentryTraceHeader sentryTraceHeader = tracingHeaders.getSentryTraceHeader(); @@ -87,6 +92,14 @@ public void inject(final Context context, final C carrier, final TextMapSett } } + private @Nullable String getUrl(final @NotNull IOtelSpanWrapper sentrySpan) { + final @Nullable Attributes attributes = sentrySpan.getOpenTelemetrySpanAttributes(); + if (attributes == null) { + return null; + } + return attributesExtractor.extractUrl(attributes); + } + @Override public Context extract( final Context context, final C carrier, final TextMapGetter getter) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java index 34f2d2a4d7..8d11cb8b77 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java @@ -1,5 +1,6 @@ package io.sentry.opentelemetry; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Context; import io.opentelemetry.context.Scope; @@ -198,6 +199,16 @@ public OtelSpanWrapper( return span.get(); } + @ApiStatus.Internal + @Override + public @Nullable Attributes getOpenTelemetrySpanAttributes() { + final @Nullable ReadWriteSpan readWriteSpan = span.get(); + if (readWriteSpan != null) { + return readWriteSpan.getAttributes(); + } + return null; + } + @Override public @Nullable TraceContext traceContext() { if (scopes.getOptions().isTraceSampling()) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt index f962cfa594..8063140671 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -173,6 +173,118 @@ class OpenTelemetryAttributesExtractorTest { thenUrlIsNotSet() } + @Test + fun `returns null if no URL in attributes`() { + givenAttributes(mapOf()) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns full URL if present`() { + givenAttributes( + mapOf( + UrlAttributes.URL_FULL to "https://sentry.io/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io/some/path", url) + } + + @Test + fun `returns reconstructed URL if attributes present`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io:8082/some/path", url) + } + + @Test + fun `returns reconstructed URL if attributes present without port`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io/some/path", url) + } + + @Test + fun `returns null URL if scheme missing`() { + givenAttributes( + mapOf( + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns null URL if server address missing`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path" + ) + ) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns reconstructed URL if attributes present without port and path`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io" + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io", url) + } + + @Test + fun `returns reconstructed URL if attributes present without path`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io:8082", url) + } + private fun givenAttributes(map: Map, Any>) { map.forEach { k, v -> fixture.attributes.put(k, v) @@ -183,6 +295,10 @@ class OpenTelemetryAttributesExtractorTest { OpenTelemetryAttributesExtractor().extract(fixture.spanData, fixture.sentrySpan, fixture.scope) } + private fun whenExtractingUrl(): String? { + return OpenTelemetryAttributesExtractor().extractUrl(fixture.attributes) + } + private fun thenRequestIsSet() { assertNotNull(fixture.scope.request) } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt new file mode 100644 index 0000000000..21ff416bc3 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt @@ -0,0 +1,320 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.context.Context +import io.opentelemetry.context.propagation.TextMapGetter +import io.opentelemetry.context.propagation.TextMapSetter +import io.opentelemetry.semconv.UrlAttributes +import io.sentry.BaggageHeader +import io.sentry.Sentry +import io.sentry.SentryTraceHeader +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_BAGGAGE_KEY +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_TRACE_KEY +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class OtelSentryPropagatorTest { + + val spanStorage: SentryWeakSpanStorage = SentryWeakSpanStorage.getInstance() + + @BeforeTest + fun setup() { + Sentry.init("https://key@sentry.io/proj") + } + + @AfterTest + fun cleanup() { + spanStorage.clear() + } + + @Test + fun `propagator registers for sentry-trace and baggage`() { + val propagator = OtelSentryPropagator() + assertEquals(listOf("sentry-trace", "baggage"), propagator.fields()) + } + + @Test + fun `forks root scopes if none in context without headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf() + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(Sentry.forkedRootScopes("test").parentScopes, scopes.parentScopes) + } + + @Test + fun `forks scopes from context if present without headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf() + val scopeInContext = Sentry.forkedRootScopes("test") + + val newContext = propagator.extract(Context.root().with(SENTRY_SCOPES_KEY, scopeInContext), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(scopeInContext, scopes.parentScopes) + } + + @Test + fun `forks root scopes if none in context with headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(Sentry.forkedRootScopes("test").parentScopes, scopes.parentScopes) + } + + @Test + fun `forks scopes from context if present with headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val scopeInContext = Sentry.forkedRootScopes("test") + + val newContext = propagator.extract(Context.root().with(SENTRY_SCOPES_KEY, scopeInContext), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(scopeInContext, scopes.parentScopes) + } + + @Test + fun `invalid sentry trace header returns context without modification`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf( + "sentry-trace" to "wrong", + "baggage" to "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val scopeInContext = Sentry.forkedRootScopes("test") + + val newContext = propagator.extract(Context.root().with(SENTRY_SCOPES_KEY, scopeInContext), carrier, MapGetter()) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(scopeInContext, scopes) + } + + @Test + fun `uses incoming headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val span = Span.fromContext(newContext) + assertEquals("f9118105af4a2d42b4124532cd1065ff", span.spanContext.traceId) + assertEquals("424cffc8f94feeee", span.spanContext.spanId) + assertTrue(span.spanContext.isSampled) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", newContext.get(SENTRY_TRACE_KEY)?.value) + assertEquals("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", newContext.get(SENTRY_BAGGAGE_KEY)?.toHeaderString(null)) + } + + @Test + fun `injects headers if no URL`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", carrier["baggage"]) + } + + @Test + fun `injects headers if URL in span attributes with default options`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", carrier["baggage"]) + } + + @Test + fun `injects headers if URL in span attributes with tracePropagationTargets set to same url`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", carrier["baggage"]) + } + + @Test + fun `does not inject headers if URL in span attributes with tracePropagationTargets set to different url`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("github.com")) + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if URL in span attributes with tracePropagationTargets set to same url but trace sampling disabled`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + options.isTraceSampling = false + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()).thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())).thenReturn(BaggageHeader("sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d")) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if sentry span missing`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if sentry span noop`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val sentrySpan = mock() + whenever(sentrySpan.isNoOp).thenReturn(true) + val otelSpanContext = SpanContext.create("f9118105af4a2d42b4124532cd1065ff", "424cffc8f94feeee", TraceFlags.getSampled(), TraceState.getDefault()) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if span is missing`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + propagator.inject(Context.root(), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if span is invalid`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + propagator.inject(Context.root().with(Span.getInvalid()), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } +} + +class MapGetter() : TextMapGetter> { + + override fun keys(carrier: Map): MutableIterable { + return carrier.keys.toMutableList() + } + + override fun get(carrier: Map?, key: String): String? { + return carrier?.get(key) + } +} + +class MapSetter() : TextMapSetter> { + override fun set(carrier: MutableMap?, key: String, value: String) { + carrier?.set(key, value) + } +} From 7d08e30c94196830f2aeae3726827be384ce93b3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 25 Feb 2025 15:25:27 +0100 Subject: [PATCH 3/3] fix(session-replay): Do not crash if navigation breadcrumb has no destination (#4185) * Do not crash if navigation breadcrumb has not destination * Changelog --- CHANGELOG.md | 2 ++ .../android/replay/capture/CaptureStrategy.kt | 10 ++++++-- .../capture/SessionCaptureStrategyTest.kt | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b620355f2d..840ecdbbbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ ### Fixes - `SentryOptions.setTracePropagationTargets` is no longer marked internal ([#4170](https://github.com/getsentry/sentry-java/pull/4170)) +- Session Replay: Fix crash when a navigation breadcrumb does not have "to" destination ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) +- Session Replay: Cap video segment duration to maximum 5 minutes to prevent endless video encoding in background ([#4185](https://github.com/getsentry/sentry-java/pull/4185)) - Check `tracePropagationTargets` in OpenTelemetry propagator ([#4191](https://github.com/getsentry/sentry-java/pull/4191)) - If a URL can be retrieved from OpenTelemetry span attributes, we check it against `tracePropagationTargets` before attaching `sentry-trace` and `baggage` headers to outgoing requests - If no URL can be retrieved we always attach the headers diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 98007c4553..93cb5200f6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -58,6 +58,10 @@ internal interface CaptureStrategy { companion object { private const val BREADCRUMB_START_OFFSET = 100L + // 5 minutes, otherwise relay will just drop it. Can prevent the case where the device + // time is wrong and the segment is too long. + private const val MAX_SEGMENT_DURATION = 1000L * 60 * 5 + fun createSegment( scopes: IScopes?, options: SentryOptions, @@ -76,7 +80,7 @@ internal interface CaptureStrategy { events: Deque ): ReplaySegment { val generatedVideo = cache?.createVideoOf( - duration, + minOf(duration, MAX_SEGMENT_DURATION), currentSegmentTimestamp.time, segmentId, height, @@ -179,7 +183,9 @@ internal interface CaptureStrategy { recordingPayload += rrwebEvent // fill in the urls array from navigation breadcrumbs - if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation" && + rrwebEvent.data?.getOrElse("to", { null }) is String + ) { urls.add(rrwebEvent.data!!["to"] as String) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 79afdb8f85..b704350125 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -336,6 +336,30 @@ class SessionCaptureStrategyTest { ) } + @Test + fun `does not throw when navigation destination is not a String`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb().apply { category = "navigation" }) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes).captureReplay( + check { + assertNull(it.urls?.firstOrNull()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertNull(breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + @Test fun `sets screen from scope as replay url`() { fixture.scope.screen = "MainActivity"