From f7913385ec93651c33acaf505cb935e6ac426651 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 24 Jul 2026 00:41:16 +0530 Subject: [PATCH] feat(logger): [SDK-4835] gate otel-vs-logger switch on SDK_CUSTOM_LOGGING flag Replace the hardcoded LoggerModuleSwitch.USE_LOGGER_MODULE boolean with a remote-config-driven decision. Add the SDK_CUSTOM_LOGGING feature flag (APP_STARTUP activation) and resolve it during early init directly from the cached config in SharedPreferences via OtelIdResolver.resolveCustomLoggingEnabled(), so toggling the flag takes effect on the next app start rather than mid-session. Add unit tests covering resolveCustomLoggingEnabled() (present/absent, case-insensitive, empty/missing flags, no config, null context, invalid JSON), the LoggerModuleSwitch.useLoggerModule() end-to-end read, and the SDK_CUSTOM_LOGGING enum key + activation mode. Co-authored-by: Cursor --- .../core/internal/features/FeatureFlag.kt | 12 ++++ .../core/internal/features/FeatureManager.kt | 4 ++ .../crash/OneSignalCrashUploaderWrapper.kt | 2 +- .../logging/logger/LoggerModuleSwitch.kt | 26 +++++--- .../logging/otel/android/OtelIdResolver.kt | 17 +++++ .../internal/LoggerLifecycleManager.kt | 5 +- .../com/onesignal/internal/OneSignalImp.kt | 2 +- .../internal/features/FeatureFlagTests.kt | 7 ++ .../logging/logger/LoggerModuleSwitchTest.kt | 63 ++++++++++++++++++ .../otel/android/OtelIdResolverTest.kt | 65 +++++++++++++++++++ 10 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt 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 + } })