diff --git a/CHANGELOG.md b/CHANGELOG.md index 44231ff4eed..197df9c450f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Added `Environment` to the `Scope` ([#5772](https://github.com/getsentry/sentry-java/pull/5772)) + ### Fixes - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index f175db90488..33c4ffbb402 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -481,6 +481,12 @@ private void setRelease( private void setEnvironment( final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { if (event.getEnvironment() == null) { + final String scopeEnvironment = + readFromDisk(options, PersistingScopeObserver.ENVIRONMENT_FILENAME, String.class); + if (scopeEnvironment != null) { + event.setEnvironment(scopeEnvironment); + return; + } event.setEnvironment( getLaunchOption( ENVIRONMENT_FILENAME, String.class, options.getEnvironment(), optionsSource)); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index d5b916d3b44..b2eab52c4d6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -401,6 +401,18 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("release", processed.environment) } + @Test + fun `if scope environment is persisted, it takes precedence over the options environment`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processor = fixture.getSut(tmpDir, populateOptionsCache = true) + fixture.persistScope(PersistingScopeObserver.ENVIRONMENT_FILENAME, "staging") + + val processed = processor.process(SentryEvent(), hint) + + assertEquals("staging", processed!!.environment) + } + @Test fun `if release is not persisted and app was not updated, uses release from options`() { val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d1aecf5ccfd..621c785ead1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -286,6 +286,7 @@ public final class io/sentry/CombinedScopeView : io/sentry/IScope { public fun getBreadcrumbs ()Ljava/util/Queue; public fun getClient ()Lio/sentry/ISentryClient; public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; @@ -321,6 +322,7 @@ public final class io/sentry/CombinedScopeView : io/sentry/IScope { public fun setContexts (Ljava/lang/String;Ljava/lang/String;)V public fun setContexts (Ljava/lang/String;Ljava/util/Collection;)V public fun setContexts (Ljava/lang/String;[Ljava/lang/Object;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setFingerprint (Ljava/util/List;)V public fun setLastEventId (Lio/sentry/protocol/SentryId;)V @@ -922,6 +924,7 @@ public abstract interface class io/sentry/IScope { public abstract fun getBreadcrumbs ()Ljava/util/Queue; public abstract fun getClient ()Lio/sentry/ISentryClient; public abstract fun getContexts ()Lio/sentry/protocol/Contexts; + public abstract fun getEnvironment ()Ljava/lang/String; public abstract fun getEventProcessors ()Ljava/util/List; public abstract fun getEventProcessorsWithOrder ()Ljava/util/List; public abstract fun getExtras ()Ljava/util/Map; @@ -957,6 +960,7 @@ public abstract interface class io/sentry/IScope { public abstract fun setContexts (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setContexts (Ljava/lang/String;Ljava/util/Collection;)V public abstract fun setContexts (Ljava/lang/String;[Ljava/lang/Object;)V + public abstract fun setEnvironment (Ljava/lang/String;)V public abstract fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setFingerprint (Ljava/util/List;)V public abstract fun setLastEventId (Lio/sentry/protocol/SentryId;)V @@ -984,6 +988,7 @@ public abstract interface class io/sentry/IScopeObserver { public abstract fun removeTag (Ljava/lang/String;)V public abstract fun setBreadcrumbs (Ljava/util/Collection;)V public abstract fun setContexts (Lio/sentry/protocol/Contexts;)V + public abstract fun setEnvironment (Ljava/lang/String;)V public abstract fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setExtras (Ljava/util/Map;)V public abstract fun setFingerprint (Ljava/util/Collection;)V @@ -1745,6 +1750,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun getBreadcrumbs ()Ljava/util/Queue; public fun getClient ()Lio/sentry/ISentryClient; public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; @@ -1781,6 +1787,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun setContexts (Ljava/lang/String;Ljava/lang/String;)V public fun setContexts (Ljava/lang/String;Ljava/util/Collection;)V public fun setContexts (Ljava/lang/String;[Ljava/lang/Object;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setFingerprint (Ljava/util/List;)V public fun setLastEventId (Lio/sentry/protocol/SentryId;)V @@ -2434,6 +2441,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun getBreadcrumbs ()Ljava/util/Queue; public fun getClient ()Lio/sentry/ISentryClient; public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; @@ -2469,6 +2477,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun setContexts (Ljava/lang/String;Ljava/lang/String;)V public fun setContexts (Ljava/lang/String;Ljava/util/Collection;)V public fun setContexts (Ljava/lang/String;[Ljava/lang/Object;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setFingerprint (Ljava/util/List;)V public fun setLastEventId (Lio/sentry/protocol/SentryId;)V @@ -2517,6 +2526,7 @@ public abstract class io/sentry/ScopeObserverAdapter : io/sentry/IScopeObserver public fun removeTag (Ljava/lang/String;)V public fun setBreadcrumbs (Ljava/util/Collection;)V public fun setContexts (Lio/sentry/protocol/Contexts;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V @@ -4880,6 +4890,7 @@ public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOption public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObserverAdapter { public static final field BREADCRUMBS_FILENAME Ljava/lang/String; public static final field CONTEXTS_FILENAME Ljava/lang/String; + public static final field ENVIRONMENT_FILENAME Ljava/lang/String; public static final field EXTRAS_FILENAME Ljava/lang/String; public static final field FINGERPRINT_FILENAME Ljava/lang/String; public static final field LEVEL_FILENAME Ljava/lang/String; @@ -4896,6 +4907,7 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public fun resetCache ()V public fun setBreadcrumbs (Ljava/util/Collection;)V public fun setContexts (Lio/sentry/protocol/Contexts;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V public fun setLevel (Lio/sentry/SentryLevel;)V diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index ea2d752d44b..942598cacc6 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -56,6 +56,24 @@ public void setLevel(@Nullable SentryLevel level) { getDefaultWriteScope().setLevel(level); } + @Override + public @Nullable String getEnvironment() { + final @Nullable String current = scope.getEnvironment(); + if (current != null) { + return current; + } + final @Nullable String isolation = isolationScope.getEnvironment(); + if (isolation != null) { + return isolation; + } + return globalScope.getEnvironment(); + } + + @Override + public void setEnvironment(@Nullable String environment) { + getDefaultWriteScope().setEnvironment(environment); + } + @Override public @Nullable String getTransactionName() { final @Nullable String current = scope.getTransactionName(); diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 5b6c38bbcfb..df034ddfc78 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -26,6 +26,22 @@ public interface IScope { */ void setLevel(final @Nullable SentryLevel level); + /** + * Returns the Scope's environment. + * + * @return the environment or {@code null} if not set on the scope + */ + @Nullable + String getEnvironment(); + + /** + * Sets the Scope's environment. Takes precedence over {@link SentryOptions#getEnvironment()} when + * applied to events. Mainly used by hybrid SDKs to sync their environment into this SDK. + * + * @param environment the environment + */ + void setEnvironment(final @Nullable String environment); + /** * Returns the Scope's transaction name. * diff --git a/sentry/src/main/java/io/sentry/IScopeObserver.java b/sentry/src/main/java/io/sentry/IScopeObserver.java index e1b9a785043..b6210c88701 100644 --- a/sentry/src/main/java/io/sentry/IScopeObserver.java +++ b/sentry/src/main/java/io/sentry/IScopeObserver.java @@ -38,6 +38,8 @@ public interface IScopeObserver { void setLevel(@Nullable SentryLevel level); + void setEnvironment(@Nullable String environment); + void setContexts(@NotNull Contexts contexts); void setTransaction(@Nullable String transaction); diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index 9d2f603c673..a452cb3f350 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -41,6 +41,14 @@ public static NoOpScope getInstance() { @Override public void setLevel(@Nullable SentryLevel level) {} + @Override + public @Nullable String getEnvironment() { + return null; + } + + @Override + public void setEnvironment(@Nullable String environment) {} + @Override public @Nullable String getTransactionName() { return null; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index f5c57f5ac5d..5be5c694833 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -53,6 +53,9 @@ public final class Scope implements IScope { /** Scope's screen */ private @Nullable String screen; + /** Scope's environment */ + private @Nullable String environment; + /** Scope's request */ private @Nullable Request request; @@ -137,6 +140,7 @@ private Scope(final @NotNull Scope scope) { final User userRef = scope.user; this.user = userRef != null ? new User(userRef) : null; this.screen = scope.screen; + this.environment = scope.environment; this.replayId = scope.replayId; final Request requestRef = scope.request; @@ -224,6 +228,32 @@ public void setLevel(final @Nullable SentryLevel level) { } } + /** + * Returns the Scope's environment. + * + * @return the environment or {@code null} if not set on the scope + */ + @Override + public @Nullable String getEnvironment() { + return environment; + } + + /** + * Sets the Scope's environment. Mainly used by hybrid SDKs (e.g. .NET, Unity) to sync their + * environment into this SDK. Takes precedence over {@link SentryOptions#getEnvironment()} when + * applied to events. + * + * @param environment the environment + */ + @Override + public void setEnvironment(final @Nullable String environment) { + this.environment = environment; + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setEnvironment(environment); + } + } + /** * Returns the Scope's transaction name. * diff --git a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java index 4f6a5ac842c..e80d8b6b208 100644 --- a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java @@ -46,6 +46,9 @@ public void setFingerprint(@NotNull Collection fingerprint) {} @Override public void setLevel(@Nullable SentryLevel level) {} + @Override + public void setEnvironment(@Nullable String environment) {} + @Override public void setContexts(@NotNull Contexts contexts) {} diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a0ff98a9af7..45f424072df 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1563,6 +1563,9 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri if (sentryBaseEvent.getUser() == null) { sentryBaseEvent.setUser(scope.getUser()); } + if (sentryBaseEvent.getEnvironment() == null) { + sentryBaseEvent.setEnvironment(scope.getEnvironment()); + } if (sentryBaseEvent.getTags() == null) { sentryBaseEvent.setTags(scope.getTags()); } else { diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 420d0d31e22..4dde96afcc2 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -46,6 +46,7 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { public static final String CONTEXTS_FILENAME = "contexts.json"; public static final String REQUEST_FILENAME = "request.json"; public static final String LEVEL_FILENAME = "level.json"; + public static final String ENVIRONMENT_FILENAME = "environment.json"; public static final String FINGERPRINT_FILENAME = "fingerprint.json"; public static final String TRANSACTION_FILENAME = "transaction.json"; public static final String TRACE_FILENAME = "trace.json"; @@ -189,6 +190,18 @@ public void setLevel(@Nullable SentryLevel level) { }); } + @Override + public void setEnvironment(@Nullable String environment) { + serializeToDisk( + () -> { + if (environment == null) { + delete(ENVIRONMENT_FILENAME); + } else { + store(environment, ENVIRONMENT_FILENAME); + } + }); + } + @Override public void setTransaction(@Nullable String transaction) { serializeToDisk( @@ -296,6 +309,7 @@ public void resetCache() { // the rest we can safely delete delete(USER_FILENAME); delete(LEVEL_FILENAME); + delete(ENVIRONMENT_FILENAME); delete(REQUEST_FILENAME); delete(FINGERPRINT_FILENAME); delete(CONTEXTS_FILENAME); diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 86aaf6f8f24..2f7d41ff9a9 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -781,6 +781,36 @@ class ScopeTest { verify(observer).setLevel(eq(WARNING)) } + @Test + fun `Scope set environment sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { addScopeObserver(observer) } + val scope = Scope(options) + + scope.environment = "staging" + verify(observer).setEnvironment(eq("staging")) + } + + @Test + fun `Scope set environment null sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { addScopeObserver(observer) } + val scope = Scope(options) + + scope.environment = null + verify(observer).setEnvironment(null) + } + + @Test + fun `Scope clone copies environment`() { + val scope = Scope(SentryOptions()) + scope.environment = "staging" + + val clone = scope.clone() + + assertEquals("staging", clone.environment) + } + @Test fun `Scope set transaction name sync scopes`() { val observer = mock() diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 5cfa4b6b619..34bad35900a 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -578,24 +578,35 @@ class SentryClientTest { } @Test - fun `when event has environment, value from options not applied`() { + fun `when scope has environment, it takes precedence over options`() { val event = SentryEvent() - val expected = "original" - fixture.sentryOptions.environment = "not to be applied" - event.environment = expected + val scope = createScope() + scope.environment = "from-scope" + fixture.sentryOptions.environment = "from-options" val sut = fixture.getSut() - sut.captureEvent(event) - assertEquals(expected, event.environment) + sut.captureEvent(event, scope) + assertEquals("from-scope", event.environment) } @Test - fun `when event doesn't have environment, value from options applied`() { + fun `when event has environment, scope environment not applied`() { val event = SentryEvent() - val expected = "original" - fixture.sentryOptions.environment = expected + event.environment = "from-event" + val scope = createScope() + scope.environment = "from-scope" val sut = fixture.getSut() - sut.captureEvent(event) - assertEquals(expected, event.environment) + sut.captureEvent(event, scope) + assertEquals("from-event", event.environment) + } + + @Test + fun `when scope has no environment, value from options applied`() { + val event = SentryEvent() + val scope = createScope() + fixture.sentryOptions.environment = "from-options" + val sut = fixture.getSut() + sut.captureEvent(event, scope) + assertEquals("from-options", event.environment) } @Test diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt index f682ac4c474..b98c5b8a319 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -9,6 +9,7 @@ import io.sentry.SpanContext import io.sentry.SpanId import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME +import io.sentry.cache.PersistingScopeObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME @@ -196,6 +197,15 @@ class PersistingScopeObserverTest( DeletedEntityProvider { null }, ) + private fun environment(): Array = + arrayOf( + "staging", + StoreScopeValue { environment, _ -> setEnvironment(environment) }, + ENVIRONMENT_FILENAME, + DeleteScopeValue { setEnvironment(null) }, + DeletedEntityProvider { null }, + ) + private fun transaction(): Array = arrayOf( "MainActivity", @@ -296,6 +306,7 @@ class PersistingScopeObserverTest( request(), fingerprint(), level(), + environment(), transaction(), trace(), contexts(),