diff --git a/CHANGELOG.md b/CHANGELOG.md index 54dff9fef7..214125f0a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add configurable `IScopesStorageFactory` to `SentryOptions` for providing a custom `IScopesStorage`, e.g. when the default `ThreadLocal`-backed storage is incompatible with non-pinning thread models ([#5199](https://github.com/getsentry/sentry-java/pull/5199)) + ## 8.35.0 ### Fixes diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0b8171da63..ba9bee045f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1060,6 +1060,10 @@ public abstract interface class io/sentry/IScopesStorage { public abstract fun set (Lio/sentry/IScopes;)Lio/sentry/ISentryLifecycleToken; } +public abstract interface class io/sentry/IScopesStorageFactory { + public abstract fun create (Lio/sentry/SentryOptions;)Lio/sentry/IScopesStorage; +} + public abstract interface class io/sentry/ISentryClient { public abstract fun captureBatchedLogEvents (Lio/sentry/SentryLogEvents;)V public abstract fun captureBatchedMetricsEvents (Lio/sentry/SentryMetricsEvents;)V @@ -3629,6 +3633,7 @@ public class io/sentry/SentryOptions { public fun getReplayController ()Lio/sentry/ReplayController; public fun getSampleRate ()Ljava/lang/Double; public fun getScopeObservers ()Ljava/util/List; + public fun getScopesStorageFactory ()Lio/sentry/IScopesStorageFactory; public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion; public fun getSentryClientName ()Ljava/lang/String; public fun getSerializer ()Lio/sentry/ISerializer; @@ -3781,6 +3786,7 @@ public class io/sentry/SentryOptions { public fun setRelease (Ljava/lang/String;)V public fun setReplayController (Lio/sentry/ReplayController;)V public fun setSampleRate (Ljava/lang/Double;)V + public fun setScopesStorageFactory (Lio/sentry/IScopesStorageFactory;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setSendClientReports (Z)V public fun setSendDefaultPii (Z)V diff --git a/sentry/src/main/java/io/sentry/IScopesStorageFactory.java b/sentry/src/main/java/io/sentry/IScopesStorageFactory.java new file mode 100644 index 0000000000..129d97a935 --- /dev/null +++ b/sentry/src/main/java/io/sentry/IScopesStorageFactory.java @@ -0,0 +1,11 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** Factory for creating custom {@link IScopesStorage} implementations. */ +@ApiStatus.Experimental +public interface IScopesStorageFactory { + @NotNull + IScopesStorage create(@NotNull SentryOptions options); +} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 63caf829fc..fee19dc4d0 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -439,7 +439,10 @@ private static void initFatalLogger(final @NotNull SentryOptions options) { private static void initScopesStorage(SentryOptions options) { getScopesStorage().close(); - if (SentryOpenTelemetryMode.OFF == options.getOpenTelemetryMode()) { + if (options.getScopesStorageFactory() != null) { + scopesStorage = options.getScopesStorageFactory().create(options); + scopesStorage.init(); + } else if (SentryOpenTelemetryMode.OFF == options.getOpenTelemetryMode()) { scopesStorage = new DefaultScopesStorage(); } else { scopesStorage = ScopesStorageFactory.create(new LoadClass(), NoOpLogger.getInstance()); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index a831a11ea8..862bd708aa 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -557,6 +557,8 @@ public class SentryOptions { private @NotNull ISpanFactory spanFactory = NoOpSpanFactory.getInstance(); + private @Nullable IScopesStorageFactory scopesStorageFactory; + /** * Profiling traces rate. 101 hz means 101 traces in 1 second. Defaults to 101 to avoid possible * lockstep sampling. More on @@ -3557,6 +3559,27 @@ public void setSpanFactory(final @NotNull ISpanFactory spanFactory) { this.spanFactory = spanFactory; } + /** + * Returns the custom scopes storage factory, or null if auto-detection should be used. + * + * @return the custom scopes storage factory or null + */ + @ApiStatus.Experimental + public @Nullable IScopesStorageFactory getScopesStorageFactory() { + return scopesStorageFactory; + } + + /** + * Sets a custom factory for creating {@link IScopesStorage} implementations. When set, this + * factory takes precedence over the default auto-detection logic. + * + * @param scopesStorageFactory the custom factory, or null to use auto-detection + */ + @ApiStatus.Experimental + public void setScopesStorageFactory(final @Nullable IScopesStorageFactory scopesStorageFactory) { + this.scopesStorageFactory = scopesStorageFactory; + } + @ApiStatus.Experimental public @NotNull SentryOptions.Logs getLogs() { return logs; diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 960b2838e2..1fd8d9cc81 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -964,4 +964,27 @@ class SentryOptionsTest { options.logs.loggerBatchProcessorFactory = mock assertSame(mock, options.logs.loggerBatchProcessorFactory) } + + @Test + fun `scopesStorageFactory is null by default`() { + val options = SentryOptions() + assertNull(options.scopesStorageFactory) + } + + @Test + fun `scopesStorageFactory can be set and retrieved`() { + val options = SentryOptions() + val factory = IScopesStorageFactory { _ -> DefaultScopesStorage() } + options.scopesStorageFactory = factory + assertSame(factory, options.scopesStorageFactory) + } + + @Test + fun `scopesStorageFactory can be set to null`() { + val options = SentryOptions() + val factory = IScopesStorageFactory { _ -> DefaultScopesStorage() } + options.scopesStorageFactory = factory + options.scopesStorageFactory = null + assertNull(options.scopesStorageFactory) + } } diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index c3da8c1c12..25f45816b7 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1723,4 +1723,50 @@ class SentryTest { javaClass.injectForField("name", "io.sentry.SentryTest\$CustomAndroidOptions") } } + + @Test + fun `when scopesStorageFactory is set, it is used instead of default storage`() { + val customStorage = mock() + whenever(customStorage.set(anyOrNull())).thenReturn(mock()) + whenever(customStorage.get()).thenReturn(null) + + initForTest { + it.dsn = dsn + it.scopesStorageFactory = IScopesStorageFactory { _ -> customStorage } + } + + verify(customStorage).init() + verify(customStorage).set(any()) + } + + @Test + fun `when scopesStorageFactory is null, default auto-detection is used`() { + initForTest { + it.dsn = dsn + it.scopesStorageFactory = null + } + + // Should work normally with DefaultScopesStorage + val scopes = Sentry.getCurrentScopes() + assertFalse(scopes.isNoOp) + } + + @Test + fun `custom scopes storage from factory is functional`() { + val backingStorage = DefaultScopesStorage() + val factoryCalled = AtomicBoolean(false) + + initForTest { + it.dsn = dsn + it.scopesStorageFactory = IScopesStorageFactory { _ -> + factoryCalled.set(true) + backingStorage + } + } + + assertTrue(factoryCalled.get()) + + val scopes = Sentry.getCurrentScopes() + assertFalse(scopes.isNoOp) + } }