diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt index b2b2d6903..4151d4b9b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt @@ -29,6 +29,18 @@ enum class FeatureFlag( "sdk_identity_verification", FeatureActivationMode.IMMEDIATE ), + + /** + * Routes SDK observability (remote logging, crash capture/upload, ANR detection) through the + * multiplatform `logger` module instead of the legacy OpenTelemetry `otel` module. + * + * APP_STARTUP: the module choice is latched for the whole process and a remote change only + * takes effect on the next app start — switching observability pipelines mid-session is unsafe. + */ + SDK_CUSTOM_LOGGING( + "sdk_custom_logging", + FeatureActivationMode.APP_STARTUP + ), ; /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index c67e563b9..476835cc1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -170,6 +170,10 @@ internal class FeatureManager( // SDK_IDENTITY_VERIFICATION has no side effect: IdentityVerificationService // reads featureStates directly via isEnabled() at gate-check time. FeatureFlag.SDK_IDENTITY_VERIFICATION -> {} + // SDK_CUSTOM_LOGGING has no side effect here: the observability module choice is + // made during early init (before this manager exists) by reading the cached flag + // straight from prefs via LoggerModuleSwitch/OtelIdResolver. + FeatureFlag.SDK_CUSTOM_LOGGING -> {} } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt index 65f251437..fb3ed3a0b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt @@ -66,7 +66,7 @@ internal class OneSignalCrashUploaderWrapper( if (!OtelSdkSupport.isSupported) return OneSignalDispatchers.launchOnIO { try { - if (LoggerModuleSwitch.USE_LOGGER_MODULE) { + if (LoggerModuleSwitch.useLoggerModule(applicationService.appContext)) { loggerUploader.start() } else { otelUploader.start() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt index f02d9e8a1..e65a7e297 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt @@ -1,18 +1,28 @@ package com.onesignal.debug.internal.logging.logger +import android.content.Context +import com.onesignal.debug.internal.logging.otel.android.OtelIdResolver + /** - * Single switch that routes the SDK's observability (remote logging, crash capture, - * crash upload, ANR detection) through either: + * Routes the SDK's observability (remote logging, crash capture, crash upload, ANR detection) + * through either: * - the legacy OpenTelemetry-based `otel` module (default), or * - the new, multiplatform, OpenTelemetry-free `logger` module. * - * Flip [USE_LOGGER_MODULE] to `true` to exercise the `logger` module end-to-end on - * Android. Keeping it `false` by default leaves the existing otel path — and all of - * its tests — completely unchanged, so the swap is a one-line, low-risk toggle. + * The choice is driven by the [com.onesignal.core.internal.features.FeatureFlag.SDK_CUSTOM_LOGGING] + * remote feature flag, read from the cached config in SharedPreferences via [OtelIdResolver]. Because + * the value comes from the config the *previous* session persisted, enabling/disabling the flag takes + * effect on the next app start — never mid-session (the flag is [FeatureActivationMode.APP_STARTUP]). + * + * The flag is read directly from prefs (not through [com.onesignal.core.internal.features.FeatureManager]) + * because the module decision is made during early init, before service bootstrap. It is read fresh on + * each call — consumers ([com.onesignal.internal.LoggerLifecycleManager] and the crash uploader) all run + * early in the same init pass, before the first remote config fetch can change the persisted value, so + * they resolve to the same module for the session. * - * Once the `logger` module has been validated in production, the otel path (and this - * switch) can be removed along with the `:otel` module. + * Once the `logger` module has been validated in production, the otel path (and this switch) can be + * removed along with the `:otel` module. */ internal object LoggerModuleSwitch { - const val USE_LOGGER_MODULE = true + fun useLoggerModule(context: Context): Boolean = OtelIdResolver(context).resolveCustomLoggingEnabled() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt index b205fffd9..ef400f2ab 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt @@ -2,7 +2,9 @@ package com.onesignal.debug.internal.logging.otel.android import android.content.Context import com.onesignal.common.IDManager +import com.onesignal.common.toList import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.Logging @@ -231,6 +233,21 @@ internal class OtelIdResolver( if (remoteLoggingParams.has("logLevel")) remoteLoggingParams.getString("logLevel") else null ) + /** + * Resolves whether the multiplatform `logger` module should be used instead of `otel`, from + * the [FeatureFlag.SDK_CUSTOM_LOGGING] flag in the cached config's remote feature flags. + * + * Read directly from SharedPreferences (not via ConfigModelStore / FeatureManager) because + * this is consulted during early init, before those services are ready. The cached value is + * whatever the previous session persisted, so toggling the flag remotely takes effect on the + * next app start. Matching is case-insensitive to mirror FeatureManager's canonical keys. + */ + fun resolveCustomLoggingEnabled(): Boolean { + val flags = readConfigModel()?.optJSONArray(ConfigModel::sdkRemoteFeatureFlags.name)?.toList() ?: return false + val target = FeatureFlag.SDK_CUSTOM_LOGGING.key + return flags.any { (it as? String)?.equals(target, ignoreCase = true) == true } + } + /** * Resolves install ID from SharedPreferences. * Returns "InstallId-Null" if not found, "InstallId-NotFound" if there's an error. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt index ae053f305..435811043 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt @@ -28,8 +28,9 @@ import com.onesignal.logger.LoggerFactory * multiplatform, OpenTelemetry-free observability pipeline and reacts to remote config * changes the same way (using the shared [OtelConfig]/[OtelConfigEvaluator]). * - * Only active when [com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.USE_LOGGER_MODULE] - * is true; otherwise [OtelLifecycleManager] is used instead. + * Only active when [com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.useLoggerModule] + * resolves true (i.e. the SDK_CUSTOM_LOGGING feature flag is enabled in cached config); + * otherwise [OtelLifecycleManager] is used instead. */ @Suppress("TooManyFunctions") internal class LoggerLifecycleManager( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index e89307a28..306dc57b2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -241,7 +241,7 @@ internal class OneSignalImp : IOneSignal, // can be deferred until services have bootstrapped. val featureManagerProvider = { services.getService() } observabilityManager = - if (com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.USE_LOGGER_MODULE) { + if (com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.useLoggerModule(context)) { LoggerLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) } else { OtelLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt index e2417a3fa..e187f4cf5 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt @@ -16,4 +16,11 @@ class FeatureFlagTests : FunSpec({ FeatureFlag.SDK_IDENTITY_VERIFICATION.key shouldBe "sdk_identity_verification" FeatureFlag.SDK_IDENTITY_VERIFICATION.activationMode shouldBe FeatureActivationMode.IMMEDIATE } + + test("SDK_CUSTOM_LOGGING uses the expected remote key and APP_STARTUP activation") { + // APP_STARTUP so the observability module choice is latched per process and a remote + // change only takes effect on the next app start. + FeatureFlag.SDK_CUSTOM_LOGGING.key shouldBe "sdk_custom_logging" + FeatureFlag.SDK_CUSTOM_LOGGING.activationMode shouldBe FeatureActivationMode.APP_STARTUP + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt new file mode 100644 index 000000000..a03168eaf --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt @@ -0,0 +1,63 @@ +package com.onesignal.debug.internal.logging.logger + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.json.JSONArray +import org.json.JSONObject +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace + +/** + * End-to-end coverage for the otel-vs-logger routing switch. Asserts [LoggerModuleSwitch.useLoggerModule] + * reflects the SDK_CUSTOM_LOGGING flag as persisted in the cached config (the same prefs the previous + * session wrote), which is how the choice is made during early init before service bootstrap. + */ +@RobolectricTest +class LoggerModuleSwitchTest : FunSpec({ + + lateinit var appContext: Context + lateinit var sharedPreferences: SharedPreferences + + fun writeCachedFeatureFlags(vararg flags: String) { + val configModel = JSONObject().apply { + put(ConfigModel::sdkRemoteFeatureFlags.name, JSONArray().apply { flags.forEach { put(it) } }) + } + val configArray = JSONArray().apply { put(configModel) } + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + } + + beforeEach { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + sharedPreferences.edit().clear().commit() + } + + afterEach { + sharedPreferences.edit().clear().commit() + } + + test("useLoggerModule returns true when SDK_CUSTOM_LOGGING is cached") { + writeCachedFeatureFlags(FeatureFlag.SDK_CUSTOM_LOGGING.key) + + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe true + } + + test("useLoggerModule returns false when SDK_CUSTOM_LOGGING is not cached") { + writeCachedFeatureFlags("sdk_identity_verification") + + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe false + } + + test("useLoggerModule returns false when no config is cached") { + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe false + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt index 86be0f189..d43e92b69 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.common.IDManager.LOCAL_PREFIX import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.LogLevel @@ -1048,4 +1049,68 @@ class OtelIdResolverTest : FunSpec({ appId2 shouldBe "test-app-id" pushId2 shouldBe "test-push-id" } + + // ===== resolveCustomLoggingEnabled Tests ===== + // Reads the SDK_CUSTOM_LOGGING flag from the cached config's sdkRemoteFeatureFlags array. + // Drives the otel-vs-logger observability module choice (via LoggerModuleSwitch) on next launch. + + fun writeConfigWithFeatureFlags(vararg flags: String) { + val configModel = JSONObject().apply { + put(ConfigModel::sdkRemoteFeatureFlags.name, JSONArray().apply { flags.forEach { put(it) } }) + } + writeAndVerifyConfigData(JSONArray().apply { put(configModel) }) + } + + test("resolveCustomLoggingEnabled returns true when sdk_custom_logging flag is present") { + writeConfigWithFeatureFlags(FeatureFlag.SDK_CUSTOM_LOGGING.key) + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled matches the flag case-insensitively") { + writeConfigWithFeatureFlags("SDK_Custom_Logging") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled returns true when flag is present among other flags") { + writeConfigWithFeatureFlags("sdk_identity_verification", "sdk_custom_logging", "some_other_flag") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled returns false when flag is absent but others present") { + writeConfigWithFeatureFlags("sdk_identity_verification") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when the feature flags array is empty") { + writeConfigWithFeatureFlags() + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when sdkRemoteFeatureFlags field is missing") { + val configModel = JSONObject().apply { put(ConfigModel::appId.name, "test-app-id") } + writeAndVerifyConfigData(JSONArray().apply { put(configModel) }) + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when no config exists") { + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when context is null") { + OtelIdResolver(null).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled handles invalid JSON gracefully") { + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } })