Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ internal class OneSignalImp : IOneSignal,
// can be deferred until services have bootstrapped.
val featureManagerProvider = { services.getService<IFeatureManager>() }
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
Original file line number Diff line number Diff line change
@@ -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
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
})
Loading