diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md
index 48f037b0a5..f19d502359 100644
--- a/MIGRATION_GUIDE.md
+++ b/MIGRATION_GUIDE.md
@@ -50,6 +50,35 @@ The above statement will bring in the entire OneSignalSDK and is the desired sta
- `com.onesignal:location`: Include to bring in location-based functionality.
+## OpenTelemetry Dependency Removal
+
+As of the release that removes the `:otel` module, the SDK no longer depends on OpenTelemetry. The `com.onesignal:otel` artifact is no longer published, and with it goes the entire `io.opentelemetry` dependency tree (`opentelemetry-api`, `-sdk`, `-exporter-otlp`, `-semconv`, and `opentelemetry-disk-buffering`). SDK diagnostics are now handled by the multiplatform `logger` module bundled inside `com.onesignal:core`, which has no third-party telemetry dependencies.
+
+No public, supported API changed. The removal does delete internal API surface in `com.onesignal.debug.internal.logging` — most visibly `Logging.setOtelTelemetry`. That method took a parameter type (`IOtelOpenTelemetryRemote`) that only existed inside the `com.onesignal:otel` artifact, so no application could have compiled against it without depending on that artifact directly. If you did, remove the reference and rebuild.
+
+For most integrations no action is required, but note the following:
+
+- **If you declared `com.onesignal:otel` directly**, remove it. The artifact is no longer published.
+- **If you added ProGuard/R8 rules for OneSignal's OpenTelemetry usage**, you can remove them. Rules such as the following are no longer needed, because those classes are never on the classpath via OneSignal:
+
+```pro
+-dontwarn com.fasterxml.jackson.core.**
+-dontwarn com.google.auto.value.**
+-dontwarn io.opentelemetry.api.incubator.**
+-dontwarn io.opentelemetry.api.internal.**
+```
+
+- **If your app uses OpenTelemetry itself**, you no longer need to reconcile its version with OneSignal's. Whatever version you depend on is now the only one in your build, which removes a class of R8 "Missing class" failures caused by version skew between the two.
+- **If you were excluding OpenTelemetry from the OneSignal dependency**, that exclusion is now a no-op and can be deleted.
+
+One upgrade-time note about crash reports still buffered on disk when the upgrade happens. Which ones survive depends on the format they were written in:
+
+- Reports written by the OpenTelemetry path use its disk-buffering format, which the new implementation cannot read. These are deleted unread on a subsequent launch, so a crash captured shortly before the upgrade may never arrive.
+- Reports written by the newer logger path are already in the format the upgraded SDK uses, and are uploaded normally.
+
+Reports captured from the upgraded version onward are unaffected.
+
+
## Code Modularization
The OneSignal SDK has been updated to be more modular in nature. The SDK has been split into namespaces and functionality previously in the static `OneSignal` class has been moved to the appropriate namespace. Some namespaces are only available if you include the associated module in your build (for simplicity, including module `com.onesignal:OneSignal` will automatically bring in all modules). The namespaces, their containing modules, and how to access them in code are as follows:
diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK
index 87e87fd264..64ce06b3ec 160000
--- a/OneSignal-KMP-SDK
+++ b/OneSignal-KMP-SDK
@@ -1 +1 @@
-Subproject commit 87e87fd264284448541bfc34b7f1b0673bbe2dbb
+Subproject commit 64ce06b3ec233cfcbebb6e0acbd3fe23b78c6a4c
diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle
index ff3fa2ec59..6a29fc4016 100644
--- a/OneSignalSDK/build.gradle
+++ b/OneSignalSDK/build.gradle
@@ -27,10 +27,6 @@ buildscript {
ktlintVersion = '0.50.0' // Used by Spotless for Kotlin formatting (compatible with Kotlin 1.7.10)
spotlessVersion = '6.25.0'
tdunningJsonForTest = '1.0' // DO NOT upgrade for tests, using an old version so it matches AOSP
- // OpenTelemetry versions
- opentelemetryBomVersion = '1.55.0'
- opentelemetrySemconvVersion = '1.37.0'
- opentelemetryDiskBufferingVersion = '1.51.0-alpha'
sharedRepos = {
google()
diff --git a/OneSignalSDK/coverage/jacoco.gradle b/OneSignalSDK/coverage/jacoco.gradle
index c7b551de28..2d80344c04 100644
--- a/OneSignalSDK/coverage/jacoco.gradle
+++ b/OneSignalSDK/coverage/jacoco.gradle
@@ -16,6 +16,19 @@ subprojects {
testCoverageEnabled = true
}
}
+
+ // Robolectric loads classes through its own instrumenting classloader, which
+ // strips the source-location metadata JaCoCo uses to attribute execution. Without
+ // this, every class exercised only by a @RobolectricTest reports 0% coverage even
+ // when it is thoroughly tested.
+ testOptions {
+ unitTests.all {
+ jacoco {
+ includeNoLocationClasses = true
+ excludes = ['jdk.internal.*']
+ }
+ }
+ }
}
def coverageExcludes = [
diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle
index d465a354c5..a3feb38d7d 100644
--- a/OneSignalSDK/onesignal/core/build.gradle
+++ b/OneSignalSDK/onesignal/core/build.gradle
@@ -93,9 +93,7 @@ dependencies {
}
}
- // Otel module dependency
- implementation(project(':OneSignal:otel'))
- // Shared KMP module (OpenTelemetry-free logger) — the eventual replacement for :otel.
+ // Shared KMP module — backs the SDK's observability pipeline (remote logging, crash, ANR).
implementation(project(':OneSignal:kmp'))
testImplementation(project(':OneSignal:testhelpers'))
diff --git a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml
index a50209b634..cd5f7c0802 100644
--- a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml
+++ b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml
@@ -1,9 +1,9 @@
-
-
-
+
+
+
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt
index e1b651f8b0..6089ab6eae 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt
@@ -221,7 +221,7 @@ object OneSignalDispatchers {
*
* Background:
* The lazy `by lazy` properties below construct `ThreadPoolExecutor` instances and wrap them
- * in `asCoroutineDispatcher() + SupervisorJob() + CoroutineScope(...)`. Production OTel
+ * in `asCoroutineDispatcher() + SupervisorJob() + CoroutineScope(...)`. Production remote-logging
* shows that when the **first** caller of [launchOnIO] / [launchOnSerialIO] is on the main
* thread (Activity-lifecycle handler, `JobService.onStartJob`, etc.), the construction cost
* — which includes a `kotlinx.coroutines.BuildersKt.launch` that hits
@@ -323,7 +323,7 @@ object OneSignalDispatchers {
* thread parked in a non-cancellable JVM wait (e.g. a `CountDownLatch.await()` that never gets
* released because the test asserted/failed first); a plain coroutine cancellation cannot free
* such a thread, so it permanently starves the small pool. Later specs that use the real pool
- * (e.g. HttpClientTests' `launchOnIO {…}.join()`, OperationRepo, OtelIdResolver) then see
+ * (e.g. HttpClientTests' `launchOnIO {…}.join()`, OperationRepo, LoggerIdResolver) then see
* `launchOnIO` rejected/cancelled and observe null results.
*
* This atomically swaps in a fresh [Pools] generation and tears the old one down —
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt
index b7533961de..9266c5c90a 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/OneSignalService.kt
@@ -1,6 +1,6 @@
package com.onesignal.core.internal.http
-/** Central API base URL used by all SDK HTTP traffic, including Otel log export. */
+/** Central API base URL used by all SDK HTTP traffic, including remote log export. */
object OneSignalService {
// const val ONESIGNAL_API_BASE_URL = "https://api.staging.onesignal.com/"
const val ONESIGNAL_API_BASE_URL = "https://api.onesignal.com/"
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrCheckEvaluator.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrCheckEvaluator.kt
index 16afbd42fd..a0e90e4b7e 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrCheckEvaluator.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/AnrCheckEvaluator.kt
@@ -1,14 +1,13 @@
package com.onesignal.debug.internal.crash
+import com.onesignal.logger.CrashData
import java.util.concurrent.atomic.AtomicLong
/**
- * Pure, Android-free decision core for the ANR watchdog.
- *
- * All timing/classification/deduplication state lives here so it can be exercised deterministically
- * on the JVM (with an injected clock) without a `Handler`, `Looper`, or real background thread. The
- * Android shell ([OtelAnrDetector]) owns the thread, the real sleep, and the reporting side effects,
- * and delegates every per-iteration decision to [evaluate].
+ * Pure, Android-free decision core for the ANR watchdog. All timing, classification and dedup
+ * state lives here; the Android shell
+ * ([com.onesignal.debug.internal.logging.logger.android.AndroidLogAnrDetector]) owns the thread,
+ * the real sleep and the reporting side effects, and delegates each iteration to [evaluate].
*
* Foreground and background blocks keep independent dedup timestamps: a stream of backgrounded
* warnings must never suppress a genuine foreground ANR (and vice versa).
@@ -21,7 +20,7 @@ internal class AnrCheckEvaluator(
private val dedupWindowMs: Long,
private val now: () -> Long,
) {
- // Monotonic timestamps (from `now`); see OtelAnrDetector for why the clock must be monotonic.
+ // Monotonic timestamps (from `now`); see AndroidLogAnrDetector for why the clock must be monotonic.
private val lastResponseTime = AtomicLong(now())
private val lastForegroundReportTime = AtomicLong(NEVER_REPORTED)
private val lastBackgroundReportTime = AtomicLong(NEVER_REPORTED)
@@ -81,9 +80,8 @@ internal class AnrCheckEvaluator(
val nowMs = now()
val lastReport = lastReportHolder.get()
- // Skip only if we actually reported this class of block recently. NEVER_REPORTED means we have
- // not reported yet (or the main thread recovered), so we must not dedup — important shortly
- // after boot when the monotonic clock is still small and `now - 0` would look "recent".
+ // NEVER_REPORTED must not dedup: shortly after boot the monotonic clock is still small and
+ // `now - 0` would look "recent", suppressing the very first block.
if (lastReport != NEVER_REPORTED && nowMs - lastReport <= dedupWindowMs) {
return AnrCheckResult.Deduped(durationMs = durationMs, sinceLastReportMs = nowMs - lastReport, inForeground = inForeground)
}
@@ -125,21 +123,11 @@ internal sealed interface AnrCheckResult {
data class BackgroundWarning(val durationMs: Long) : AnrCheckResult
}
-/**
- * How a watchdog check is interpreted. Kept separate from side effects so the decision is a pure,
- * deterministically testable function of the measured timings and app state.
- */
+/** How a watchdog check is interpreted; see [AnrCheckResult] for what each case means. */
internal enum class BlockClassification {
- /** Main thread responded within the applicable threshold. */
RESPONSIVE,
-
- /** The watchdog thread's own sleep overran — the process was frozen, not the main thread. */
FROZEN_PROCESS,
-
- /** Foreground block beyond the ANR threshold: a real, user-visible ANR. */
FOREGROUND_ANR,
-
- /** Background block beyond the background threshold: not an ANR, recorded as a warning. */
BACKGROUND_WARNING,
}
@@ -172,11 +160,77 @@ internal fun classifyBlock(
/**
* Compact fingerprint for a captured main-thread stack: the top frame plus the first OneSignal frame.
- * Kept as a queryable summary so background blocks can be grouped/triaged without parsing the full
- * stack. Pure (operates only on the array) so it is covered by plain JVM tests.
+ * Kept as a queryable summary so background blocks can be grouped and triaged without parsing the
+ * full stack.
*/
internal fun buildBlockFingerprint(stackTrace: Array): String {
val topFrame = stackTrace.firstOrNull()?.toString() ?: "unknown"
val oneSignalFrame = stackTrace.firstOrNull { it.className.startsWith("com.onesignal") }?.toString() ?: "none"
return "top=$topFrame|onesignal=$oneSignalFrame"
}
+
+/** Exception type for a foreground, user-visible ANR. Dashboards key off this exact value. */
+internal const val ANR_EXCEPTION_TYPE = "ApplicationNotRespondingException"
+
+/** Exception type for a backgrounded main-thread block, which is never an ANR. */
+internal const val BACKGROUND_BLOCK_EXCEPTION_TYPE = "BackgroundMainThreadBlockException"
+
+/**
+ * Renders a live thread's stack in the canonical JVM layout that [Throwable.stackTraceToString]
+ * emits: a `type: message` header followed by `\tat `-prefixed frames. ANR records are captured
+ * from a running thread rather than a thrown exception, so there is no throwable to serialize, but
+ * the output must stay byte-identical to the crash path's or consumers that parse
+ * `exception.stacktrace` stop matching ANRs only.
+ */
+internal fun formatJvmStacktrace(
+ exceptionType: String,
+ exceptionMessage: String,
+ stackTrace: Array,
+): String {
+ // Matches printStackTrace, which terminates every line with the platform separator.
+ val lineSeparator = System.lineSeparator()
+ return buildString {
+ append(exceptionType)
+ if (exceptionMessage.isNotEmpty()) {
+ append(": ").append(exceptionMessage)
+ }
+ append(lineSeparator)
+ for (frame in stackTrace) {
+ append("\tat ").append(frame).append(lineSeparator)
+ }
+ }
+}
+
+/** Builds the fatal ANR record for a foreground block of [unresponsiveDurationMs]. */
+internal fun buildAnrCrashData(
+ threadName: String,
+ stackTrace: Array,
+ unresponsiveDurationMs: Long,
+): CrashData {
+ val message = "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms"
+ return CrashData(
+ threadName = threadName,
+ exceptionType = ANR_EXCEPTION_TYPE,
+ exceptionMessage = message,
+ stacktrace = formatJvmStacktrace(ANR_EXCEPTION_TYPE, message, stackTrace),
+ )
+}
+
+/**
+ * Builds the non-fatal record for a backgrounded main-thread block, with a
+ * [buildBlockFingerprint] summary embedded in the message.
+ */
+internal fun buildBackgroundBlockCrashData(
+ threadName: String,
+ stackTrace: Array,
+ unresponsiveDurationMs: Long,
+): CrashData {
+ val message =
+ "Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}"
+ return CrashData(
+ threadName = threadName,
+ exceptionType = BACKGROUND_BLOCK_EXCEPTION_TYPE,
+ exceptionMessage = message,
+ stacktrace = formatJvmStacktrace(BACKGROUND_BLOCK_EXCEPTION_TYPE, message, stackTrace),
+ )
+}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/ObservabilitySdkSupport.kt
similarity index 80%
rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt
rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/ObservabilitySdkSupport.kt
index 47fc0034de..ae82ca48bb 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelSdkSupport.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/ObservabilitySdkSupport.kt
@@ -3,14 +3,14 @@ package com.onesignal.debug.internal.crash
import android.os.Build
/**
- * Centralizes the SDK version requirement for Otel-based features
+ * Centralizes the SDK version requirement for observability features
* (crash reporting, ANR detection, remote log shipping).
*
* [isSupported] is writable internally so that unit tests can override
* the device-level gate without Robolectric @Config gymnastics.
*/
-internal object OtelSdkSupport {
- /** Otel libraries require Android O (API 26) or above. */
+internal object ObservabilitySdkSupport {
+ /** The shared logger module requires Android O (API 26) or above. */
const val MIN_SDK_VERSION = Build.VERSION_CODES.O // 26
/**
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt
deleted file mode 100644
index 16393c872b..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.onesignal.debug.internal.crash
-
-import android.content.Context
-import com.onesignal.core.internal.features.IFeatureManager
-import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider
-import com.onesignal.otel.IOtelCrashHandler
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.OtelFactory
-
-/**
- * Factory for creating Otel-based crash handlers.
- * Callers must verify [OtelSdkSupport.isSupported] before calling [createCrashHandler].
- *
- * Uses minimal dependencies - Context, logger, and a feature manager supplier (so per-event
- * OTel attrs can include `ossdk.feature_flags`). Platform provider uses OtelIdResolver
- * internally which reads from SharedPreferences.
- */
-internal object OneSignalCrashHandlerFactory {
- /**
- * Creates an Otel crash handler. Must only be called on supported devices
- * (SDK >= [OtelSdkSupport.MIN_SDK_VERSION]).
- *
- * @param context Android context for creating platform provider
- * @param logger Logger instance (can be shared with other components)
- * @param featureManagerProvider Lazy supplier for the feature manager. Resolved on each
- * `enabledFeatureFlags` read so the OTel pipeline can come up before the IoC container
- * has finished bootstrapping.
- * @throws IllegalArgumentException if called on an unsupported SDK
- */
- fun createCrashHandler(
- context: Context,
- logger: IOtelLogger,
- featureManagerProvider: () -> IFeatureManager,
- ): IOtelCrashHandler {
- require(OtelSdkSupport.isSupported) {
- "createCrashHandler called on unsupported SDK (< ${OtelSdkSupport.MIN_SDK_VERSION})"
- }
-
- Logging.info("OneSignal: Creating Otel crash handler (SDK >= ${OtelSdkSupport.MIN_SDK_VERSION})")
- val platformProvider = createAndroidOtelPlatformProvider(context, featureManagerProvider)
- return OtelFactory.createCrashHandler(platformProvider, logger)
- }
-}
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 28b1820189..4e1af0636d 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
@@ -5,59 +5,26 @@ import com.onesignal.core.internal.application.IApplicationService
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.startup.IStartableService
import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch
import com.onesignal.debug.internal.logging.logger.android.AndroidLogger
-import com.onesignal.debug.internal.logging.logger.android.CrashDirEntry
import com.onesignal.debug.internal.logging.logger.android.FileLogStore
import com.onesignal.debug.internal.logging.logger.android.OneSignalLogHttpSender
import com.onesignal.debug.internal.logging.logger.android.createAndroidLoggerPlatformProvider
-import com.onesignal.debug.internal.logging.logger.android.formatCrashDirInventory
-import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger
-import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider
+import com.onesignal.debug.internal.logging.logger.android.getCrashStoragePath
import com.onesignal.logger.LoggerFactory
-import com.onesignal.otel.OtelFactory
-import com.onesignal.otel.crash.OtelCrashUploader
+import com.onesignal.logger.crash.CrashDirEntry
+import com.onesignal.logger.crash.CrashRetention
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
/**
- * Android-specific wrapper for OtelCrashUploader that implements IStartableService.
- *
- * This is a thin adapter layer that:
- * 1. Takes Android-specific services as dependencies
- * 2. Creates platform-agnostic implementations (IOtelPlatformProvider, IOtelLogger)
- * 3. Wraps the platform-agnostic OtelCrashUploader for Android service architecture
- *
- * The OtelCrashUploader itself is fully platform-agnostic and can be used directly
- * in KMP projects by providing platform-specific implementations of:
- * - IOtelPlatformProvider (inject all platform values)
- * - IOtelLogger (platform logging interface)
- *
- * Example KMP usage:
- * ```kotlin
- * val platformProvider = MyPlatformProvider(...) // iOS/Android specific
- * val logger = MyPlatformLogger() // iOS/Android specific
- * val uploader = OtelFactory.createCrashUploader(platformProvider, logger)
- * // Use uploader.start() in a coroutine
- * ```
+ * Adapts the shared, platform-agnostic `LogCrashUploader` to [IStartableService], supplying it
+ * with Android implementations of `ILoggerPlatformProvider` and `ILogger`.
*/
internal class OneSignalCrashUploaderWrapper(
private val applicationService: IApplicationService,
private val featureManager: IFeatureManager,
) : IStartableService {
- private val otelUploader: OtelCrashUploader by lazy {
- // Create Android-specific platform provider (injects Android values + a FeatureManager
- // supplier that resolves to the constructor-injected manager on each access).
- val platformProvider = createAndroidOtelPlatformProvider(
- applicationService.appContext,
- ) { featureManager }
- // Create Android-specific logger (delegates to Android Logging)
- val logger = AndroidOtelLogger()
- // Create platform-agnostic uploader using factory
- OtelFactory.createCrashUploader(platformProvider, logger)
- }
-
- private val loggerUploader by lazy {
+ private val uploader by lazy {
val platformProvider = createAndroidLoggerPlatformProvider(applicationService.appContext) { featureManager }
val logger = AndroidLogger()
val httpSender = OneSignalLogHttpSender(logger) { platformProvider.isExporterLoggingEnabled }
@@ -68,22 +35,14 @@ internal class OneSignalCrashUploaderWrapper(
@Suppress("TooGenericExceptionCaught")
override fun start() {
- if (!OtelSdkSupport.isSupported) return
+ if (!ObservabilitySdkSupport.isSupported) return
OneSignalDispatchers.launchOnIO {
try {
- val useLogger = LoggerModuleSwitch.useLoggerModule(applicationService.appContext)
- val module = if (useLogger) "logger" else "otel"
- Logging.info("OneSignal: Crash uploader selecting module=$module (SDK_CUSTOM_LOGGING=$useLogger)")
logCrashDirInventory("before-upload")
- if (useLogger) {
- // Shared LogCrashUploader.start() is suspend and finishes the owned-record
- // upload pass plus the finally-purge before returning, so the after-cleanup
- // inventory below is not racing a background purge.
- loggerUploader.start()
- logCrashDirInventory("after-cleanup")
- } else {
- otelUploader.start()
- }
+ // start() completes the upload pass and the finally-purge before returning, so
+ // the after-cleanup inventory below is not racing a background purge.
+ uploader.start()
+ logCrashDirInventory("after-cleanup")
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
@@ -95,13 +54,15 @@ internal class OneSignalCrashUploaderWrapper(
}
}
- /** Resolves the shared crash directory both modules write to. */
- private fun crashStoragePath(): String =
- createAndroidOtelPlatformProvider(applicationService.appContext) { featureManager }
- .crashStoragePath
+ /**
+ * Resolves the crash directory via the pure path helper rather than a provider: the value is
+ * derived from the context alone, and building a provider costs a `PackageManager` round-trip
+ * and re-emits its "Crash logs stored at" line.
+ */
+ private fun crashStoragePath(): String = getCrashStoragePath(applicationService.appContext)
/**
- * Logs a snapshot of the shared crash dir (counts of owned `.otlp` vs foreign/legacy
+ * Logs a snapshot of the crash dir (counts of owned `.otlp` vs foreign/legacy
* entries, plus a bounded per-file sample) so leftover formats are visible and
* cleanup is verifiable from logs alone.
*/
@@ -119,12 +80,13 @@ internal class OneSignalCrashUploaderWrapper(
)
}.orEmpty()
Logging.info(
- formatCrashDirInventory(
+ CrashRetention.formatInventory(
label = label,
path = path,
entries = entries,
nowMs = now,
maxSample = MAX_INVENTORY_SAMPLE,
+ policy = CrashRetention.defaultPolicy,
),
)
} catch (t: Throwable) {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt
deleted file mode 100644
index a5733a9095..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt
+++ /dev/null
@@ -1,358 +0,0 @@
-package com.onesignal.debug.internal.crash
-
-import android.os.Handler
-import android.os.Looper
-import android.os.SystemClock
-import com.onesignal.otel.IOtelCrashReporter
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.IOtelOpenTelemetryCrash
-import com.onesignal.otel.OtelFactory
-import com.onesignal.otel.crash.IOtelAnrDetector
-import com.onesignal.otel.crash.isOneSignalAtFault
-import kotlinx.coroutines.runBlocking
-import java.util.concurrent.atomic.AtomicBoolean
-
-/**
- * Android-specific implementation of ANR detection.
- *
- * Uses a watchdog pattern to monitor the main thread:
- * - Posts a message to the main thread every check interval
- * - If the main thread doesn't respond within the threshold, classifies and records the block
- * - Captures the main thread's stack trace when a block is detected
- *
- * Detection is app-state aware, because a blocked main thread does not mean the same thing in
- * the foreground as in the background:
- * - Foreground block > [anrThresholdMs]: a real, user-visible ANR — reported as a crash-class ANR.
- * - Background block > [backgroundThresholdMs]: not an ANR (Android raises no ANR for a
- * backgrounded app) — recorded under a distinct exception type so it stays out of the ANR metric
- * while remaining retained and queryable for real background regressions.
- * - If the watchdog thread's own sleep overran far beyond [checkIntervalMs], the whole process was
- * descheduled (Doze / cached-process freeze) rather than the main thread being stuck, so the
- * measured "block" is a freeze artifact and is suppressed.
- *
- * Every timing/classification/dedup decision is delegated to [AnrCheckEvaluator] (pure, JVM-tested),
- * and all Android touch points go through the injectable [AnrWatchdogPlatform] seam so the watchdog's
- * behavior can be exercised deterministically off-device.
- */
-internal class OtelAnrDetector(
- openTelemetryCrash: IOtelOpenTelemetryCrash,
- private val logger: IOtelLogger,
- private val anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
- private val checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS,
- backgroundThresholdMs: Long = AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS,
- private val isAppInForeground: () -> Boolean = { true },
- // Android touch points (main-thread Handler, stack capture) plus the monotonic clock, injectable
- // so the whole watchdog runs deterministically off-device.
- private val platform: AnrWatchdogPlatform = AndroidAnrWatchdogPlatform(),
-) : IOtelAnrDetector {
- private val crashReporter: IOtelCrashReporter = OtelFactory.createCrashReporter(openTelemetryCrash, logger)
- private val isMonitoring = AtomicBoolean(false)
-
- private val evaluator = AnrCheckEvaluator(
- anrThresholdMs = anrThresholdMs,
- checkIntervalMs = checkIntervalMs,
- backgroundThresholdMs = backgroundThresholdMs,
- frozenSlackMs = FROZEN_PROCESS_SLACK_MS,
- dedupWindowMs = MIN_TIME_BETWEEN_ANR_REPORTS_MS,
- now = { platform.now() },
- )
-
- private var watchdogThread: Thread? = null
- private var watchdogRunnable: Runnable? = null
- private var mainThreadRunnable: Runnable? = null
-
- companion object {
- private const val TAG = "OtelAnrDetector"
-
- // Minimum time between reports (to avoid duplicate reports for the same ongoing block)
- private const val MIN_TIME_BETWEEN_ANR_REPORTS_MS = 30_000L // 30 seconds
-
- // If the watchdog thread's own sleep overruns the check interval by more than this, the
- // process itself was frozen/descheduled rather than the main thread being blocked, so the
- // measurement is meaningless. Generous enough to tolerate GC pauses and CPU contention.
- private const val FROZEN_PROCESS_SLACK_MS = 2_000L
- }
-
- override fun start() {
- if (isMonitoring.getAndSet(true)) {
- logger.warn("$TAG: Already monitoring for ANRs, skipping start")
- return
- }
-
- logger.info("$TAG: Starting ANR detection (threshold: ${anrThresholdMs}ms, check interval: ${checkIntervalMs}ms)")
-
- // Reset the baseline so a gap between construction and start() can't be read as a block.
- evaluator.resetBaseline()
- setupRunnables()
- startWatchdogThread()
-
- logger.info("$TAG: ✅ ANR detection started successfully")
- }
-
- @Suppress("TooGenericExceptionCaught")
- private fun setupRunnables() {
- // Runnable that runs on the main thread to indicate it's responsive
- mainThreadRunnable = Runnable {
- recordHeartbeat()
- }
-
- // Runnable that runs on the watchdog thread to check for ANRs
- watchdogRunnable = Runnable {
- while (isMonitoring.get()) {
- try {
- checkForAnr()
- } catch (e: InterruptedException) {
- // Thread was interrupted, stop monitoring
- logger.info("$TAG: Watchdog thread interrupted, stopping ANR detection")
- break
- } catch (t: Throwable) {
- logger.error("$TAG: Error in ANR watchdog: ${t.message} - ${t.javaClass.simpleName}")
- }
- }
- }
- }
-
- internal fun checkForAnr() {
- val runnable = mainThreadRunnable ?: return
- platform.postToMainThread(runnable)
-
- // Time the sleep itself: if our own thread oversleeps, the process was frozen, not blocked.
- val sleepStart = platform.now()
- Thread.sleep(checkIntervalMs)
- evaluateCheck(actualSleepMs = platform.now() - sleepStart)
- }
-
- /** Records that the main thread ran our heartbeat runnable. */
- internal fun recordHeartbeat() {
- evaluator.recordHeartbeat()
- }
-
- /**
- * Runs one watchdog iteration's decision (via [AnrCheckEvaluator]) and performs the side effect.
- * Split from [checkForAnr] (which owns the real sleep) so the wiring can be exercised
- * deterministically with an injected clock and platform.
- */
- internal fun evaluateCheck(actualSleepMs: Long) {
- val inForeground = resolveForeground()
- when (val result = evaluator.evaluate(actualSleepMs = actualSleepMs, inForeground = inForeground)) {
- is AnrCheckResult.Responsive -> Unit
- is AnrCheckResult.FrozenProcess ->
- logger.debug(
- "$TAG: Skipping check — watchdog overslept ${result.actualSleepMs}ms " +
- "(expected ${result.expectedSleepMs}ms); process was frozen, not blocked",
- )
- is AnrCheckResult.Deduped ->
- logger.debug(
- "$TAG: Block still ongoing (${result.durationMs}ms), already reported recently (${result.sinceLastReportMs}ms ago)",
- )
- is AnrCheckResult.ForegroundAnr -> {
- logger.info("$TAG: ⚠️ ANR detected! Main thread unresponsive for ${result.durationMs}ms (foreground)")
- reportAnr(result.durationMs)
- }
- is AnrCheckResult.BackgroundWarning -> {
- logger.info("$TAG: Main thread blocked for ${result.durationMs}ms while backgrounded — recording warning, not ANR")
- reportBackgroundBlock(result.durationMs)
- }
- }
- }
-
- @Suppress("TooGenericExceptionCaught")
- private fun resolveForeground(): Boolean =
- try {
- isAppInForeground()
- } catch (t: Throwable) {
- // Unknown state: treat as foreground so a genuine ANR is never silently downgraded.
- logger.debug("$TAG: Could not resolve app state (${t.message}), assuming foreground")
- true
- }
-
- private fun startWatchdogThread() {
- // Start the watchdog thread
- watchdogThread = Thread(watchdogRunnable, "OneSignal-ANR-Watchdog")
- watchdogThread?.isDaemon = true
- watchdogThread?.start()
- }
-
- override fun stop() {
- if (!isMonitoring.getAndSet(false)) {
- logger.warn("$TAG: Not monitoring, skipping stop")
- return
- }
-
- logger.info("$TAG: Stopping ANR detection...")
-
- // Interrupt the watchdog thread to stop it
- watchdogThread?.interrupt()
- watchdogThread = null
- watchdogRunnable = null
- // Remove pending callbacks before nulling to prevent execution after stop
- mainThreadRunnable?.let { platform.removeFromMainThread(it) }
- mainThreadRunnable = null
-
- logger.info("$TAG: ✅ ANR detection stopped")
- }
-
- @Suppress("TooGenericExceptionCaught")
- private fun reportAnr(unresponsiveDurationMs: Long) {
- try {
- logger.info("$TAG: Checking if ANR is OneSignal-related (unresponsive for ${unresponsiveDurationMs}ms)")
-
- val mainThread = platform.mainThread()
- val stackTrace = platform.mainThreadStackTrace()
-
- // Only report if OneSignal is at fault (uses centralized utility from otel module)
- if (!isOneSignalAtFault(stackTrace)) {
- logger.debug("$TAG: ANR is not OneSignal-related, skipping report")
- return
- }
-
- logger.info("$TAG: OneSignal-related ANR detected, reporting...")
-
- // Create an ANR exception with the stack trace
- val anrException = ApplicationNotRespondingException(
- "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms",
- stackTrace,
- )
-
- // Report it as a crash (but mark it as ANR)
- runBlocking {
- crashReporter.saveCrash(mainThread, anrException)
- }
-
- logger.info("$TAG: ✅ ANR report saved successfully")
- } catch (t: Throwable) {
- logger.error("$TAG: Failed to report ANR: ${t.message} - ${t.javaClass.simpleName}")
- }
- }
-
- /**
- * Records a backgrounded main-thread block as a retained warning rather than an ANR.
- *
- * Android raises no ANR for a backgrounded app, so this is not a user-visible crash. We route it
- * through the same retained, disk-buffered crash telemetry as [reportAnr] (so it survives
- * regardless of the remote log level and stays queryable), but via [IOtelCrashReporter.saveNonFatal]
- * so it is emitted at a non-fatal severity and tagged non-fatal — keeping it out of any
- * severity-based crash/ANR metric rather than relying on the exception type alone. It is also
- * given a distinct exception type — [BackgroundMainThreadBlockException] — so it can be segmented
- * into its own stream, and the exception message carries a compact stack fingerprint (top frame +
- * first OneSignal frame) for triage. Like [reportAnr], only OneSignal-induced blocks are recorded.
- */
- @Suppress("TooGenericExceptionCaught")
- private fun reportBackgroundBlock(unresponsiveDurationMs: Long) {
- try {
- val mainThread = platform.mainThread()
- val stackTrace = platform.mainThreadStackTrace()
-
- if (!isOneSignalAtFault(stackTrace)) {
- logger.debug("$TAG: Background block is not OneSignal-related, skipping")
- return
- }
-
- val blockException = BackgroundMainThreadBlockException(
- "Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}",
- stackTrace,
- )
-
- runBlocking {
- crashReporter.saveNonFatal(mainThread, blockException)
- }
-
- logger.info("$TAG: ✅ Background block warning recorded")
- } catch (t: Throwable) {
- logger.error("$TAG: Failed to record background block: ${t.message} - ${t.javaClass.simpleName}")
- }
- }
-
- /**
- * Custom exception type for ANRs.
- * This allows us to distinguish ANRs from regular crashes in the crash reporting system.
- */
- private class ApplicationNotRespondingException(
- message: String,
- stackTrace: Array,
- ) : RuntimeException(message) {
- init {
- this.stackTrace = stackTrace
- }
- }
-
- /**
- * Custom exception type for backgrounded main-thread blocks. A distinct type keeps these out of
- * the ANR/crash buckets on the backend while remaining queryable as their own stream.
- */
- private class BackgroundMainThreadBlockException(
- message: String,
- stackTrace: Array,
- ) : RuntimeException(message) {
- init {
- this.stackTrace = stackTrace
- }
- }
-}
-
-/**
- * The platform touch points the watchdog needs: posting to the main thread, capturing its stack, and
- * a monotonic clock. Injecting this keeps [OtelAnrDetector] free of hard Android references at test
- * time so the watchdog logic can be driven on a plain JVM.
- */
-internal interface AnrWatchdogPlatform {
- fun postToMainThread(runnable: Runnable)
-
- fun removeFromMainThread(runnable: Runnable)
-
- fun mainThread(): Thread
-
- fun mainThreadStackTrace(): Array
-
- /**
- * Monotonic time in ms. Backed by SystemClock.uptimeMillis in production: it can't be skewed by
- * clock adjustments (NTP, manual time change, DST), it matches the clock the main Looper schedules
- * with, and it pauses during deep sleep so a dozing device doesn't accumulate phantom block time.
- */
- fun now(): Long
-}
-
-/** Production [AnrWatchdogPlatform] backed by the app's main [Looper] and [SystemClock]. */
-private class AndroidAnrWatchdogPlatform : AnrWatchdogPlatform {
- private val mainHandler = Handler(Looper.getMainLooper())
-
- override fun postToMainThread(runnable: Runnable) {
- mainHandler.post(runnable)
- }
-
- override fun removeFromMainThread(runnable: Runnable) {
- mainHandler.removeCallbacks(runnable)
- }
-
- override fun mainThread(): Thread = Looper.getMainLooper().thread
-
- override fun mainThreadStackTrace(): Array = Looper.getMainLooper().thread.stackTrace
-
- override fun now(): Long = SystemClock.uptimeMillis()
-}
-
-/**
- * Factory function to create an ANR detector for Android.
- * This is in the core module since it needs to access Android-specific classes.
- */
-internal fun createAnrDetector(
- platformProvider: com.onesignal.otel.IOtelPlatformProvider,
- logger: IOtelLogger,
- anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
- checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS,
- backgroundThresholdMs: Long = AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS,
-): IOtelAnrDetector {
- // Use the factory to create crash local instance (keeps implementation details internal)
- val crashLocal = OtelFactory.createCrashLocalTelemetry(platformProvider)
-
- return OtelAnrDetector(
- crashLocal,
- logger,
- anrThresholdMs,
- checkIntervalMs,
- backgroundThresholdMs,
- // appState is computed per access. Only "background" downgrades to a warning; "unknown"
- // is treated as foreground so a genuine ANR is never silently dropped.
- isAppInForeground = { platformProvider.appState != "background" },
- )
-}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt
index 7c27cd4f96..ac677ebabc 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/Logging.kt
@@ -8,8 +8,6 @@ import com.onesignal.debug.LogLevel
import com.onesignal.debug.OneSignalLogEvent
import com.onesignal.logger.ILogTelemetryRemote
import com.onesignal.logger.LogLoggingHelper
-import com.onesignal.otel.IOtelOpenTelemetryRemote
-import com.onesignal.otel.OtelLoggingHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -26,49 +24,26 @@ object Logging {
private val logListeners = CopyOnWriteArraySet()
/**
- * Optional Otel remote telemetry for logging SDK events.
+ * Optional `logger` module remote telemetry for shipping SDK events.
* Set this when remote logging is enabled.
*/
@Volatile
- private var otelRemoteTelemetry: IOtelOpenTelemetryRemote? = null
+ private var loggerRemoteTelemetry: ILogTelemetryRemote? = null
/**
* Function to check if a specific log level should be sent remotely.
* Set this to dynamically check remote logging configuration based on log level.
*/
@Volatile
- private var shouldSendLogLevel: (LogLevel) -> Boolean = { false }
+ private var shouldSendLoggerLogLevel: (LogLevel) -> Boolean = { false }
/**
- * Sets the Otel remote telemetry instance and log level check function.
+ * Sets the `logger` module remote telemetry instance and log level check function.
* This should be called when remote logging is enabled.
*
- * @param telemetry The Otel remote telemetry instance
+ * @param telemetry The remote telemetry instance
* @param shouldSend Function that returns true if a log level should be sent remotely
*/
- fun setOtelTelemetry(
- telemetry: IOtelOpenTelemetryRemote?,
- shouldSend: (LogLevel) -> Boolean = { false },
- ) {
- otelRemoteTelemetry = telemetry
- shouldSendLogLevel = shouldSend
- }
-
- /**
- * Optional `logger` module remote telemetry. This is the OpenTelemetry-free
- * replacement for [otelRemoteTelemetry]; only one of the two is wired at a time
- * (see LoggerModuleSwitch). Set this when remote logging is enabled via the
- * logger module.
- */
- @Volatile
- private var loggerRemoteTelemetry: ILogTelemetryRemote? = null
-
- @Volatile
- private var shouldSendLoggerLogLevel: (LogLevel) -> Boolean = { false }
-
- /**
- * Sets the `logger` module remote telemetry instance and log level check function.
- */
fun setLoggerTelemetry(
telemetry: ILogTelemetryRemote?,
shouldSend: (LogLevel) -> Boolean = { false },
@@ -77,8 +52,8 @@ object Logging {
shouldSendLoggerLogLevel = shouldSend
}
- // Coroutine scope for async Otel logging (non-blocking)
- private val otelLoggingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ // Coroutine scope for async remote logging (non-blocking)
+ private val remoteLoggingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@JvmStatic
var logLevel = LogLevel.WARN
@@ -156,7 +131,6 @@ object Logging {
logToLogcat(level, fullMessage, throwable)
showVisualLogging(level, fullMessage, throwable)
callLogListeners(level, fullMessage, throwable)
- logToOtel(level, fullMessage, throwable)
logToLogger(level, fullMessage, throwable)
}
@@ -226,57 +200,25 @@ object Logging {
}
/**
- * Logs to Otel remote telemetry if enabled.
+ * Logs to the `logger` module remote telemetry if enabled.
* This is non-blocking and runs asynchronously.
*/
@Suppress("TooGenericExceptionCaught", "ReturnCount")
- private fun logToOtel(
+ private fun logToLogger(
level: LogLevel,
message: String,
throwable: Throwable?,
) {
- val telemetry = otelRemoteTelemetry ?: return
+ val telemetry = loggerRemoteTelemetry ?: return
// Skip NONE level
if (level == LogLevel.NONE) return
// Check if this log level should be sent remotely
- if (!shouldSendLogLevel(level)) return
-
- // Log asynchronously (non-blocking)
- otelLoggingScope.launch {
- try {
- OtelLoggingHelper.logToOtel(
- telemetry = telemetry,
- level = level.name,
- message = message,
- exceptionType = throwable?.javaClass?.name,
- exceptionMessage = throwable?.message,
- exceptionStacktrace = throwable?.stackTraceToString(),
- )
- } catch (t: Throwable) {
- // Don't log Otel errors to Otel (would cause infinite loop)
- android.util.Log.e(TAG, "Failed to log to Otel: ${t.message}", t)
- }
- }
- }
-
- /**
- * Logs to the `logger` module remote telemetry if enabled. Non-blocking, mirrors
- * [logToOtel]. Only one of the otel/logger sinks is active at a time.
- */
- @Suppress("TooGenericExceptionCaught", "ReturnCount")
- private fun logToLogger(
- level: LogLevel,
- message: String,
- throwable: Throwable?,
- ) {
- val telemetry = loggerRemoteTelemetry ?: return
-
- if (level == LogLevel.NONE) return
if (!shouldSendLoggerLogLevel(level)) return
- otelLoggingScope.launch {
+ // Log asynchronously (non-blocking)
+ remoteLoggingScope.launch {
try {
LogLoggingHelper.log(
telemetry = telemetry,
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
deleted file mode 100644
index b4516ea7e0..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.onesignal.debug.internal.logging.logger
-
-import android.content.Context
-import com.onesignal.debug.internal.logging.otel.android.OtelIdResolver
-
-/**
- * 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.
- *
- * The choice is driven by the [com.onesignal.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 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.
- */
-internal object LoggerModuleSwitch {
- fun useLoggerModule(context: Context): Boolean = OtelIdResolver(context).resolveCustomLoggingEnabled()
-}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt
index e409959dba..73924a8801 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt
@@ -6,8 +6,8 @@ import android.os.SystemClock
import com.onesignal.debug.internal.crash.AnrCheckEvaluator
import com.onesignal.debug.internal.crash.AnrCheckResult
import com.onesignal.debug.internal.crash.AnrConstants
-import com.onesignal.debug.internal.crash.buildBlockFingerprint
-import com.onesignal.logger.CrashData
+import com.onesignal.debug.internal.crash.buildAnrCrashData
+import com.onesignal.debug.internal.crash.buildBackgroundBlockCrashData
import com.onesignal.logger.ILogAnrDetector
import com.onesignal.logger.ILogCrashReporter
import com.onesignal.logger.ILogger
@@ -16,7 +16,6 @@ import java.util.concurrent.atomic.AtomicBoolean
/**
* Android [ILogAnrDetector] — watchdog that monitors main-thread responsiveness and, if
* OneSignal is at fault, persists a report via the `logger` module's [ILogCrashReporter].
- * Transport-agnostic analogue of `OtelAnrDetector`.
*
* Detection is app-state aware, because a blocked main thread does not mean the same thing
* in the foreground as in the background:
@@ -29,8 +28,8 @@ import java.util.concurrent.atomic.AtomicBoolean
* descheduled (Doze / cached-process freeze), so the measured block is a freeze artifact
* and is suppressed.
*
- * Every timing/classification/dedup decision is delegated to the shared [AnrCheckEvaluator]
- * (pure, JVM-tested), the same core the `otel` watchdog uses, so the two stay in lockstep.
+ * Every timing/classification/dedup decision is delegated to [AnrCheckEvaluator]
+ * (pure, JVM-tested), so this shell stays free of timing logic.
*/
internal class AndroidLogAnrDetector(
private val crashReporter: ILogCrashReporter,
@@ -38,8 +37,6 @@ internal class AndroidLogAnrDetector(
private val anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
private val checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS,
backgroundThresholdMs: Long = AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS,
- // Only "background" downgrades to a warning; "unknown" is treated as foreground so a
- // genuine ANR is never silently dropped.
private val isAppInForeground: () -> Boolean = { true },
) : ILogAnrDetector {
private val mainHandler = Handler(Looper.getMainLooper())
@@ -164,14 +161,7 @@ internal class AndroidLogAnrDetector(
logger.debug("$TAG: ANR not OneSignal-related, skipping report")
return
}
- val crash =
- CrashData(
- threadName = mainThread.name,
- exceptionType = "ApplicationNotRespondingException",
- exceptionMessage = "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms",
- stacktrace = stackTrace.joinToString("\n") { it.toString() },
- )
- crashReporter.saveCrash(crash)
+ crashReporter.saveCrash(buildAnrCrashData(mainThread.name, stackTrace, unresponsiveDurationMs))
logger.info("$TAG: ANR report saved")
} catch (t: Throwable) {
logger.error("$TAG: failed to report ANR: ${t.message}")
@@ -179,9 +169,8 @@ internal class AndroidLogAnrDetector(
}
/**
- * Records a backgrounded main-thread block as a retained non-fatal warning rather than an
- * ANR. Uses a distinct exception type so it can be segmented into its own stream, and the
- * message carries a compact stack fingerprint (top frame + first OneSignal frame) for triage.
+ * Records a backgrounded main-thread block as a non-fatal warning rather than an ANR, under a
+ * distinct exception type so it can be segmented into its own stream.
*/
@Suppress("TooGenericExceptionCaught")
private fun reportBackgroundBlock(unresponsiveDurationMs: Long) {
@@ -192,15 +181,7 @@ internal class AndroidLogAnrDetector(
logger.debug("$TAG: background block not OneSignal-related, skipping")
return
}
- val crash =
- CrashData(
- threadName = mainThread.name,
- exceptionType = "BackgroundMainThreadBlockException",
- exceptionMessage =
- "Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}",
- stacktrace = stackTrace.joinToString("\n") { it.toString() },
- )
- crashReporter.saveNonFatal(crash)
+ crashReporter.saveNonFatal(buildBackgroundBlockCrashData(mainThread.name, stackTrace, unresponsiveDurationMs))
logger.info("$TAG: background block warning recorded")
} catch (t: Throwable) {
logger.error("$TAG: failed to record background block: ${t.message}")
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt
index 8a494a4b95..6e4072c99b 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt
@@ -11,8 +11,7 @@ import com.onesignal.logger.ILogger
* `logger` module's [ILogCrashReporter].
*
* Crash *capture* is platform-specific (hence this lives in core), but everything
- * downstream — persisting and shipping — is shared multiplatform code. Direct
- * analogue of `OtelCrashHandler`.
+ * downstream — persisting and shipping — is shared multiplatform code.
*/
internal class AndroidLogCrashHandler(
private val crashReporter: ILogCrashReporter,
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt
index 0838ed5e58..9fd82301a6 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt
@@ -5,7 +5,7 @@ import com.onesignal.logger.ILogger
/**
* Android implementation of [ILogger] for the `logger` module. Delegates to the
- * existing [Logging] object. Direct analogue of `AndroidOtelLogger`.
+ * existing [Logging] object.
*/
internal class AndroidLogger : ILogger {
override fun error(message: String) {
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanup.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanup.kt
deleted file mode 100644
index 377aeab71f..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanup.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.onesignal.debug.internal.logging.logger.android
-
-/**
- * Pure, Android-free helpers for the shared logger/otel crash directory.
- *
- * Ownership is suffix-based: logger-owned records end in [CRASH_OWNED_SUFFIX]; everything else
- * (legacy otel bare-millis names, stray `.tmp`s) is foreign. Keeping this logic free of
- * `File` / `Logging` / Robolectric means it is counted by Jacoco on the plain JVM.
- */
-internal const val CRASH_OWNED_SUFFIX = ".otlp"
-
-internal data class CrashDirEntry(
- val name: String,
- val lastModifiedMs: Long,
- val lengthBytes: Long = 0L,
-)
-
-/** True when [name] is a logger-owned crash record. */
-internal fun isOwnedCrashFile(name: String, ownedSuffix: String = CRASH_OWNED_SUFFIX): Boolean =
- name.endsWith(ownedSuffix)
-
-/**
- * Returns foreign/legacy entries old enough to reclaim. Owned `*.otlp` names are never selected,
- * regardless of age.
- */
-internal fun selectUnrecognizedEntries(
- entries: List,
- nowMs: Long,
- minAgeMillis: Long,
- ownedSuffix: String = CRASH_OWNED_SUFFIX,
-): List =
- entries.filter { entry ->
- !isOwnedCrashFile(entry.name, ownedSuffix) &&
- nowMs - entry.lastModifiedMs >= minAgeMillis
- }
-
-/**
- * Builds the human-readable crash-dir inventory line used for rollout verification.
- * Per-file detail is capped at [maxSample] so Logcat is not flooded.
- */
-internal fun formatCrashDirInventory(
- label: String,
- path: String,
- entries: List,
- nowMs: Long,
- maxSample: Int,
- ownedSuffix: String = CRASH_OWNED_SUFFIX,
-): String {
- if (entries.isEmpty()) {
- return "OneSignal: Crash storage inventory [$label] ($path): empty"
- }
- val otlp = entries.count { isOwnedCrashFile(it.name, ownedSuffix) }
- val legacy = entries.size - otlp
- val sample = entries.take(maxSample)
- val summary =
- sample.joinToString(separator = "; ") { entry ->
- "name=${entry.name} bytes=${entry.lengthBytes} ageMs=${nowMs - entry.lastModifiedMs}"
- }
- val truncated =
- if (entries.size > maxSample) {
- " …(+${entries.size - maxSample} more)"
- } else {
- ""
- }
- return "OneSignal: Crash storage inventory [$label] ($path): " +
- "total=${entries.size} otlp=$otlp legacy=$legacy [$summary]$truncated"
-}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt
index b36893434e..f07208cd9a 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt
@@ -4,6 +4,8 @@ import android.util.Log
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.logger.ILogFileStore
import com.onesignal.logger.StoredLogFile
+import com.onesignal.logger.crash.CrashDirEntry
+import com.onesignal.logger.crash.CrashRetention
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@@ -11,35 +13,54 @@ import java.util.UUID
import kotlin.coroutines.cancellation.CancellationException
/**
- * Android [ILogFileStore] backed by the local filesystem. Replaces OpenTelemetry's
- * `disk-buffering` contrib library with a trivial one-file-per-record format we own.
+ * Android [ILogFileStore] backed by the local filesystem: one file per crash record under
+ * [rootPath], with the file's last-modified time as the record age for [listReadable] so a file
+ * the crashing process may still have been writing is never read.
*
- * Each crash record is written to its own file under [rootPath]. The file's last
- * modified time is used as the record age for [listReadable], mirroring the old
- * `minFileAgeForReadMillis` behavior (never read a file the crashing process may
- * still have been writing).
+ * The directory is shared with pre-upgrade sessions, so ownership is decided purely by the
+ * policy's owned suffix: everything this store writes ends in `.otlp`; anything else
+ * (bare-millis names, stray `.tmp`s) is foreign and reclaimable via [deleteUnrecognizedEntries].
*
- * The logger and the legacy otel module share this one crash directory, so ownership
- * is distinguished purely by [CRASH_OWNED_SUFFIX]: everything the logger writes ends in
- * `.otlp`; anything else (legacy otel bare-millis files, stray `.tmp`s) is foreign and
- * reclaimable via [deleteUnrecognizedEntries] once the logger is the active module.
+ * All retention decisions come from the shared [CrashRetention] so Android and iOS bound the
+ * directory identically; this class only turns a listing into [CrashDirEntry]s and applies the
+ * result with `File` I/O. `maxTotalBytes` bounds the *budget claim* rather than raw disk bytes —
+ * the two differ only for oversized records inherited from a build predating the write-time limit
+ * in [save]. Both bounds are enforced on every path that touches the directory ([save],
+ * [listReadable], [deleteUnrecognizedEntries]), and over-limit records are deleted rather than
+ * merely hidden from [listReadable], so a record that never uploads cannot grow the cache forever.
*/
internal class FileLogStore(
private val rootPath: String,
) : ILogFileStore {
private val rootDir: File get() = File(rootPath)
+ // One instance passed to every selector, so no path can disagree about the bounds.
+ private val policy = CrashRetention.defaultPolicy
+
private companion object {
const val TAG = "OneSignal"
+
+ /** Keeps reclaim log lines bounded when a large backlog is trimmed at once. */
+ const val MAX_NAMES_LOGGED = 10
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override fun save(bytes: ByteArray): Boolean {
return try {
+ if (bytes.size > policy.maxRecordBytes) {
+ // Refuse rather than store-then-reclaim: a record this large would either claim
+ // the whole shared budget or be deleted before it was ever uploaded.
+ Log.w(
+ TAG,
+ "FileLogStore: refusing record of ${bytes.size} bytes, " +
+ "over the ${policy.maxRecordBytes}-byte limit",
+ )
+ return false
+ }
val dir = rootDir
if (!dir.exists()) dir.mkdirs()
// Write to a temp file then rename so a half-written file is never readable.
- val target = File(dir, "${System.currentTimeMillis()}-${UUID.randomUUID()}$CRASH_OWNED_SUFFIX")
+ val target = File(dir, "${System.currentTimeMillis()}-${UUID.randomUUID()}${policy.ownedSuffix}")
val temp = File(dir, target.name + ".tmp")
temp.writeBytes(bytes)
if (!temp.renameTo(target)) {
@@ -50,6 +71,7 @@ internal class FileLogStore(
// Crash path: raw Logcat only — Logging.info can invoke app listeners
// synchronously, and a listener exception would flip a successful write to false.
Log.i(TAG, "FileLogStore: saved name=${target.name} bytes=${bytes.size} dir=${dir.path}")
+ enforceAccumulationCaps(dir, keepName = target.name)
true
} catch (t: Throwable) {
// Crash-path safety: never throw from persistence; signal failure to caller.
@@ -58,21 +80,125 @@ internal class FileLogStore(
}
}
+ /**
+ * Evicts oldest-first on the crash path until the owned records fit the accumulation caps,
+ * always retaining [keepName] (the record [save] just wrote).
+ *
+ * Runs inline on the crashing thread, and uses raw Logcat for the same reason [save] does.
+ * The [CrashRetention.isWithinCaps] check below is what keeps that affordable: the selector
+ * sorts the whole directory, so it must only run on the rare pass that is actually over cap.
+ */
+ @Suppress("TooGenericExceptionCaught", "SwallowedException")
+ private fun enforceAccumulationCaps(dir: File, keepName: String) {
+ try {
+ val entries = listEntries(dir)
+ // Uses the selector's own capped accounting, so the check and the trim cannot
+ // disagree about whether a trim is needed.
+ if (CrashRetention.isWithinCaps(entries, policy)) return
+ val overflow =
+ CrashRetention.selectOverflowOwned(
+ entries,
+ nowMs = System.currentTimeMillis(),
+ keepName = keepName,
+ policy = policy,
+ )
+ if (overflow.isEmpty()) return
+ var evicted = 0
+ for (entry in overflow) {
+ if (File(dir, entry.name).delete()) evicted++
+ }
+ Log.i(
+ TAG,
+ "FileLogStore: evicted $evicted/${overflow.size} over-cap record(s) in ${dir.path}: " +
+ overflow.take(MAX_NAMES_LOGGED).joinToString(", ") { it.name },
+ )
+ } catch (t: Throwable) {
+ // Never let cache trimming turn a successful crash write into a failure.
+ Log.w(TAG, "FileLogStore: cap enforcement failed: ${t.message}")
+ }
+ }
+
+ /**
+ * Deletes owned records beyond the accumulation caps. Unlike [enforceAccumulationCaps] this
+ * runs on the uploader's IO paths, where walking a large inherited backlog is safe.
+ *
+ * @return names of the evicted records, so callers can exclude them from the same pass
+ */
+ private fun reclaimOverLimitRecords(entries: List, nowMs: Long): Set {
+ val overflow = CrashRetention.selectOverflowOwned(entries, nowMs, policy = policy)
+ if (overflow.isEmpty()) return emptySet()
+ var deleted = 0
+ for (entry in overflow) {
+ if (File(rootDir, entry.name).delete()) {
+ deleted++
+ } else {
+ Logging.warn("FileLogStore: failed to evict over-cap record ${entry.name}")
+ }
+ }
+ Logging.info(
+ "FileLogStore: evicted $deleted/${overflow.size} over-cap record(s) in ${rootDir.path}: " +
+ overflow.take(MAX_NAMES_LOGGED).joinToString(", ") { it.name },
+ )
+ return overflow.mapTo(HashSet()) { it.name }
+ }
+
+ private fun listEntries(dir: File): List =
+ dir.listFiles()?.filter { it.isFile }?.map { file ->
+ CrashDirEntry(
+ name = file.name,
+ lastModifiedMs = file.lastModified(),
+ lengthBytes = file.length(),
+ )
+ }.orEmpty()
+
+ /**
+ * Deletes owned records past the policy's read-age ceiling. Called from both read paths so
+ * over-age records are reclaimed even when remote logging is off and the uploader never
+ * gets as far as [listReadable].
+ *
+ * @return every expired name, whether or not its delete succeeded — one that could not be
+ * removed must still not be read.
+ */
+ private fun reclaimExpiredOwnedRecords(entries: List, nowMs: Long): Set {
+ val expired = CrashRetention.selectExpiredOwned(entries, nowMs, policy)
+ if (expired.isEmpty()) return emptySet()
+ var deleted = 0
+ for (entry in expired) {
+ if (File(rootDir, entry.name).delete()) {
+ deleted++
+ } else {
+ Logging.warn("FileLogStore: failed to reclaim expired record ${entry.name}")
+ }
+ }
+ Logging.info(
+ "FileLogStore: reclaimed $deleted/${expired.size} expired record(s) in ${rootDir.path}: " +
+ expired.take(MAX_NAMES_LOGGED).joinToString(", ") { it.name },
+ )
+ return expired.mapTo(HashSet()) { it.name }
+ }
+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override suspend fun listReadable(minAgeMillis: Long): List =
withContext(Dispatchers.IO) {
try {
val now = System.currentTimeMillis()
- val allFiles = rootDir.listFiles()?.filter { it.isFile }.orEmpty()
- val suffixMatches = allFiles.filter { isOwnedCrashFile(it.name) }
+ val entries = listEntries(rootDir)
+ // Reclaim before reading: payloads are only materialized for records that
+ // survive both bounds, so an over-cap backlog is never fully loaded.
+ val expired = reclaimExpiredOwnedRecords(entries, now)
+ val evicted = reclaimOverLimitRecords(entries.filterNot { expired.contains(it.name) }, now)
+ val dropped = expired + evicted
+ val suffixMatches =
+ entries.filter { CrashRetention.isOwned(it.name, policy) && !dropped.contains(it.name) }
val readable =
suffixMatches
- .filter { now - it.lastModified() >= minAgeMillis }
- .mapNotNull { file -> readRecord(file) }
+ .filter { now - it.lastModifiedMs >= minAgeMillis }
+ .mapNotNull { entry -> readRecord(File(rootDir, entry.name)) }
Logging.debug(
- "FileLogStore: listReadable minAgeMs=$minAgeMillis total=${allFiles.size} " +
+ "FileLogStore: listReadable minAgeMs=$minAgeMillis total=${entries.size} " +
"suffix=${suffixMatches.size} readable=${readable.size} " +
- "legacy=${allFiles.size - suffixMatches.size}",
+ "expired=${expired.size} overCap=${evicted.size} " +
+ "legacy=${entries.count { !CrashRetention.isOwned(it.name, policy) }}",
)
readable
} catch (e: CancellationException) {
@@ -107,31 +233,26 @@ internal class FileLogStore(
}
/**
- * Removes on-disk entries this store does not own — legacy OTEL disk-buffering
- * files (bare-millis names) and stray `.tmp`s that share this directory — whose
- * age is at least [minAgeMillis]. All `*.otlp` owned records are left untouched
- * so failed / too-young uploads can still retry on the next launch.
+ * Removes on-disk entries this store does not own — bare-millis files and stray `.tmp`s
+ * that share this directory — whose age is at least [minAgeMillis]. Owned `*.otlp` records
+ * are left untouched so failed / too-young uploads can still retry on the next launch,
+ * except for ones past the read-age ceiling or beyond the accumulation caps.
*
- * Implements the shared [ILogFileStore] contract: the KMP `LogCrashUploader`
- * invokes this after its owned-record upload pass. Idempotent and safe to call
- * repeatedly.
+ * Unlike [listReadable] the uploader also calls this when remote logging is disabled, which
+ * makes it the only chance to bound records written by a session that never uploads.
+ * Idempotent and safe to call repeatedly.
*
- * @return number of unrecognized entries deleted
+ * @return number of unrecognized entries deleted, excluding reclaimed owned records
*/
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override suspend fun deleteUnrecognizedEntries(minAgeMillis: Long): Int =
withContext(Dispatchers.IO) {
try {
val now = System.currentTimeMillis()
- val listed =
- rootDir.listFiles()?.filter { it.isFile }?.map { file ->
- CrashDirEntry(
- name = file.name,
- lastModifiedMs = file.lastModified(),
- lengthBytes = file.length(),
- )
- }.orEmpty()
- val foreign = selectUnrecognizedEntries(listed, now, minAgeMillis)
+ val listed = listEntries(rootDir)
+ val expired = reclaimExpiredOwnedRecords(listed, now)
+ reclaimOverLimitRecords(listed.filterNot { expired.contains(it.name) }, now)
+ val foreign = CrashRetention.selectUnrecognized(listed, now, minAgeMillis, policy)
if (foreign.isEmpty()) {
Logging.debug("FileLogStore: no unrecognized files to purge in ${rootDir.path}")
return@withContext 0
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/logger/android/LoggerIdResolver.kt
similarity index 90%
rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt
rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerIdResolver.kt
index 32dd58c951..c4f8056e9a 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/logger/android/LoggerIdResolver.kt
@@ -1,13 +1,11 @@
-package com.onesignal.debug.internal.logging.otel.android
+package com.onesignal.debug.internal.logging.logger.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.preferences.PreferenceOneSignalKeys
import com.onesignal.core.internal.preferences.PreferenceStores
import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.features.FeatureFlag
import com.onesignal.user.internal.backend.IdentityConstants
import org.json.JSONArray
import org.json.JSONObject
@@ -21,7 +19,7 @@ import org.json.JSONObject
* and correctness. The performance impact is minimal since these methods are not called frequently.
*/
@Suppress("TooManyFunctions") // This class intentionally groups related ID resolution functions
-internal class OtelIdResolver(
+internal class LoggerIdResolver(
private val context: Context?,
) {
companion object {
@@ -233,21 +231,6 @@ 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/debug/internal/logging/logger/android/LoggerPlatformFactory.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformFactory.kt
deleted file mode 100644
index 34bfff504e..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformFactory.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.onesignal.debug.internal.logging.logger.android
-
-import android.content.Context
-import com.onesignal.core.internal.features.IFeatureManager
-import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider
-import com.onesignal.logger.ILoggerPlatformProvider
-
-/**
- * Builds an [ILoggerPlatformProvider] for Android by adapting the existing Android
- * platform provider. Centralizes the wiring so the lifecycle manager and crash
- * uploader wrapper construct it identically.
- */
-internal fun createAndroidLoggerPlatformProvider(
- context: Context,
- featureManagerProvider: () -> IFeatureManager,
-): ILoggerPlatformProvider =
- LoggerPlatformProviderAdapter(
- createAndroidOtelPlatformProvider(context, featureManagerProvider),
- )
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProvider.kt
similarity index 79%
rename from OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt
rename to OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProvider.kt
index 88f2ac2f91..def491a252 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProvider.kt
@@ -1,4 +1,4 @@
-package com.onesignal.debug.internal.logging.otel.android
+package com.onesignal.debug.internal.logging.logger.android
import android.app.ActivityManager
import android.content.Context
@@ -8,16 +8,16 @@ import com.onesignal.common.OneSignalWrapper
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.http.OneSignalService
import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.otel.IOtelPlatformProvider
+import com.onesignal.logger.ILoggerPlatformProvider
import java.io.File
-// Use this to enable/disable the Otel exporter logging in debug builds.
-internal const val OTEL_EXPORTER_LOGGING_ENABLED = false
+// Use this to enable/disable local exporter diagnostics in debug builds.
+internal const val EXPORTER_LOGGING_ENABLED = false
/**
- * Configuration for AndroidOtelPlatformProvider.
+ * Configuration for [LoggerPlatformProvider].
*/
-internal data class OtelPlatformProviderConfig(
+internal data class LoggerPlatformProviderConfig(
val crashStoragePath: String,
val appPackageId: String,
val appVersion: String,
@@ -26,22 +26,22 @@ internal data class OtelPlatformProviderConfig(
)
/**
- * Android-specific implementation of IOtelPlatformProvider.
+ * Android implementation of [ILoggerPlatformProvider].
* Reads all values directly from SharedPreferences and system services.
* No SDK service dependencies required.
*
- * All IDs (appId, onesignalId, pushSubscriptionId) are resolved from SharedPreferences via OtelIdResolver.
- * Remote log level defaults to ERROR if not found in config.
+ * All IDs (appId, onesignalId, pushSubscriptionId) are resolved from SharedPreferences via
+ * [LoggerIdResolver]. Remote log level defaults to ERROR if not found in config.
*/
-internal class OtelPlatformProvider(
- config: OtelPlatformProviderConfig,
+internal class LoggerPlatformProvider(
+ config: LoggerPlatformProviderConfig,
private val featureManagerProvider: () -> IFeatureManager,
-) : IOtelPlatformProvider {
+) : ILoggerPlatformProvider {
override val appPackageId: String = config.appPackageId
override val appVersion: String = config.appVersion
private val context: Context? = config.context
private val getIsInForeground: (() -> Boolean?)? = config.getIsInForeground
- private val idResolver = OtelIdResolver(context)
+ private val idResolver = LoggerIdResolver(context)
// Top-level attributes (static, calculated once)
override suspend fun getInstallId(): String = idResolver.resolveInstallId()
@@ -76,10 +76,10 @@ internal class OtelPlatformProvider(
// Read through the supplier on every access so per-event attributes always reflect the
// current featureStates snapshot (including IMMEDIATE-mode flag changes). The supplier is
- // an immutable constructor val that resolves IFeatureManager lazily — this lets the OTel
+ // an immutable constructor val that resolves IFeatureManager lazily — this lets the logging
// pipeline come up early in init (before service bootstrap) without mutable late-bound
// state. Returns an empty list when the supplier or the manager throws (e.g. very early
- // emissions before services are ready); the attribute is then omitted by OtelFieldsPerEvent.
+ // emissions before services are ready); the attribute is then omitted downstream.
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override val enabledFeatureFlags: List
get() = try {
@@ -147,14 +147,14 @@ internal class OtelPlatformProvider(
override val minFileAgeForReadMillis: Long = 5_000
// Cached from SharedPreferences on first access and held for the session.
- // Mid-session config updates are handled by OtelLifecycleManager reading
+ // Mid-session config updates are handled by LoggerLifecycleManager reading
// from ConfigModel directly, not from these cached values.
override val isRemoteLoggingEnabled: Boolean by lazy {
idResolver.resolveRemoteLoggingEnabled()
}
// Cached from SharedPreferences on first access and held for the session.
- // Mid-session config updates are handled by OtelLifecycleManager reading
+ // Mid-session config updates are handled by LoggerLifecycleManager reading
// from ConfigModel directly, not from these cached values.
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override val remoteLogLevel: String? by lazy {
@@ -165,7 +165,7 @@ internal class OtelPlatformProvider(
}
}
- override val isOtelExporterLoggingEnabled: Boolean = OTEL_EXPORTER_LOGGING_ENABLED
+ override val isExporterLoggingEnabled: Boolean = EXPORTER_LOGGING_ENABLED
override val appIdForHeaders: String
get() = appId ?: ""
@@ -174,18 +174,18 @@ internal class OtelPlatformProvider(
}
/**
- * Factory function to create AndroidOtelPlatformProvider. Reads value-config directly from
- * SharedPreferences / system services; receives a [featureManagerProvider] supplier that the
- * provider invokes lazily on each `enabledFeatureFlags` read so the OTel pipeline can come up
+ * Factory function to create the Android [ILoggerPlatformProvider]. Reads value-config directly
+ * from SharedPreferences / system services; receives a [featureManagerProvider] supplier that the
+ * provider invokes lazily on each `enabledFeatureFlags` read so the logging pipeline can come up
* before service bootstrap completes.
*/
-internal fun createAndroidOtelPlatformProvider(
+internal fun createAndroidLoggerPlatformProvider(
context: Context,
featureManagerProvider: () -> IFeatureManager,
-): OtelPlatformProvider {
- return OtelPlatformProvider(
- OtelPlatformProviderConfig(
- crashStoragePath = getOtelCrashStoragePath(context),
+): LoggerPlatformProvider {
+ return LoggerPlatformProvider(
+ LoggerPlatformProviderConfig(
+ crashStoragePath = getCrashStoragePath(context),
appPackageId = context.packageName,
appVersion = com.onesignal.common.AndroidUtils.getAppVersion(context) ?: "unknown",
context = context,
@@ -194,5 +194,11 @@ internal fun createAndroidOtelPlatformProvider(
)
}
-internal fun getOtelCrashStoragePath(context: Context): String =
+/**
+ * The `otel` path segment is kept even though OpenTelemetry is gone: it is the directory
+ * upgrading installs already hold crash records in, and moving it would orphan pending
+ * uploads. Legacy OTel-format records left behind are reclaimed by
+ * [com.onesignal.logger.crash.CrashRetention.selectUnrecognized].
+ */
+internal fun getCrashStoragePath(context: Context): String =
File(File(File(context.cacheDir, "onesignal"), "otel"), "crashes").path
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt
deleted file mode 100644
index deca25f5e0..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.onesignal.debug.internal.logging.logger.android
-
-import com.onesignal.logger.ILoggerPlatformProvider
-import com.onesignal.otel.IOtelPlatformProvider
-
-/**
- * Adapts the existing Android [IOtelPlatformProvider] to the `logger` module's
- * [ILoggerPlatformProvider]. This reuses all of the battle-tested Android value
- * resolution (IDs, config, device metadata) so the logger pipeline reads exactly the
- * same values as the otel pipeline — only the consuming interface differs.
- *
- * When `otel` is eventually removed, the underlying provider's logic can move into a
- * native [ILoggerPlatformProvider] implementation and this adapter deleted.
- */
-internal class LoggerPlatformProviderAdapter(
- private val delegate: IOtelPlatformProvider,
-) : ILoggerPlatformProvider {
- override suspend fun getInstallId(): String = delegate.getInstallId()
-
- override val sdkBase: String get() = delegate.sdkBase
- override val sdkBaseVersion: String get() = delegate.sdkBaseVersion
- override val appPackageId: String get() = delegate.appPackageId
- override val appVersion: String get() = delegate.appVersion
- override val deviceManufacturer: String get() = delegate.deviceManufacturer
- override val deviceModel: String get() = delegate.deviceModel
- override val osName: String get() = delegate.osName
- override val osVersion: String get() = delegate.osVersion
- override val osBuildId: String get() = delegate.osBuildId
- override val sdkWrapper: String? get() = delegate.sdkWrapper
- override val sdkWrapperVersion: String? get() = delegate.sdkWrapperVersion
- override val kotlinVersion: String? get() = delegate.kotlinVersion
- override val swiftVersion: String? get() = delegate.swiftVersion
- override val additionalVersionAttributes: Map
- get() = delegate.additionalVersionAttributes
- override val enabledFeatureFlags: List get() = delegate.enabledFeatureFlags
-
- override val appId: String? get() = delegate.appId
- override val onesignalId: String? get() = delegate.onesignalId
- override val pushSubscriptionId: String? get() = delegate.pushSubscriptionId
- override val appState: String get() = delegate.appState
- override val processUptime: Long get() = delegate.processUptime
- override val currentThreadName: String get() = delegate.currentThreadName
-
- override val crashStoragePath: String get() = delegate.crashStoragePath
- override val minFileAgeForReadMillis: Long get() = delegate.minFileAgeForReadMillis
-
- override val isRemoteLoggingEnabled: Boolean get() = delegate.isRemoteLoggingEnabled
- override val remoteLogLevel: String? get() = delegate.remoteLogLevel
- override val isExporterLoggingEnabled: Boolean get() = delegate.isOtelExporterLoggingEnabled
- override val appIdForHeaders: String get() = delegate.appIdForHeaders
- override val apiBaseUrl: String get() = delegate.apiBaseUrl
-}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt
index bd0e8b7a6e..4f80939919 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt
@@ -18,7 +18,7 @@ import java.net.URL
*
* Request/response diagnostics are emitted through [logger] only when
* [isDiagnosticsEnabled] returns true (driven by the remote-config exporter-logging
- * toggle), mirroring the old otel exporter's opt-in logging — never unconditional
+ * toggle) — never unconditional
* logcat noise in production.
*/
internal class OneSignalLogHttpSender(
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt
deleted file mode 100644
index 0452a8dca3..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.onesignal.debug.internal.logging.otel.android
-
-import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.otel.IOtelLogger
-
-/**
- * Android-specific implementation of IOtelLogger.
- * Delegates to the existing Logging object.
- */
-internal class AndroidOtelLogger : IOtelLogger {
- override fun error(message: String) {
- Logging.error(message)
- }
-
- override fun warn(message: String) {
- Logging.warn(message)
- }
-
- override fun info(message: String) {
- Logging.info(message)
- }
-
- override fun debug(message: String) {
- Logging.debug(message)
- }
-}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt
index 1704e41ef4..c8b44420f1 100644
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt
@@ -6,9 +6,8 @@ import com.onesignal.core.internal.config.ConfigModelStore
* Owns the lifecycle of the SDK's observability features (remote logging, crash
* handling, ANR detection) and reacts to remote config changes.
*
- * Implemented by both [OtelLifecycleManager] (OpenTelemetry path) and
- * [LoggerLifecycleManager] (multiplatform `logger` path) so [OneSignalImp] can switch
- * between them via a single toggle without caring which backend is active.
+ * Implemented by [LoggerLifecycleManager] so [OneSignalImp] can hold the pipeline
+ * behind a narrow contract without depending on the backing module.
*/
internal interface IObservabilityLifecycleManager {
/** Boots whichever features are already enabled from cached config at cold start. */
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 4358110437..b5224a70fd 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
@@ -9,7 +9,7 @@ import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.crash.AnrConstants
-import com.onesignal.debug.internal.crash.OtelSdkSupport
+import com.onesignal.debug.internal.crash.ObservabilitySdkSupport
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.debug.internal.logging.logger.android.AndroidLogAnrDetector
import com.onesignal.debug.internal.logging.logger.android.AndroidLogCrashHandler
@@ -19,50 +19,87 @@ import com.onesignal.debug.internal.logging.logger.android.OneSignalLogHttpSende
import com.onesignal.debug.internal.logging.logger.android.createAndroidLoggerPlatformProvider
import com.onesignal.logger.ILogAnrDetector
import com.onesignal.logger.ILogCrashHandler
+import com.onesignal.logger.ILogCrashReporter
+import com.onesignal.logger.ILogFileStore
+import com.onesignal.logger.ILogHttpSender
import com.onesignal.logger.ILogTelemetryRemote
+import com.onesignal.logger.ILogger
import com.onesignal.logger.ILoggerPlatformProvider
import com.onesignal.logger.LoggerFactory
+/** Shared by the crash-handler and ANR-detector defaults, which each report through their own reporter. */
+private fun createReporter(
+ platformProvider: ILoggerPlatformProvider,
+ fileStore: ILogFileStore,
+ logger: ILogger,
+): ILogCrashReporter =
+ LoggerFactory.createCrashReporter(
+ LoggerFactory.createCrashLocalTelemetry(platformProvider, fileStore),
+ logger,
+ )
+
/**
- * The `logger` module counterpart to [OtelLifecycleManager]. Owns the lifecycle of the
- * multiplatform, OpenTelemetry-free observability pipeline and reacts to remote config
- * changes the same way (using the shared [OtelConfig]/[OtelConfigEvaluator]).
+ * Owns the lifecycle of the SDK's multiplatform observability pipeline (remote logging,
+ * crash capture, ANR detection) and reacts to remote config changes via the shared
+ * [ObservabilityConfig]/[ObservabilityConfigEvaluator].
*
- * 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.
+ * Production callers supply only [context] and [featureManagerProvider]; the remaining
+ * parameters default to the real implementations and exist for test injection.
*/
-@Suppress("TooManyFunctions")
+@Suppress("TooManyFunctions", "LongParameterList")
internal class LoggerLifecycleManager(
private val context: Context,
private val featureManagerProvider: () -> IFeatureManager,
+ private val platformProviderFactory: (Context, () -> IFeatureManager) -> ILoggerPlatformProvider =
+ { ctx, fm -> createAndroidLoggerPlatformProvider(ctx, fm) },
+ private val logger: ILogger = AndroidLogger(),
+ private val fileStoreFactory: (String) -> ILogFileStore = { path -> FileLogStore(path) },
+ private val crashHandlerFactory: (ILoggerPlatformProvider, ILogFileStore, ILogger) -> ILogCrashHandler =
+ { pp, store, log -> AndroidLogCrashHandler(createReporter(pp, store, log), log) },
+ private val anrDetectorFactory: (ILoggerPlatformProvider, ILogFileStore, ILogger) -> ILogAnrDetector =
+ { pp, store, log ->
+ AndroidLogAnrDetector(
+ createReporter(pp, store, log),
+ log,
+ AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
+ AnrConstants.DEFAULT_CHECK_INTERVAL_MS,
+ AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS,
+ // Only "background" downgrades a block to a non-fatal warning; "unknown" is
+ // treated as foreground so a genuine ANR is never silently dropped.
+ isAppInForeground = { pp.appState != "background" },
+ )
+ },
+ private val remoteTelemetryFactory: (ILoggerPlatformProvider, ILogHttpSender) -> ILogTelemetryRemote =
+ { pp, sender -> LoggerFactory.createRemoteTelemetry(pp, sender) },
) : ISingletonModelStoreChangeHandler, IObservabilityLifecycleManager {
private val lock = Any()
private val platformProvider: ILoggerPlatformProvider by lazy {
- createAndroidLoggerPlatformProvider(context, featureManagerProvider)
+ platformProviderFactory(context, featureManagerProvider)
}
- private val logger = AndroidLogger()
private val httpSender = OneSignalLogHttpSender(logger) { platformProvider.isExporterLoggingEnabled }
- private val fileStore: FileLogStore by lazy { FileLogStore(platformProvider.crashStoragePath) }
+ private val fileStore: ILogFileStore by lazy { fileStoreFactory(platformProvider.crashStoragePath) }
private var crashHandler: ILogCrashHandler? = null
private var anrDetector: ILogAnrDetector? = null
private var remoteTelemetry: ILogTelemetryRemote? = null
- private var currentConfig: OtelConfig? = null
+ private var currentConfig: ObservabilityConfig? = null
+
+ /** Level the live sink is actually filtering at, which is not always [currentConfig]'s level. */
+ private var activeLogLevel: LogLevel? = null
@Suppress("TooGenericExceptionCaught")
override fun initializeFromCachedConfig() {
- if (!OtelSdkSupport.isSupported) {
- Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, logger module not supported — skipping")
+ if (!ObservabilitySdkSupport.isSupported) {
+ Logging.info("OneSignal: Device SDK < ${ObservabilitySdkSupport.MIN_SDK_VERSION}, logger module not supported — skipping")
return
}
try {
val cachedConfig = readCurrentCachedConfig()
synchronized(lock) {
- val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = cachedConfig)
+ val action = ObservabilityConfigEvaluator.evaluate(old = currentConfig, new = cachedConfig)
applyAction(action, cachedConfig)
}
} catch (t: Throwable) {
@@ -77,15 +114,15 @@ internal class LoggerLifecycleManager(
@Suppress("TooGenericExceptionCaught")
override fun onModelReplaced(model: ConfigModel, tag: String) {
if (tag != ModelChangeTags.HYDRATE) return
- if (!OtelSdkSupport.isSupported) return
+ if (!ObservabilitySdkSupport.isSupported) return
try {
val newConfig =
- OtelConfig(
+ ObservabilityConfig(
isEnabled = model.remoteLoggingParams.isEnabled,
logLevel = model.remoteLoggingParams.logLevel,
)
synchronized(lock) {
- val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = newConfig)
+ val action = ObservabilityConfigEvaluator.evaluate(old = currentConfig, new = newConfig)
applyAction(action, newConfig)
}
} catch (t: Throwable) {
@@ -97,115 +134,195 @@ internal class LoggerLifecycleManager(
// Only full model replacements (HYDRATE) matter here.
}
- private fun readCurrentCachedConfig(): OtelConfig {
+ private fun readCurrentCachedConfig(): ObservabilityConfig {
val enabled = platformProvider.isRemoteLoggingEnabled
val level = LogLevel.fromString(platformProvider.remoteLogLevel)
- return OtelConfig(isEnabled = enabled, logLevel = level)
+ return ObservabilityConfig(isEnabled = enabled, logLevel = level)
}
- /** Must be called while holding [lock]. */
- private fun applyAction(action: OtelConfigAction, newConfig: OtelConfig) {
- when (action) {
- is OtelConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR)
- is OtelConfigAction.Disable -> disableFeatures()
- is OtelConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel)
- is OtelConfigAction.NoChange -> Logging.debug("OneSignal: logger config unchanged")
+ /**
+ * Must be called while holding [lock].
+ *
+ * [currentConfig] is only advanced once the requested state is actually in place. If a
+ * component failed to start, the config is left behind so the next HYDRATE — which for a
+ * stable remote payload is an identical one — still evaluates to `Enable` and retries the
+ * missing piece, instead of collapsing to `NoChange` and leaving it dead for the session.
+ */
+ private fun applyAction(action: ObservabilityConfigAction, newConfig: ObservabilityConfig) {
+ // The evaluator only sees desired config, and a partial-failure Enable leaves components
+ // live under a config that was never committed. Honoring "off" off actual liveness keeps
+ // the remote kill switch effective there; gating on liveness is what stops it thrashing.
+ if (!newConfig.isEnabled && isAnyFeatureLive()) {
+ disableFeatures()
+ currentConfig = newConfig
+ return
}
- currentConfig = newConfig
+ val applied =
+ when (action) {
+ is ObservabilityConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR)
+ is ObservabilityConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel)
+ is ObservabilityConfigAction.Disable -> {
+ disableFeatures()
+ true
+ }
+ is ObservabilityConfigAction.NoChange -> {
+ Logging.debug("OneSignal: logger config unchanged")
+ true
+ }
+ }
+ if (applied) currentConfig = newConfig
}
+ private fun isAnyFeatureLive(): Boolean = crashHandler != null || anrDetector != null || remoteTelemetry != null
+
+ /**
+ * Starts whatever is not already running. Each component is independent: one failing must
+ * not stop the others.
+ *
+ * @return true when every feature is up, so the caller knows whether to commit the config
+ */
@Suppress("TooGenericExceptionCaught")
- private fun enableFeatures(logLevel: LogLevel) {
+ private fun enableFeatures(logLevel: LogLevel): Boolean {
Logging.info("OneSignal: Enabling logger module features at level $logLevel")
+ var allStarted = true
try {
startCrashHandler()
} catch (t: Throwable) {
+ allStarted = false
Logging.warn("OneSignal: Failed to start logger crash handler: ${t.message}", t)
}
try {
startAnrDetector()
} catch (t: Throwable) {
+ allStarted = false
Logging.warn("OneSignal: Failed to start logger ANR detector: ${t.message}", t)
}
try {
- startLogging(logLevel)
+ // Guarded like the other two so a retry does not tear down a healthy sink — but a
+ // retry can carry a newer level than the live sink was started at, and only
+ // startLogging can move it, so compare against the level actually in force.
+ if (remoteTelemetry == null || activeLogLevel != logLevel) startLogging(logLevel)
} catch (t: Throwable) {
+ allStarted = false
Logging.warn("OneSignal: Failed to start logger logging: ${t.message}", t)
}
+ if (!allStarted) {
+ Logging.warn("OneSignal: Some logger features did not start; will retry on the next config refresh")
+ }
+ return allStarted
}
@Suppress("TooGenericExceptionCaught")
private fun disableFeatures() {
Logging.info("OneSignal: Disabling logger module features")
+ // Each reference is cleared before the teardown call, not after. A collaborator that
+ // throws on the way down would otherwise leave its field set, and the start guards
+ // below would then treat the dead component as already running — permanently
+ // disabling it for the rest of the process.
try {
- anrDetector?.stop()
+ val detector = anrDetector
anrDetector = null
+ detector?.stop()
} catch (t: Throwable) {
Logging.warn("OneSignal: Error stopping logger ANR detector: ${t.message}", t)
}
try {
- crashHandler?.unregister()
+ val handler = crashHandler
crashHandler = null
+ handler?.unregister()
} catch (t: Throwable) {
Logging.warn("OneSignal: Error unregistering logger crash handler: ${t.message}", t)
}
try {
- Logging.setLoggerTelemetry(null) { false }
- remoteTelemetry?.shutdown()
+ val telemetry = remoteTelemetry
remoteTelemetry = null
+ activeLogLevel = null
+ Logging.setLoggerTelemetry(null) { false }
+ telemetry?.shutdown()
} catch (t: Throwable) {
Logging.warn("OneSignal: Error disabling logger logging: ${t.message}", t)
}
}
+ /** @return true when the new level is live, so the caller knows whether to commit the config */
@Suppress("TooGenericExceptionCaught")
- private fun updateLogLevel(newLevel: LogLevel) {
+ private fun updateLogLevel(newLevel: LogLevel): Boolean {
Logging.info("OneSignal: Updating logger module log level to $newLevel")
- try {
+ return try {
startLogging(newLevel)
+ true
} catch (t: Throwable) {
Logging.warn("OneSignal: Failed to update logger log level: ${t.message}", t)
+ false
}
}
+ /**
+ * A partially-started component still has to be undone. [ILogCrashHandler.initialize] chains
+ * itself onto the process-global uncaught-exception handler before it can throw, so dropping
+ * the reference on failure would let the next retry chain a second one and double-report.
+ * Unregistering before clearing the field keeps [disableFeatures]' invariant intact: the field
+ * is never left pointing at something dead, so the start guards above cannot be fooled.
+ */
+ @Suppress("TooGenericExceptionCaught")
private fun startCrashHandler() {
if (crashHandler != null) return
- val crashTelemetry = LoggerFactory.createCrashLocalTelemetry(platformProvider, fileStore)
- val reporter = LoggerFactory.createCrashReporter(crashTelemetry, logger)
- val handler = AndroidLogCrashHandler(reporter, logger)
- handler.initialize()
+ val handler = crashHandlerFactory(platformProvider, fileStore, logger)
+ try {
+ handler.initialize()
+ } catch (t: Throwable) {
+ try {
+ handler.unregister()
+ } catch (inner: Throwable) {
+ Logging.warn("OneSignal: Error unwinding a partially initialized logger crash handler: ${inner.message}", inner)
+ }
+ throw t
+ }
crashHandler = handler
Logging.info("OneSignal: logger crash handler initialized — logs at: ${platformProvider.crashStoragePath}")
}
+ /** Same partial-start unwind as [startCrashHandler]: a throwing start() may already have spawned its watchdog. */
+ @Suppress("TooGenericExceptionCaught")
private fun startAnrDetector() {
if (anrDetector != null) return
- val crashTelemetry = LoggerFactory.createCrashLocalTelemetry(platformProvider, fileStore)
- val reporter = LoggerFactory.createCrashReporter(crashTelemetry, logger)
- val detector =
- AndroidLogAnrDetector(
- reporter,
- logger,
- AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
- AnrConstants.DEFAULT_CHECK_INTERVAL_MS,
- AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS,
- // Only "background" downgrades a block to a non-fatal warning; "unknown" is
- // treated as foreground so a genuine ANR is never silently dropped.
- isAppInForeground = { platformProvider.appState != "background" },
- )
- detector.start()
+ val detector = anrDetectorFactory(platformProvider, fileStore, logger)
+ try {
+ detector.start()
+ } catch (t: Throwable) {
+ try {
+ detector.stop()
+ } catch (inner: Throwable) {
+ Logging.warn("OneSignal: Error unwinding a partially started logger ANR detector: ${inner.message}", inner)
+ }
+ throw t
+ }
anrDetector = detector
Logging.info("OneSignal: logger ANR detector started")
}
+ @Suppress("TooGenericExceptionCaught")
private fun startLogging(logLevel: LogLevel) {
- remoteTelemetry?.shutdown()
- val telemetry = LoggerFactory.createRemoteTelemetry(platformProvider, httpSender)
+ // Same invariant as disableFeatures: detach both the field and Logging's global before
+ // tearing the old sink down. Shutting down first would route every log emitted until the
+ // replacement is installed — including the warn below — into a cancelled consumer, and a
+ // throwing factory would leave the global pointing at the dead instance for the session.
+ val previous = remoteTelemetry
+ remoteTelemetry = null
+ activeLogLevel = null
+ Logging.setLoggerTelemetry(null) { false }
+ try {
+ previous?.shutdown()
+ } catch (t: Throwable) {
+ Logging.warn("OneSignal: Error shutting down previous logger telemetry: ${t.message}", t)
+ }
+ val telemetry = remoteTelemetryFactory(platformProvider, httpSender)
remoteTelemetry = telemetry
val shouldSend: (LogLevel) -> Boolean = { level ->
logLevel != LogLevel.NONE && level <= logLevel
}
Logging.setLoggerTelemetry(telemetry, shouldSend)
+ activeLogLevel = logLevel
Logging.info("OneSignal: logger module logging active at level $logLevel")
}
}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/ObservabilityConfigEvaluator.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/ObservabilityConfigEvaluator.kt
new file mode 100644
index 0000000000..85779c65fe
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/ObservabilityConfigEvaluator.kt
@@ -0,0 +1,66 @@
+package com.onesignal.internal
+
+import com.onesignal.debug.LogLevel
+
+/**
+ * Snapshot of the observability-relevant fields from remote config.
+ * Used by [ObservabilityConfigEvaluator] to diff old vs new config.
+ */
+internal data class ObservabilityConfig(
+ val isEnabled: Boolean,
+ val logLevel: LogLevel?,
+) {
+ companion object {
+ val DISABLED = ObservabilityConfig(isEnabled = false, logLevel = null)
+ }
+}
+
+/**
+ * Describes what the [LoggerLifecycleManager] should do after a config change.
+ */
+internal sealed class ObservabilityConfigAction {
+ /** Nothing changed that affects observability features. */
+ object NoChange : ObservabilityConfigAction()
+
+ /** Observability features should be started at the given [logLevel]. */
+ data class Enable(val logLevel: LogLevel) : ObservabilityConfigAction()
+
+ /** The remote log level changed while features remain enabled. */
+ data class UpdateLogLevel(val oldLevel: LogLevel, val newLevel: LogLevel) : ObservabilityConfigAction()
+
+ /** Observability features should be stopped/torn down. */
+ object Disable : ObservabilityConfigAction()
+}
+
+/**
+ * Pure, side-effect-free evaluator that compares old and new [ObservabilityConfig]
+ * and returns the [ObservabilityConfigAction] the lifecycle manager should execute.
+ */
+internal object ObservabilityConfigEvaluator {
+ /**
+ * @param old the previous config snapshot, or null on first evaluation (cold start).
+ * @param new the freshly-arrived config snapshot.
+ */
+ fun evaluate(old: ObservabilityConfig?, new: ObservabilityConfig): ObservabilityConfigAction {
+ val wasEnabled = old?.isEnabled == true
+ val isNowEnabled = new.isEnabled
+
+ return when {
+ // Transition: off -> on
+ !wasEnabled && isNowEnabled -> {
+ val level = new.logLevel ?: LogLevel.ERROR
+ ObservabilityConfigAction.Enable(level)
+ }
+ // Transition: on -> off
+ wasEnabled && !isNowEnabled -> ObservabilityConfigAction.Disable
+ // Stays enabled but log level changed
+ wasEnabled && isNowEnabled && old?.logLevel != new.logLevel -> {
+ val oldLevel = old?.logLevel ?: LogLevel.ERROR
+ val newLevel = new.logLevel ?: LogLevel.ERROR
+ ObservabilityConfigAction.UpdateLogLevel(oldLevel, newLevel)
+ }
+ // Everything else: no meaningful change
+ else -> ObservabilityConfigAction.NoChange
+ }
+ }
+}
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 d9581c9ccb..77af9a91a6 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
@@ -28,7 +28,7 @@ import com.onesignal.debug.IDebugManager
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.DebugManager
import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.debug.internal.logging.otel.android.getOtelCrashStoragePath
+import com.onesignal.debug.internal.logging.logger.android.getCrashStoragePath
import com.onesignal.inAppMessages.IInAppMessagesManager
import com.onesignal.location.ILocationManager
import com.onesignal.notifications.INotificationsManager
@@ -237,20 +237,15 @@ internal class OneSignalImp : IOneSignal,
}
private fun initEssentials(context: Context) {
- // OtelLifecycleManager comes up early so crash handling and remote logging can capture
+ // LoggerLifecycleManager comes up early so crash handling and remote logging can capture
// anything that happens during the rest of init. FeatureManager is wired in via a
// lazy supplier — `enabledFeatureFlags` is read per-event, so resolving the manager
// can be deferred until services have bootstrapped.
val featureManagerProvider = { services.getService() }
- val useLoggerModule =
- com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.useLoggerModule(context)
observabilityManager =
- if (useLoggerModule) {
- LoggerLifecycleManager(context = context, featureManagerProvider = featureManagerProvider)
- } else {
- OtelLifecycleManager(context = context, featureManagerProvider = featureManagerProvider)
- }.also { it.initializeFromCachedConfig() }
- logStartupDiagnostics(context, useLoggerModule)
+ LoggerLifecycleManager(context = context, featureManagerProvider = featureManagerProvider)
+ .also { it.initializeFromCachedConfig() }
+ logStartupDiagnostics(context)
PreferenceStoreFix.ensureNoObfuscatedPrefStore(context)
@@ -259,16 +254,13 @@ internal class OneSignalImp : IOneSignal,
/**
* One concise WARN line at init with the build/runtime facts most useful for
- * release triage from a raw log capture: SDK version, which observability module
- * is active (and the flag driving it), the shared KMP module version when the
- * logger is active, OS/API, device, host app + version, and the crash storage dir.
+ * release triage from a raw log capture: SDK version, the shared KMP module version,
+ * OS/API, device, host app + version, and the crash storage dir.
* Best-effort — never lets diagnostics interfere with init.
*/
@Suppress("TooGenericExceptionCaught", "SwallowedException")
- internal fun logStartupDiagnostics(context: Context, useLoggerModule: Boolean) {
+ internal fun logStartupDiagnostics(context: Context) {
try {
- val module = if (useLoggerModule) "logger" else "otel"
- val kmpVersion = if (useLoggerModule) com.onesignal.logger.LoggerBuildInfo.KMP_VERSION else "n/a"
val appVersion =
try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
@@ -277,12 +269,12 @@ internal class OneSignalImp : IOneSignal,
}
Logging.warn(
"OneSignal init: sdkVersion=${OneSignalUtils.sdkVersion} " +
- "observabilityModule=$module (SDK_CUSTOM_LOGGING=$useLoggerModule) " +
- "kmpVersion=$kmpVersion " +
+ "observabilityModule=logger " +
+ "kmpVersion=${com.onesignal.logger.LoggerBuildInfo.KMP_VERSION} " +
"os=Android/${Build.VERSION.RELEASE}(API ${Build.VERSION.SDK_INT}) " +
"device=${Build.MANUFACTURER}/${Build.MODEL} " +
"app=${context.packageName}@$appVersion " +
- "crashDir=${getOtelCrashStoragePath(context)}",
+ "crashDir=${getCrashStoragePath(context)}",
)
} catch (t: Throwable) {
Logging.warn("OneSignal init: startup diagnostics failed: ${t.message}", t)
@@ -424,7 +416,7 @@ internal class OneSignalImp : IOneSignal,
val startupService = bootstrapServices()
- // Now that the IoC container is ready, subscribe the Otel lifecycle
+ // Now that the IoC container is ready, subscribe the observability lifecycle
// manager to config store events so it reacts to fresh remote config.
observabilityManager?.subscribeToConfigStore(services.getService())
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt
deleted file mode 100644
index ea8b862ae5..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.onesignal.internal
-
-import com.onesignal.debug.LogLevel
-
-/**
- * Snapshot of the Otel-relevant fields from remote config.
- * Used by [OtelConfigEvaluator] to diff old vs new config.
- */
-internal data class OtelConfig(
- val isEnabled: Boolean,
- val logLevel: LogLevel?,
-) {
- companion object {
- val DISABLED = OtelConfig(isEnabled = false, logLevel = null)
- }
-}
-
-/**
- * Describes what the [OtelLifecycleManager] should do after a config change.
- */
-internal sealed class OtelConfigAction {
- /** Nothing changed that affects Otel features. */
- object NoChange : OtelConfigAction()
-
- /** Otel features should be started at the given [logLevel]. */
- data class Enable(val logLevel: LogLevel) : OtelConfigAction()
-
- /** The remote log level changed while features remain enabled. */
- data class UpdateLogLevel(val oldLevel: LogLevel, val newLevel: LogLevel) : OtelConfigAction()
-
- /** Otel features should be stopped/torn down. */
- object Disable : OtelConfigAction()
-}
-
-/**
- * Pure, side-effect-free evaluator that compares old and new [OtelConfig]
- * and returns the [OtelConfigAction] the lifecycle manager should execute.
- *
- * Designed to be fully unit-testable without mocks.
- */
-internal object OtelConfigEvaluator {
- /**
- * @param old the previous config snapshot, or null on first evaluation (cold start).
- * @param new the freshly-arrived config snapshot.
- */
- fun evaluate(old: OtelConfig?, new: OtelConfig): OtelConfigAction {
- val wasEnabled = old?.isEnabled == true
- val isNowEnabled = new.isEnabled
-
- return when {
- // Transition: off -> on
- !wasEnabled && isNowEnabled -> {
- val level = new.logLevel ?: LogLevel.ERROR
- OtelConfigAction.Enable(level)
- }
- // Transition: on -> off
- wasEnabled && !isNowEnabled -> OtelConfigAction.Disable
- // Stays enabled but log level changed
- wasEnabled && isNowEnabled && old?.logLevel != new.logLevel -> {
- val oldLevel = old?.logLevel ?: LogLevel.ERROR
- val newLevel = new.logLevel ?: LogLevel.ERROR
- OtelConfigAction.UpdateLogLevel(oldLevel, newLevel)
- }
- // Everything else: no meaningful change
- else -> OtelConfigAction.NoChange
- }
- }
-}
diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt
deleted file mode 100644
index a9de086d03..0000000000
--- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt
+++ /dev/null
@@ -1,244 +0,0 @@
-package com.onesignal.internal
-
-import android.content.Context
-import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
-import com.onesignal.common.modeling.ModelChangeTags
-import com.onesignal.common.modeling.ModelChangedArgs
-import com.onesignal.core.internal.config.ConfigModel
-import com.onesignal.core.internal.config.ConfigModelStore
-import com.onesignal.core.internal.features.IFeatureManager
-import com.onesignal.debug.LogLevel
-import com.onesignal.debug.internal.crash.AnrConstants
-import com.onesignal.debug.internal.crash.OneSignalCrashHandlerFactory
-import com.onesignal.debug.internal.crash.OtelSdkSupport
-import com.onesignal.debug.internal.crash.createAnrDetector
-import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger
-import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider
-import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider
-import com.onesignal.otel.IOtelCrashHandler
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.IOtelOpenTelemetryRemote
-import com.onesignal.otel.IOtelPlatformProvider
-import com.onesignal.otel.OtelFactory
-import com.onesignal.otel.crash.IOtelAnrDetector
-
-/**
- * Owns the lifecycle of all Otel-based observability features and reacts
- * to remote config changes so features can be enabled, disabled, or
- * have their log level updated mid-session.
- *
- * Subscribes to [ConfigModelStore] via [ISingletonModelStoreChangeHandler]
- * so that when fresh remote config arrives (HYDRATE), Otel features are
- * automatically started, stopped, or updated.
- *
- * Thread safety: methods are synchronized on [lock] so that concurrent
- * calls from initEssentials (main) and the config store callback (IO) are safe.
- *
- * Production callers construct as
- * `OtelLifecycleManager(context, featureManagerProvider = { services.getService() })`.
- * The supplier is invoked lazily (per-event), so it can be passed even when service bootstrap
- * has not yet completed at construction time. All other factory parameters default to the real
- * implementations; tests can override any of them to inject mocks or throwing stubs.
- */
-@Suppress("TooManyFunctions")
-internal class OtelLifecycleManager(
- private val context: Context,
- private val featureManagerProvider: () -> IFeatureManager,
- private val crashHandlerFactory: (Context, IOtelLogger, () -> IFeatureManager) -> IOtelCrashHandler =
- { ctx, log, fm -> OneSignalCrashHandlerFactory.createCrashHandler(ctx, log, fm) },
- private val anrDetectorFactory: (IOtelPlatformProvider, IOtelLogger, Long, Long) -> IOtelAnrDetector =
- { pp, log, threshold, interval -> createAnrDetector(pp, log, threshold, interval) },
- private val remoteTelemetryFactory: (IOtelPlatformProvider) -> IOtelOpenTelemetryRemote =
- { pp -> OtelFactory.createRemoteTelemetry(pp) },
- private val platformProviderFactory: (Context, () -> IFeatureManager) -> OtelPlatformProvider =
- { ctx, fm -> createAndroidOtelPlatformProvider(ctx, fm) },
- private val loggerFactory: () -> IOtelLogger = { AndroidOtelLogger() },
-) : ISingletonModelStoreChangeHandler, IObservabilityLifecycleManager {
- private val lock = Any()
-
- private val platformProvider: OtelPlatformProvider by lazy {
- platformProviderFactory(context, featureManagerProvider)
- }
-
- private val logger: IOtelLogger by lazy { loggerFactory() }
-
- private var crashHandler: IOtelCrashHandler? = null
- private var anrDetector: IOtelAnrDetector? = null
- private var remoteTelemetry: IOtelOpenTelemetryRemote? = null
- private var currentConfig: OtelConfig? = null
-
- /**
- * Called once from [OneSignalImp.initEssentials] at cold start.
- * Reads the cached config from SharedPreferences and boots
- * whichever features are already enabled.
- */
- @Suppress("TooGenericExceptionCaught")
- override fun initializeFromCachedConfig() {
- if (!OtelSdkSupport.isSupported) {
- Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, Otel not supported — skipping all Otel features")
- return
- }
-
- try {
- val cachedConfig = readCurrentCachedConfig()
- synchronized(lock) {
- val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = cachedConfig)
- applyAction(action, cachedConfig)
- }
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Failed to initialize Otel from cached config: ${t.message}", t)
- }
- }
-
- /**
- * Subscribes this manager to config store change events.
- * Call after the IoC container is bootstrapped (i.e. after [bootstrapServices]).
- */
- override fun subscribeToConfigStore(configModelStore: ConfigModelStore) {
- configModelStore.subscribe(this)
- }
-
- // ------------------------------------------------------------------
- // ISingletonModelStoreChangeHandler
- // ------------------------------------------------------------------
-
- @Suppress("TooGenericExceptionCaught")
- override fun onModelReplaced(model: ConfigModel, tag: String) {
- if (tag != ModelChangeTags.HYDRATE) return
- if (!OtelSdkSupport.isSupported) return
-
- try {
- val logLevel = model.remoteLoggingParams.logLevel
- val isEnabled = model.remoteLoggingParams.isEnabled
- val newConfig = OtelConfig(isEnabled = isEnabled, logLevel = logLevel)
- synchronized(lock) {
- val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = newConfig)
- applyAction(action, newConfig)
- }
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Failed to refresh Otel from remote config: ${t.message}", t)
- }
- }
-
- override fun onModelUpdated(args: ModelChangedArgs, tag: String) {
- // We only care about full model replacements (HYDRATE), not individual property changes.
- }
-
- // ------------------------------------------------------------------
- // Internal
- // ------------------------------------------------------------------
-
- private fun readCurrentCachedConfig(): OtelConfig {
- val enabled = platformProvider.isRemoteLoggingEnabled
- val level = LogLevel.fromString(platformProvider.remoteLogLevel)
- return OtelConfig(isEnabled = enabled, logLevel = level)
- }
-
- /** Must be called while holding [lock]. */
- @Suppress("TooGenericExceptionCaught")
- private fun applyAction(action: OtelConfigAction, newConfig: OtelConfig) {
- when (action) {
- is OtelConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR)
- is OtelConfigAction.Disable -> disableFeatures()
- is OtelConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel)
- is OtelConfigAction.NoChange -> {
- Logging.debug("OneSignal: Otel config unchanged, no action needed")
- }
- }
- currentConfig = newConfig
- }
-
- @Suppress("TooGenericExceptionCaught")
- private fun enableFeatures(logLevel: LogLevel) {
- Logging.info("OneSignal: Enabling Otel features at level $logLevel")
-
- try {
- startCrashHandler()
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Failed to start crash handler: ${t.message}", t)
- }
-
- try {
- startAnrDetector()
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Failed to start ANR detector: ${t.message}", t)
- }
-
- try {
- startOtelLogging(logLevel)
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Failed to start Otel logging: ${t.message}", t)
- }
- }
-
- @Suppress("TooGenericExceptionCaught")
- private fun disableFeatures() {
- Logging.info("OneSignal: Disabling Otel features")
-
- try {
- anrDetector?.stop()
- anrDetector = null
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Error stopping ANR detector: ${t.message}", t)
- }
-
- try {
- crashHandler?.unregister()
- crashHandler = null
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Error unregistering crash handler: ${t.message}", t)
- }
-
- try {
- Logging.setOtelTelemetry(null, { false })
- remoteTelemetry?.shutdown()
- remoteTelemetry = null
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Error disabling Otel logging: ${t.message}", t)
- }
- }
-
- @Suppress("TooGenericExceptionCaught")
- private fun updateLogLevel(newLevel: LogLevel) {
- Logging.info("OneSignal: Updating Otel log level to $newLevel")
- try {
- startOtelLogging(newLevel)
- } catch (t: Throwable) {
- Logging.warn("OneSignal: Failed to update Otel log level: ${t.message}", t)
- }
- }
-
- private fun startCrashHandler() {
- if (crashHandler != null) return
- val handler = crashHandlerFactory(context, logger, featureManagerProvider)
- handler.initialize()
- crashHandler = handler
- Logging.info("OneSignal: Crash handler initialized — logs at: ${platformProvider.crashStoragePath}")
- }
-
- private fun startAnrDetector() {
- if (anrDetector != null) return
- val detector = anrDetectorFactory(
- platformProvider,
- logger,
- AnrConstants.DEFAULT_ANR_THRESHOLD_MS,
- AnrConstants.DEFAULT_CHECK_INTERVAL_MS,
- )
- detector.start()
- anrDetector = detector
- Logging.info("OneSignal: ANR detector started")
- }
-
- @Suppress("TooGenericExceptionCaught")
- private fun startOtelLogging(logLevel: LogLevel) {
- remoteTelemetry?.shutdown()
- val telemetry = remoteTelemetryFactory(platformProvider)
- remoteTelemetry = telemetry
- val shouldSend: (LogLevel) -> Boolean = { level ->
- logLevel != LogLevel.NONE && level <= logLevel
- }
- Logging.setOtelTelemetry(telemetry, shouldSend)
- Logging.info("OneSignal: Otel logging active at level $logLevel")
- }
-}
diff --git a/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml
index 04ba2503d0..8038730f2c 100644
--- a/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml
+++ b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml
@@ -3,5 +3,5 @@
xmlns:tools="http://schemas.android.com/tools">
+ tools:overrideLibrary="com.onesignal.logger" />
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/AnrCheckEvaluatorTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/AnrCheckEvaluatorTest.kt
index 7bbd3662d3..0a1e056166 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/AnrCheckEvaluatorTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/AnrCheckEvaluatorTest.kt
@@ -5,11 +5,10 @@ import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
/**
- * Pure-JVM tests for the ANR decision core. These run without Robolectric so the logic is exercised
- * on a real JVM (and therefore counted by coverage), unlike the Android shell in [OtelAnrDetector].
+ * Pure-JVM tests for the ANR decision core; no Robolectric needed.
*
- * Defaults mirror AnrConstants: 5s foreground ANR, 2s check interval, 2s frozen slack, 10s background
- * warning, 30s dedup window.
+ * Fixtures mirror AnrConstants: 5s foreground ANR, 2s check interval, 2s frozen slack, 10s
+ * background warning, 30s dedup window.
*/
class AnrCheckEvaluatorTest : FunSpec({
@@ -201,4 +200,68 @@ class AnrCheckEvaluatorTest : FunSpec({
test("buildBlockFingerprint handles an empty stack") {
buildBlockFingerprint(emptyArray()) shouldBe "top=unknown|onesignal=none"
}
+
+ // ===== record formatting =====
+ //
+ // ANR records must serialize to the same canonical layout Throwable.stackTraceToString()
+ // produces, or consumers that parse `exception.stacktrace` (frame extraction, grouping,
+ // symbolication) silently stop matching ANRs only.
+
+ val blockedStack = arrayOf(
+ StackTraceElement("android.os.MessageQueue", "nativePollOnce", "MessageQueue.java", 1),
+ StackTraceElement("com.onesignal.core.Foo", "bar", "Foo.kt", 42),
+ )
+ val nl = System.lineSeparator()
+
+ test("formatJvmStacktrace matches what the JVM itself produces for a real throwable") {
+ val throwable = IllegalStateException("boom")
+
+ formatJvmStacktrace(
+ throwable::class.java.name,
+ throwable.message.orEmpty(),
+ throwable.stackTrace,
+ ) shouldBe throwable.stackTraceToString()
+ }
+
+ test("formatJvmStacktrace omits the colon when there is no message") {
+ formatJvmStacktrace("SomeException", "", emptyArray()) shouldBe "SomeException$nl"
+ }
+
+ test("buildAnrCrashData emits a canonical header and tab-at prefixed frames") {
+ val crash = buildAnrCrashData("main", blockedStack, 6_000L)
+
+ crash.threadName shouldBe "main"
+ crash.exceptionType shouldBe "ApplicationNotRespondingException"
+ crash.stacktrace shouldBe
+ "ApplicationNotRespondingException: Application Not Responding: Main thread blocked for 6000ms$nl" +
+ "\tat android.os.MessageQueue.nativePollOnce(MessageQueue.java:1)$nl" +
+ "\tat com.onesignal.core.Foo.bar(Foo.kt:42)$nl"
+ }
+
+ test("buildBackgroundBlockCrashData emits a canonical header and tab-at prefixed frames") {
+ val crash = buildBackgroundBlockCrashData("main", blockedStack, 11_000L)
+
+ crash.threadName shouldBe "main"
+ crash.exceptionType shouldBe "BackgroundMainThreadBlockException"
+ crash.exceptionMessage shouldBe
+ "Background main-thread block for 11000ms | " +
+ "top=android.os.MessageQueue.nativePollOnce(MessageQueue.java:1)|" +
+ "onesignal=com.onesignal.core.Foo.bar(Foo.kt:42)"
+ crash.stacktrace shouldBe
+ "BackgroundMainThreadBlockException: ${crash.exceptionMessage}$nl" +
+ "\tat android.os.MessageQueue.nativePollOnce(MessageQueue.java:1)$nl" +
+ "\tat com.onesignal.core.Foo.bar(Foo.kt:42)$nl"
+ }
+
+ test("every ANR record frame line is parseable by a `^\\s*at ` consumer") {
+ val fatal = buildAnrCrashData("main", blockedStack, 6_000L)
+ val nonFatal = buildBackgroundBlockCrashData("main", blockedStack, 11_000L)
+ val frameRegex = Regex("^\\s*at .+")
+
+ listOf(fatal, nonFatal).forEach { crash ->
+ val lines = crash.stacktrace.trimEnd().lines()
+ lines.first() shouldBe "${crash.exceptionType}: ${crash.exceptionMessage}"
+ lines.drop(1).all { frameRegex.matches(it) } shouldBe true
+ }
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/ObservabilitySdkSupportTest.kt
similarity index 50%
rename from OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt
rename to OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/ObservabilitySdkSupportTest.kt
index f7660108e8..9c273047c0 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelSdkSupportTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/ObservabilitySdkSupportTest.kt
@@ -8,31 +8,31 @@ import org.robolectric.annotation.Config
@RobolectricTest
@Config(sdk = [Build.VERSION_CODES.O])
-class OtelSdkSupportTest : FunSpec({
+class ObservabilitySdkSupportTest : FunSpec({
afterEach {
- OtelSdkSupport.reset()
+ ObservabilitySdkSupport.reset()
}
test("isSupported is true on SDK >= 26") {
- OtelSdkSupport.reset()
- OtelSdkSupport.isSupported shouldBe true
+ ObservabilitySdkSupport.reset()
+ ObservabilitySdkSupport.isSupported shouldBe true
}
test("isSupported can be overridden to false for testing") {
- OtelSdkSupport.isSupported = false
- OtelSdkSupport.isSupported shouldBe false
+ ObservabilitySdkSupport.isSupported = false
+ ObservabilitySdkSupport.isSupported shouldBe false
}
test("reset restores runtime-detected value") {
- OtelSdkSupport.isSupported = false
- OtelSdkSupport.isSupported shouldBe false
+ ObservabilitySdkSupport.isSupported = false
+ ObservabilitySdkSupport.isSupported shouldBe false
- OtelSdkSupport.reset()
- OtelSdkSupport.isSupported shouldBe true
+ ObservabilitySdkSupport.reset()
+ ObservabilitySdkSupport.isSupported shouldBe true
}
test("MIN_SDK_VERSION is 26") {
- OtelSdkSupport.MIN_SDK_VERSION shouldBe 26
+ ObservabilitySdkSupport.MIN_SDK_VERSION shouldBe 26
}
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt
deleted file mode 100644
index ba79a8854f..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-package com.onesignal.debug.internal.crash
-
-import android.content.Context
-import android.os.Build
-import androidx.test.core.app.ApplicationProvider
-import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
-import com.onesignal.core.internal.features.IFeatureManager
-import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger
-import com.onesignal.otel.IOtelCrashHandler
-import com.onesignal.otel.IOtelLogger
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldNotBe
-import io.kotest.matchers.types.shouldBeInstanceOf
-import io.mockk.every
-import io.mockk.mockk
-import org.robolectric.annotation.Config
-
-@RobolectricTest
-@Config(sdk = [Build.VERSION_CODES.O])
-class OneSignalCrashHandlerFactoryTest : FunSpec({
- lateinit var appContext: Context
- lateinit var logger: AndroidOtelLogger
- lateinit var featureManager: IFeatureManager
- // Save original handler to restore after tests
- val originalHandler: Thread.UncaughtExceptionHandler? = Thread.getDefaultUncaughtExceptionHandler()
-
- beforeAny {
- appContext = ApplicationProvider.getApplicationContext()
- logger = AndroidOtelLogger()
- featureManager = mockk().also {
- every { it.enabledFeatureKeys() } returns emptyList()
- }
- }
-
- afterEach {
- // Restore original uncaught exception handler after each test
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("createCrashHandler should return IOtelCrashHandler") {
- val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) { featureManager }
-
- handler.shouldBeInstanceOf()
- }
-
- test("createCrashHandler should create handler that can be initialized") {
- val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) { featureManager }
-
- handler shouldNotBe null
- handler.initialize()
- }
-
- test("createCrashHandler should accept mock logger") {
- val mockLogger = mockk(relaxed = true)
-
- val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, mockLogger) { featureManager }
-
- handler shouldNotBe null
- handler.shouldBeInstanceOf()
- }
-
- test("handler should be idempotent when initialized multiple times") {
- val handler = OneSignalCrashHandlerFactory.createCrashHandler(appContext, logger) { featureManager }
-
- handler.initialize()
- handler.initialize() // Should not throw
-
- handler shouldNotBe null
- }
-
- test("createCrashHandler should work with different contexts") {
- val context1: Context = ApplicationProvider.getApplicationContext()
- val context2: Context = ApplicationProvider.getApplicationContext()
-
- val handler1 = OneSignalCrashHandlerFactory.createCrashHandler(context1, logger) { featureManager }
- val handler2 = OneSignalCrashHandlerFactory.createCrashHandler(context2, logger) { featureManager }
-
- handler1 shouldNotBe null
- handler2 shouldNotBe null
- }
-})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt
index 3db8c5ce99..7eb75701e8 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapperTest.kt
@@ -11,7 +11,10 @@ import com.onesignal.core.internal.features.IFeatureManager
import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
import com.onesignal.core.internal.preferences.PreferenceStores
import com.onesignal.core.internal.startup.IStartableService
+import com.onesignal.debug.internal.logging.logger.android.getCrashStoragePath
+import io.kotest.assertions.nondeterministic.eventually
import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
@@ -20,6 +23,8 @@ import kotlinx.coroutines.runBlocking
import org.json.JSONArray
import org.json.JSONObject
import org.robolectric.annotation.Config
+import java.io.File
+import kotlin.time.Duration.Companion.seconds
import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace
@RobolectricTest
@@ -55,7 +60,6 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({
}
test("start should complete without error when remote logging is disabled") {
- // Configure remote logging as disabled (NONE)
val remoteLoggingParams = JSONObject().put("logLevel", "NONE")
val configModel = JSONObject().put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
sharedPreferences.edit()
@@ -67,12 +71,10 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({
val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager())
- // Should return early without error when remote logging is disabled
runBlocking { wrapper.start() }
}
test("start should complete without error when no crash reports exist") {
- // Configure remote logging as enabled
val remoteLoggingParams = JSONObject().put("logLevel", "ERROR")
val configModel = JSONObject().put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
sharedPreferences.edit()
@@ -84,7 +86,6 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({
val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager())
- // Should complete without error even when no crash reports exist
runBlocking { wrapper.start() }
}
@@ -94,7 +95,6 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({
val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager())
- // Multiple calls should not throw
runBlocking {
wrapper.start()
wrapper.start()
@@ -109,4 +109,30 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({
wrapper shouldNotBe null
}
+
+ // Covers the first launch after upgrade: there is no cached config yet, so unreadable
+ // inherited records must be reclaimed without an upload pass rather than lingering.
+ test("start reclaims records left in the crash dir by a pre-upgrade otel session") {
+ val crashDir = File(getCrashStoragePath(appContext)).apply { mkdirs() }
+ // Pre-upgrade sessions wrote bare-millis filenames; the logger owns `.otlp` only.
+ val legacyRecord = File(crashDir, "1784621689841").apply {
+ writeBytes("legacy".toByteArray())
+ setLastModified(System.currentTimeMillis() - 60_000L)
+ }
+ val ownedRecord = File(crashDir, "1784621689841-abc.otlp").apply {
+ writeBytes("owned".toByteArray())
+ setLastModified(System.currentTimeMillis() - 60_000L)
+ }
+
+ val mockApplicationService = mockk(relaxed = true)
+ every { mockApplicationService.appContext } returns appContext
+
+ val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager())
+ runBlocking { wrapper.start() }
+
+ eventually(10.seconds) { legacyRecord.exists() shouldBe false }
+ // A pending logger-owned record is never collateral damage.
+ ownedRecord.exists() shouldBe true
+ crashDir.deleteRecursively()
+ }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt
deleted file mode 100644
index 41bd0d51f1..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt
+++ /dev/null
@@ -1,262 +0,0 @@
-package com.onesignal.debug.internal.crash
-
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.IOtelOpenTelemetryCrash
-import com.onesignal.otel.crash.IOtelAnrDetector
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.types.shouldBeInstanceOf
-import io.mockk.mockk
-import io.mockk.verify
-
-/**
- * Pure-JVM tests for the Android ANR watchdog shell. The Android touch points (main-thread Handler,
- * main-thread stack capture) are injected via [AnrWatchdogPlatform], and the monotonic clock via a
- * fake, so the whole watchdog runs off-device without Robolectric — which also means JaCoCo actually
- * measures this code. Pure decision logic is covered separately in [AnrCheckEvaluatorTest].
- */
-class OtelAnrDetectorTest : FunSpec({
-
- val oneSignalStack = arrayOf(
- StackTraceElement("android.os.MessageQueue", "nativePollOnce", "MessageQueue.java", 1),
- StackTraceElement("com.onesignal.core.Foo", "bar", "Foo.kt", 42),
- )
- val nonOneSignalStack = arrayOf(
- StackTraceElement("android.os.MessageQueue", "nativePollOnce", "MessageQueue.java", 1),
- StackTraceElement("com.example.App", "onCreate", "App.kt", 7),
- )
-
- class FakeClock(var nowMs: Long = 1_000L) {
- fun advance(ms: Long) { nowMs += ms }
- }
-
- class FakePlatform(
- var stack: Array,
- private val clock: FakeClock,
- private val runPostsSynchronously: Boolean = true,
- ) : AnrWatchdogPlatform {
- override fun postToMainThread(runnable: Runnable) {
- // Simulate a responsive main thread that immediately runs the heartbeat.
- if (runPostsSynchronously) runnable.run()
- }
-
- override fun removeFromMainThread(runnable: Runnable) = Unit
-
- override fun mainThread(): Thread = Thread.currentThread()
-
- override fun mainThreadStackTrace(): Array = stack
-
- override fun now(): Long = clock.nowMs
- }
-
- fun buildDetector(
- clock: FakeClock,
- logger: IOtelLogger,
- platform: FakePlatform,
- inForeground: () -> Boolean = { true },
- checkIntervalMs: Long = 2_000L,
- ): OtelAnrDetector = OtelAnrDetector(
- mockk(relaxed = true),
- logger,
- anrThresholdMs = 5_000L,
- checkIntervalMs = checkIntervalMs,
- backgroundThresholdMs = 10_000L,
- isAppInForeground = inForeground,
- platform = platform,
- )
-
- test("OtelAnrDetector implements IOtelAnrDetector") {
- val clock = FakeClock()
- val detector = buildDetector(clock, mockk(relaxed = true), FakePlatform(oneSignalStack, clock))
- detector.shouldBeInstanceOf()
- }
-
- // ===== evaluateCheck: decision -> side effect wiring =====
-
- test("a fresh heartbeat keeps a foreground check responsive (no ANR)") {
- val clock = FakeClock(5_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock))
-
- detector.recordHeartbeat()
- clock.advance(4_000L)
- detector.evaluateCheck(actualSleepMs = 2_000L)
-
- verify(exactly = 0) { logger.info(match { it.contains("Main thread unresponsive") }) }
- }
-
- test("a stale heartbeat past the threshold reports and saves a foreground ANR") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock))
-
- detector.recordHeartbeat()
- clock.advance(6_000L)
- detector.evaluateCheck(actualSleepMs = 2_000L)
-
- verify(exactly = 1) { logger.info(match { it.contains("Main thread unresponsive") && it.contains("foreground") }) }
- verify { logger.info(match { it.contains("ANR report saved successfully") }) }
- }
-
- test("a foreground ANR whose stack is not OneSignal-related is not reported") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(nonOneSignalStack, clock))
-
- detector.recordHeartbeat()
- clock.advance(6_000L)
- detector.evaluateCheck(actualSleepMs = 2_000L)
-
- verify { logger.debug(match { it.contains("not OneSignal-related") }) }
- verify(exactly = 0) { logger.info(match { it.contains("ANR report saved successfully") }) }
- }
-
- test("recovering with a heartbeat returns the detector to responsive") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock))
-
- detector.recordHeartbeat()
- clock.advance(6_000L)
- detector.evaluateCheck(actualSleepMs = 2_000L) // fires once
-
- clock.advance(1_000L)
- detector.recordHeartbeat() // main thread recovers
- detector.evaluateCheck(actualSleepMs = 2_000L) // responsive again
-
- verify(exactly = 1) { logger.info(match { it.contains("Main thread unresponsive") }) }
- }
-
- test("a background block past the background threshold records a warning, not an ANR") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock), inForeground = { false })
-
- detector.recordHeartbeat()
- clock.advance(11_000L)
- detector.evaluateCheck(actualSleepMs = 2_000L)
-
- verify(exactly = 1) { logger.info(match { it.contains("backgrounded") && it.contains("warning") }) }
- verify { logger.info(match { it.contains("Background block warning recorded") }) }
- verify(exactly = 0) { logger.info(match { it.contains("Main thread unresponsive") }) }
- }
-
- test("a background block below the background threshold stays responsive") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock), inForeground = { false })
-
- detector.recordHeartbeat()
- clock.advance(7_000L) // would be a foreground ANR, but below the 10s background threshold
- detector.evaluateCheck(actualSleepMs = 2_000L)
-
- verify(exactly = 0) { logger.info(match { it.contains("Main thread unresponsive") }) }
- verify(exactly = 0) { logger.info(match { it.contains("backgrounded") }) }
- }
-
- test("a background block whose stack is not OneSignal-related is skipped") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(nonOneSignalStack, clock), inForeground = { false })
-
- detector.recordHeartbeat()
- clock.advance(11_000L)
- detector.evaluateCheck(actualSleepMs = 2_000L)
-
- verify { logger.debug(match { it.contains("Background block is not OneSignal-related") }) }
- verify(exactly = 0) { logger.info(match { it.contains("Background block warning recorded") }) }
- }
-
- test("a watchdog oversleep is reported as a frozen process and resets the baseline") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock))
-
- detector.recordHeartbeat()
- clock.advance(60_000L) // huge apparent block...
- detector.evaluateCheck(actualSleepMs = 30_000L) // ...but our own sleep overran => frozen
-
- verify(exactly = 1) { logger.debug(match { it.contains("frozen") }) }
- verify(exactly = 0) { logger.info(match { it.contains("Main thread unresponsive") }) }
-
- detector.evaluateCheck(actualSleepMs = 2_000L)
- verify(exactly = 0) { logger.info(match { it.contains("Main thread unresponsive") }) }
- }
-
- test("an ongoing block is reported only once within the dedup window") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock))
-
- detector.recordHeartbeat()
- clock.advance(6_000L)
- detector.evaluateCheck(actualSleepMs = 2_000L) // first report
-
- clock.advance(2_000L) // still blocked, within the 30s dedup window
- detector.evaluateCheck(actualSleepMs = 2_000L) // deduped
-
- verify(exactly = 1) { logger.info(match { it.contains("Main thread unresponsive") }) }
- verify { logger.debug(match { it.contains("already reported recently") }) }
- }
-
- test("an unknown app state is treated as foreground so a real ANR is never dropped") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock), inForeground = { error("no state") })
-
- detector.recordHeartbeat()
- clock.advance(6_000L)
- detector.evaluateCheck(actualSleepMs = 2_000L)
-
- verify { logger.debug(match { it.contains("Could not resolve app state") }) }
- verify(exactly = 1) { logger.info(match { it.contains("Main thread unresponsive") && it.contains("foreground") }) }
- }
-
- // ===== start / stop lifecycle (real watchdog thread, fake platform + clock) =====
-
- test("start then stop drives the watchdog loop and logs lifecycle") {
- val clock = FakeClock(1_000L)
- val logger = mockk(relaxed = true)
- // Small interval so the real watchdog thread iterates a few times before we stop it.
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock), checkIntervalMs = 20L)
-
- detector.start()
- Thread.sleep(120L) // let the watchdog run a handful of responsive checks
- detector.stop()
-
- verify { logger.info(match { it.contains("ANR detection started successfully") }) }
- verify { logger.info(match { it.contains("ANR detection stopped") }) }
- }
-
- test("start twice warns about already monitoring") {
- val clock = FakeClock()
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock), checkIntervalMs = 100_000L)
-
- detector.start()
- detector.start()
- verify { logger.warn(match { it.contains("Already monitoring") }) }
-
- detector.stop()
- }
-
- test("stop without start warns about not monitoring") {
- val clock = FakeClock()
- val logger = mockk(relaxed = true)
- val detector = buildDetector(clock, logger, FakePlatform(oneSignalStack, clock))
-
- detector.stop()
- verify { logger.warn(match { it.contains("Not monitoring") }) }
- }
-
- // ===== AnrConstants =====
-
- test("AnrConstants should have reasonable defaults") {
- AnrConstants.DEFAULT_ANR_THRESHOLD_MS shouldBe 5_000L
- AnrConstants.DEFAULT_CHECK_INTERVAL_MS shouldBe 2_000L
- AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS shouldBe 10_000L
- // The background threshold must stay above the foreground ANR threshold so backgrounded
- // blocks need to last longer before they are even recorded as a warning.
- (AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS > AnrConstants.DEFAULT_ANR_THRESHOLD_MS) shouldBe true
- }
-})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt
deleted file mode 100644
index 7213e85a6f..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt
+++ /dev/null
@@ -1,165 +0,0 @@
-package com.onesignal.debug.internal.crash
-
-import android.content.Context
-import android.content.SharedPreferences
-import android.os.Build
-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.IFeatureManager
-import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
-import com.onesignal.core.internal.preferences.PreferenceStores
-import com.onesignal.debug.internal.logging.otel.android.AndroidOtelLogger
-import com.onesignal.debug.internal.logging.otel.android.createAndroidOtelPlatformProvider
-import com.onesignal.otel.IOtelCrashHandler
-import com.onesignal.otel.IOtelPlatformProvider
-import com.onesignal.otel.OtelFactory
-import com.onesignal.user.internal.backend.IdentityConstants
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.shouldNotBe
-import io.kotest.matchers.types.shouldBeInstanceOf
-import io.mockk.every
-import io.mockk.mockk
-import kotlinx.coroutines.runBlocking
-import org.json.JSONArray
-import org.json.JSONObject
-import org.robolectric.annotation.Config
-import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace
-import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace
-
-// Helper extension for shouldBeOneOf
-private infix fun T.shouldBeOneOf(expected: List) {
- val isInList = expected.contains(this)
- if (!isInList) {
- throw AssertionError("Expected $this to be one of $expected")
- }
-}
-
-@RobolectricTest
-@Config(sdk = [Build.VERSION_CODES.O])
-class OtelIntegrationTest : FunSpec() {
- private lateinit var appContext: Context
- private lateinit var sharedPreferences: SharedPreferences
-
- init {
- beforeAny {
- if (!::appContext.isInitialized) {
- appContext = ApplicationProvider.getApplicationContext()
- sharedPreferences = appContext.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE)
- }
- }
-
- beforeEach {
- if (!::sharedPreferences.isInitialized) {
- appContext = ApplicationProvider.getApplicationContext()
- sharedPreferences = appContext.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE)
- }
- sharedPreferences.edit().clear().commit()
-
- val configModel = JSONObject().apply {
- put(ConfigModel::appId.name, "test-app-id")
- put(ConfigModel::pushSubscriptionId.name, "test-subscription-id")
- val remoteLoggingParams = JSONObject().apply {
- put("logLevel", "ERROR")
- }
- put(ConfigModel::remoteLoggingParams.name, remoteLoggingParams)
- }
- val configArray = JSONArray().apply {
- put(configModel)
- }
-
- val identityModel = JSONObject().apply {
- put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id")
- }
- val identityArray = JSONArray().apply {
- put(identityModel)
- }
-
- sharedPreferences.edit()
- .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
- .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString())
- .putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id")
- .commit()
- }
-
- afterEach {
- sharedPreferences.edit().clear().commit()
- }
-
- test("AndroidOtelPlatformProvider should provide correct Android values") {
- val provider = createAndroidOtelPlatformProvider(appContext) { mockk().also { every { it.enabledFeatureKeys() } returns emptyList() } }
-
- provider.shouldBeInstanceOf()
- provider.sdkBase shouldBe "android"
- provider.appPackageId shouldBe appContext.packageName
- provider.osName shouldBe "Android"
- provider.deviceManufacturer shouldBe Build.MANUFACTURER
- provider.deviceModel shouldBe Build.MODEL
- provider.osVersion shouldBe Build.VERSION.RELEASE
- provider.osBuildId shouldBe Build.ID
-
- runBlocking {
- provider.getInstallId() shouldNotBe null
- }
- }
-
- test("AndroidOtelPlatformProvider should provide per-event values") {
- val provider = createAndroidOtelPlatformProvider(appContext) { mockk().also { every { it.enabledFeatureKeys() } returns emptyList() } }
-
- provider.appId shouldBe "test-app-id"
- provider.onesignalId shouldBe "test-onesignal-id"
- provider.pushSubscriptionId shouldBe "test-subscription-id"
- provider.appState shouldBeOneOf listOf("foreground", "background", "unknown")
- (provider.processUptime > 0) shouldBe true
- provider.currentThreadName shouldBe Thread.currentThread().name
- }
-
- test("AndroidOtelLogger should delegate to Logging") {
- val logger = AndroidOtelLogger()
-
- logger.shouldBeInstanceOf()
- logger.debug("test")
- logger.info("test")
- logger.warn("test")
- logger.error("test")
- }
-
- test("OtelFactory should create crash handler with Android provider") {
- val provider = createAndroidOtelPlatformProvider(appContext) { mockk().also { every { it.enabledFeatureKeys() } returns emptyList() } }
- val logger = AndroidOtelLogger()
-
- val handler = OtelFactory.createCrashHandler(provider, logger)
-
- handler shouldNotBe null
- handler.shouldBeInstanceOf()
- handler.initialize()
- }
-
- test("OneSignalCrashHandlerFactory should create working crash handler") {
- val provider = createAndroidOtelPlatformProvider(appContext) { mockk().also { every { it.enabledFeatureKeys() } returns emptyList() } }
- val logger = AndroidOtelLogger()
- val handler = OtelFactory.createCrashHandler(provider, logger)
-
- handler shouldNotBe null
- handler.shouldBeInstanceOf()
- handler.initialize()
- }
-
- test("AndroidOtelPlatformProvider should provide crash storage path") {
- val provider = createAndroidOtelPlatformProvider(appContext) { mockk().also { every { it.enabledFeatureKeys() } returns emptyList() } }
-
- provider.crashStoragePath.contains("onesignal") shouldBe true
- provider.crashStoragePath.contains("otel") shouldBe true
- provider.crashStoragePath.contains("crashes") shouldBe true
- provider.minFileAgeForReadMillis shouldBe 5000L
- }
-
- test("AndroidOtelPlatformProvider should handle remote logging config") {
- val provider = createAndroidOtelPlatformProvider(appContext) { mockk().also { every { it.enabledFeatureKeys() } returns emptyList() } }
-
- provider.remoteLogLevel shouldBe "ERROR"
- provider.appIdForHeaders shouldBe "test-app-id"
- }
- }
-}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt
deleted file mode 100644
index 6bde1defb8..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt
+++ /dev/null
@@ -1,232 +0,0 @@
-package com.onesignal.debug.internal.logging
-
-import android.os.Build
-import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
-import com.onesignal.debug.LogLevel
-import com.onesignal.otel.IOtelOpenTelemetryRemote
-import io.kotest.core.spec.style.FunSpec
-import io.mockk.mockk
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.runBlocking
-import org.robolectric.annotation.Config
-
-@RobolectricTest
-@Config(sdk = [Build.VERSION_CODES.O])
-class LoggingOtelTest : FunSpec({
- val mockTelemetry = mockk(relaxed = true)
-
- beforeEach {
- // Reset Logging state
- Logging.setOtelTelemetry(null, { false })
-
- // Setup default mock behavior - relaxed mock automatically returns mocks for suspend functions
- // The return type (LogRecordBuilder) is handled by the relaxed mock, but we can't verify it
- // directly due to type visibility. We'll test behavior instead.
- }
-
- test("setOtelTelemetry should store telemetry and enabled check function") {
- // Given
- val shouldSend = { _: LogLevel -> true }
-
- // When
- Logging.setOtelTelemetry(mockTelemetry, shouldSend)
-
- // Then - verify it's set (we'll test it works by logging)
- Logging.info("test")
-
- // Wait for async logging
- runBlocking {
- delay(100)
- }
-
- // Then - verify it doesn't crash (integration test)
- // Note: We can't verify exact calls due to OpenTelemetry type visibility
- }
-
- test("logToOtel should work when remote logging is enabled") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true })
-
- // When
- Logging.info("test message")
-
- // Wait for async logging
- runBlocking {
- delay(100)
- }
-
- // Then - should not crash (integration test)
- // The actual Otel call is verified in otel module tests
- }
-
- test("logToOtel should NOT crash when remote logging is disabled") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> false })
-
- // When
- Logging.info("test message")
-
- // Wait for async logging
- runBlocking {
- delay(100)
- }
-
- // Then - should not crash
- }
-
- test("logToOtel should NOT crash when telemetry is null") {
- // Given
- Logging.setOtelTelemetry(null, { _: LogLevel -> true })
-
- // When
- Logging.info("test message")
-
- // Wait for async logging
- runBlocking {
- delay(100)
- }
-
- // Then - should not crash
- }
-
- test("logToOtel should handle all log levels without crashing") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true })
-
- // When
- Logging.verbose("verbose message")
- Logging.debug("debug message")
- Logging.info("info message")
- Logging.warn("warn message")
- Logging.error("error message")
- Logging.fatal("fatal message")
-
- // Wait for async logging
- runBlocking {
- delay(200)
- }
-
- // Then - should not crash for any level
- }
-
- test("logToOtel should NOT log NONE level") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true })
-
- // When
- Logging.log(LogLevel.NONE, "none message")
-
- // Wait for async logging
- runBlocking {
- delay(100)
- }
-
- // Then - should not crash, NONE level is skipped
- }
-
- test("logToOtel should handle exceptions in logs") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true })
- val exception = RuntimeException("test exception")
-
- // When
- Logging.error("error with exception", exception)
-
- // Wait for async logging
- runBlocking {
- delay(100)
- }
-
- // Then - should not crash, exception details are included
- }
-
- test("logToOtel should handle null exception message") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true })
- val exception = RuntimeException()
-
- // When
- Logging.error("error with null exception message", exception)
-
- // Wait for async logging
- runBlocking {
- delay(100)
- }
-
- // Then - should not crash
- }
-
- test("logToOtel should handle Otel errors gracefully") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true })
- // Note: We can't mock getLogger() to throw due to OpenTelemetry type visibility,
- // but the real implementation in Logging.logToOtel() handles errors gracefully
-
- // When
- Logging.info("test message")
-
- // Wait for async logging
- runBlocking {
- delay(100)
- }
-
- // Then - should not crash, error handling is tested in integration tests
- }
-
- test("logToOtel should use dynamic remote logging check") {
- // Given
- var isEnabled = false
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> isEnabled })
-
- // When - initially disabled
- Logging.info("message 1")
- runBlocking { delay(50) }
-
- // When - enable remote logging
- isEnabled = true
- Logging.info("message 2")
- runBlocking { delay(50) }
-
- // When - disable again
- isEnabled = false
- Logging.info("message 3")
- runBlocking { delay(50) }
-
- // Then - should not crash, dynamic check works
- }
-
- test("logToOtel should handle multiple rapid log calls") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true })
-
- // When - rapid fire logging
- repeat(10) {
- Logging.info("message $it")
- }
-
- // Wait for async logging
- runBlocking {
- delay(200)
- }
-
- // Then - should not crash
- }
-
- test("logToOtel should work with different message formats") {
- // Given
- Logging.setOtelTelemetry(mockTelemetry, { _: LogLevel -> true })
-
- // When
- Logging.info("simple message")
- Logging.info("message with numbers: 12345")
- Logging.info("message with special chars: !@#$%")
- Logging.info("message with unicode: 测试 🚀")
-
- // Wait for async logging
- runBlocking {
- delay(200)
- }
-
- // Then - should not crash
- }
-})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingRemoteTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingRemoteTest.kt
new file mode 100644
index 0000000000..4fee8f77a3
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingRemoteTest.kt
@@ -0,0 +1,146 @@
+package com.onesignal.debug.internal.logging
+
+import android.os.Build
+import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
+import com.onesignal.debug.LogLevel
+import com.onesignal.logger.ILogTelemetryRemote
+import com.onesignal.logger.LogRecord
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.robolectric.annotation.Config
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Covers the single remaining remote-logging sink. Emission is asynchronous, so each
+ * assertion waits on the shared scope before verifying.
+ */
+@RobolectricTest
+@Config(sdk = [Build.VERSION_CODES.O])
+class LoggingRemoteTest : FunSpec({
+ val originalLogLevel = Logging.logLevel
+
+ beforeEach {
+ Logging.logLevel = LogLevel.VERBOSE
+ Logging.setLoggerTelemetry(null) { false }
+ }
+
+ afterEach {
+ Logging.logLevel = originalLogLevel
+ Logging.setLoggerTelemetry(null) { false }
+ }
+
+ test("emits a record to the logger sink when the level is sendable") {
+ val telemetry = mockk(relaxed = true)
+ val record = slot()
+ val emitted = signalOn(telemetry)
+ Logging.setLoggerTelemetry(telemetry) { true }
+
+ Logging.error("boom")
+ awaitEmit(emitted)
+
+ coVerify { telemetry.emit(capture(record)) }
+ record.captured.body shouldBe "[${Thread.currentThread().name}] boom"
+ record.captured.attributes["log.level"] shouldBe "ERROR"
+ }
+
+ test("includes exception details when a throwable is supplied") {
+ val telemetry = mockk(relaxed = true)
+ val record = slot()
+ val emitted = signalOn(telemetry)
+ Logging.setLoggerTelemetry(telemetry) { true }
+
+ Logging.error("with cause", IllegalStateException("bad state"))
+ awaitEmit(emitted)
+
+ coVerify { telemetry.emit(capture(record)) }
+ record.captured.attributes["exception.type"] shouldBe "java.lang.IllegalStateException"
+ record.captured.attributes["exception.message"] shouldBe "bad state"
+ }
+
+ test("does not emit when the level check rejects the level") {
+ val telemetry = mockk(relaxed = true)
+ Logging.setLoggerTelemetry(telemetry) { level -> level <= LogLevel.ERROR }
+
+ Logging.info("filtered out")
+ runBlocking { delay(QUIET_WINDOW_MS) }
+
+ coVerify(exactly = 0) { telemetry.emit(any()) }
+ }
+
+ test("does not emit NONE level even when the check accepts everything") {
+ val telemetry = mockk(relaxed = true)
+ Logging.setLoggerTelemetry(telemetry) { true }
+
+ Logging.log(LogLevel.NONE, "should be dropped")
+ runBlocking { delay(QUIET_WINDOW_MS) }
+
+ coVerify(exactly = 0) { telemetry.emit(any()) }
+ }
+
+ test("clearing the telemetry stops emission") {
+ val telemetry = mockk(relaxed = true)
+ Logging.setLoggerTelemetry(telemetry) { true }
+ Logging.setLoggerTelemetry(null) { false }
+
+ Logging.error("after clear")
+ runBlocking { delay(QUIET_WINDOW_MS) }
+
+ coVerify(exactly = 0) { telemetry.emit(any()) }
+ }
+
+ test("a throwing sink does not propagate to the caller") {
+ val telemetry = mockk()
+ val emitted = CompletableDeferred()
+ coEvery { telemetry.emit(any()) } answers { emitted.complete(Unit); throw RuntimeException("sink down") }
+ Logging.setLoggerTelemetry(telemetry) { true }
+
+ Logging.error("survives a broken sink")
+ awaitEmit(emitted)
+
+ coVerify { telemetry.emit(any()) }
+ }
+
+ test("every severity is forwarded") {
+ val telemetry = mockk(relaxed = true)
+ val sixth = CompletableDeferred()
+ // Emission fans out across Dispatchers.Default, so the counter is touched concurrently.
+ val seen = AtomicInteger(0)
+ coEvery { telemetry.emit(any()) } answers { if (seen.incrementAndGet() == 6) sixth.complete(Unit); Unit }
+ Logging.setLoggerTelemetry(telemetry) { true }
+
+ Logging.verbose("v")
+ Logging.debug("d")
+ Logging.info("i")
+ Logging.warn("w")
+ Logging.error("e")
+ Logging.fatal("f")
+ awaitEmit(sixth)
+
+ coVerify(exactly = 6) { telemetry.emit(any()) }
+ }
+})
+
+/** Completes when the sink has been reached, so positive cases never race a fixed sleep. */
+private fun signalOn(telemetry: ILogTelemetryRemote): CompletableDeferred {
+ val emitted = CompletableDeferred()
+ coEvery { telemetry.emit(any()) } answers { emitted.complete(Unit); Unit }
+ return emitted
+}
+
+private fun awaitEmit(signal: CompletableDeferred) {
+ runBlocking { withTimeout(EMIT_TIMEOUT_MS) { signal.await() } }
+}
+
+/** Generous upper bound on a signal we expect; only a hang burns the full budget. */
+private const val EMIT_TIMEOUT_MS = 5_000L
+
+/** Settle window for the negative cases, where there is no event to wait on. */
+private const val QUIET_WINDOW_MS = 200L
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingTest.kt
index 92d8d69885..e7f10f0e78 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingTest.kt
@@ -5,15 +5,12 @@ import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
import com.onesignal.debug.ILogListener
import com.onesignal.debug.LogLevel
import com.onesignal.debug.OneSignalLogEvent
-import com.onesignal.otel.IOtelOpenTelemetryRemote
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.runBlocking
import org.robolectric.annotation.Config
@RobolectricTest
@@ -27,14 +24,14 @@ class LoggingTest : FunSpec({
// Reset Logging state
Logging.logLevel = LogLevel.WARN
Logging.visualLogLevel = LogLevel.NONE
- Logging.setOtelTelemetry(null) { false }
+ Logging.setLoggerTelemetry(null) { false }
}
afterEach {
// Restore original state
Logging.logLevel = originalLogLevel
Logging.visualLogLevel = originalVisualLogLevel
- Logging.setOtelTelemetry(null) { false }
+ Logging.setLoggerTelemetry(null) { false }
}
// ===== Log Level Tests =====
@@ -228,59 +225,6 @@ class LoggingTest : FunSpec({
Logging.removeListener(mockListener)
}
- // ===== Otel Integration Tests =====
-
- test("setOtelTelemetry should set telemetry instance") {
- // Given
- val mockTelemetry = mockk(relaxed = true)
-
- // When
- Logging.setOtelTelemetry(mockTelemetry) { true }
-
- // Then - no exception thrown
- }
-
- test("setOtelTelemetry with null should clear telemetry") {
- // Given
- val mockTelemetry = mockk(relaxed = true)
- Logging.setOtelTelemetry(mockTelemetry) { true }
-
- // When
- Logging.setOtelTelemetry(null) { false }
-
- // Then - no exception thrown
- }
-
- test("log with Otel configured should not throw") {
- // Given - Using relaxed mock that doesn't require OpenTelemetry classes
- val mockTelemetry = mockk(relaxed = true)
-
- Logging.setOtelTelemetry(mockTelemetry) { level -> level >= LogLevel.ERROR }
- Logging.logLevel = LogLevel.ERROR
-
- // When & Then - should not throw
- Logging.error("Test Otel error message")
- runBlocking { delay(100) }
- }
-
- test("log with Otel telemetry set to null should not throw") {
- // Given
- Logging.setOtelTelemetry(null) { true }
- Logging.logLevel = LogLevel.ERROR
-
- // When & Then - should not throw
- Logging.error("Test error - telemetry is null")
- }
-
- test("log with NONE level and Otel configured should not throw") {
- // Given
- val mockTelemetry = mockk(relaxed = true)
- Logging.setOtelTelemetry(mockTelemetry) { true }
-
- // When & Then - should not throw
- Logging.log(LogLevel.NONE, "Should not be logged")
- }
-
// ===== Message Formatting Tests =====
test("log message should include thread name") {
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
deleted file mode 100644
index 7aabe11a01..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-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.preferences.PreferenceOneSignalKeys
-import com.onesignal.core.internal.preferences.PreferenceStores
-import com.onesignal.features.FeatureFlag
-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/logger/android/CrashDirCleanupTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanupTest.kt
deleted file mode 100644
index 21f90abe04..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanupTest.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.onesignal.debug.internal.logging.logger.android
-
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.string.shouldContain
-import io.kotest.matchers.string.shouldNotContain
-
-/**
- * Pure-JVM coverage for crash-dir ownership / inventory helpers. Runs without Robolectric so Jacoco
- * counts these lines (Roboelectric Android shells are not attributed in this project's reports).
- */
-class CrashDirCleanupTest : FunSpec({
-
- val now = 100_000L
-
- test("isOwnedCrashFile recognizes only the logger suffix") {
- isOwnedCrashFile("123-abc.otlp") shouldBe true
- isOwnedCrashFile("1784621689841") shouldBe false
- isOwnedCrashFile("stale.tmp") shouldBe false
- }
-
- test("selectUnrecognizedEntries keeps owned and too-young foreign files") {
- val entries =
- listOf(
- CrashDirEntry("123-abc.otlp", lastModifiedMs = now - 60_000),
- CrashDirEntry("too-young-legacy", lastModifiedMs = now - 100),
- CrashDirEntry("stale-legacy", lastModifiedMs = now - 10_000),
- CrashDirEntry("stale.tmp", lastModifiedMs = now - 60_000),
- )
-
- val selected =
- selectUnrecognizedEntries(
- entries = entries,
- nowMs = now,
- minAgeMillis = 5_000,
- )
-
- selected.map { it.name } shouldBe listOf("stale-legacy", "stale.tmp")
- }
-
- test("selectUnrecognizedEntries is empty when only owned records exist") {
- val selected =
- selectUnrecognizedEntries(
- entries = listOf(CrashDirEntry("123-abc.otlp", lastModifiedMs = now - 60_000)),
- nowMs = now,
- minAgeMillis = 0,
- )
-
- selected shouldBe emptyList()
- }
-
- test("formatCrashDirInventory reports empty directories") {
- formatCrashDirInventory(
- label = "before-upload",
- path = "/cache/crashes",
- entries = emptyList(),
- nowMs = now,
- maxSample = 20,
- ) shouldBe "OneSignal: Crash storage inventory [before-upload] (/cache/crashes): empty"
- }
-
- test("formatCrashDirInventory counts otlp vs legacy and bounds the sample") {
- val entries =
- (1..25).map { index ->
- val name = if (index <= 3) "$index.otlp" else "legacy-$index"
- CrashDirEntry(name, lastModifiedMs = now - index * 1_000L, lengthBytes = index.toLong())
- }
-
- val line =
- formatCrashDirInventory(
- label = "after-cleanup",
- path = "/cache/crashes",
- entries = entries,
- nowMs = now,
- maxSample = 5,
- )
-
- line shouldContain "total=25 otlp=3 legacy=22"
- line shouldContain "name=1.otlp"
- line shouldContain "…(+20 more)"
- line shouldNotContain "name=legacy-25"
- }
-})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/FileLogStoreTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/FileLogStoreTest.kt
index d7083a58d3..b7e3269eb8 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/FileLogStoreTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/FileLogStoreTest.kt
@@ -2,6 +2,7 @@ package com.onesignal.debug.internal.logging.logger.android
import android.os.Build
import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
+import com.onesignal.logger.crash.CrashRetention
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.runBlocking
@@ -15,6 +16,10 @@ class FileLogStoreTest : FunSpec({
lateinit var dir: File
+ // The bounds FileLogStore enforces; asserting against the shared policy rather than
+ // copies of its numbers keeps these expectations tied to what the store actually uses.
+ val policy = CrashRetention.defaultPolicy
+
beforeEach {
dir = Files.createTempDirectory("crashes").toFile()
}
@@ -23,14 +28,14 @@ class FileLogStoreTest : FunSpec({
dir.deleteRecursively()
}
- fun write(name: String, ageMsAgo: Long = 60_000L): File =
+ fun write(name: String, ageMsAgo: Long = 60_000L, sizeBytes: Int = 1): File =
File(dir, name).apply {
- writeBytes("x".toByteArray())
+ writeBytes(ByteArray(sizeBytes) { 'x'.code.toByte() })
setLastModified(System.currentTimeMillis() - ageMsAgo)
}
test("deleteUnrecognizedEntries removes stale legacy files and keeps owned .otlp records") {
- write("1784621689841") // legacy otel bare-millis file
+ write("1784621689841") // bare-millis file left by a pre-upgrade otel session
write("stale.tmp") // stray temp
write("123-abc.otlp") // owned logger record
write("456-def.otlp") // owned logger record
@@ -88,4 +93,148 @@ class FileLogStoreTest : FunSpec({
second shouldBe 0
File(dir, "789-ghi.otlp").exists() shouldBe true
}
+
+ test("listReadable drops an owned record past the max read age and deletes it from disk") {
+ write("expired-123.otlp", ageMsAgo = policy.maxReadAgeMillis + 60_000)
+ write("fresh-456.otlp", ageMsAgo = 60_000)
+
+ val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) }
+
+ readable.map { it.id } shouldBe listOf("fresh-456.otlp")
+ File(dir, "expired-123.otlp").exists() shouldBe false
+ File(dir, "fresh-456.otlp").exists() shouldBe true
+ }
+
+ test("listReadable returns and retains an owned record inside the age window") {
+ write("edge-123.otlp", ageMsAgo = policy.maxReadAgeMillis - 60_000)
+
+ val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) }
+
+ readable.map { it.id } shouldBe listOf("edge-123.otlp")
+ File(dir, "edge-123.otlp").exists() shouldBe true
+ }
+
+ test("deleteUnrecognizedEntries reclaims expired owned records without counting them as foreign") {
+ write("expired-123.otlp", ageMsAgo = policy.maxReadAgeMillis + 60_000)
+ write("fresh-456.otlp", ageMsAgo = 60_000)
+ write("1784621689841")
+
+ val purged = runBlocking { FileLogStore(dir.path).deleteUnrecognizedEntries(minAgeMillis = 0) }
+
+ purged shouldBe 1
+ File(dir, "expired-123.otlp").exists() shouldBe false
+ File(dir, "fresh-456.otlp").exists() shouldBe true
+ File(dir, "1784621689841").exists() shouldBe false
+ }
+
+ test("save evicts oldest-first once the record count cap is exceeded") {
+ // Distinct mtimes so "oldest" is unambiguous; the newest seeded record is 1s old.
+ repeat(policy.maxRecordCount) { i ->
+ write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1))
+ }
+ val oldest = "seed-${policy.maxRecordCount - 1}.otlp"
+
+ FileLogStore(dir.path).save("new".toByteArray()) shouldBe true
+
+ dir.listFiles()!!.count { it.name.endsWith(policy.ownedSuffix) } shouldBe policy.maxRecordCount
+ File(dir, oldest).exists() shouldBe false
+ File(dir, "seed-0.otlp").exists() shouldBe true
+ }
+
+ test("save evicts oldest-first once the total byte cap is exceeded") {
+ // Each is just under the per-record cap, so only their combined size can breach the
+ // total budget — five of them do, and the oldest is the one that loses.
+ val nearCap = (policy.maxRecordBytes - 12_288).toInt()
+ repeat(5) { i -> write("big-$i.otlp", ageMsAgo = 10_000L * (i + 1), sizeBytes = nearCap) }
+
+ FileLogStore(dir.path).save("new".toByteArray()) shouldBe true
+
+ File(dir, "big-4.otlp").exists() shouldBe false
+ File(dir, "big-0.otlp").exists() shouldBe true
+ }
+
+ test("save refuses a payload over the per-record limit and writes nothing") {
+ val oversized = ByteArray((policy.maxRecordBytes + 1).toInt())
+
+ FileLogStore(dir.path).save(oversized) shouldBe false
+
+ dir.listFiles()!!.count { it.name.endsWith(policy.ownedSuffix) } shouldBe 0
+ }
+
+ test("an inherited oversized record is still offered for upload, not deleted unread") {
+ // Written by a build predating the write-time limit. Deleting it before an upload
+ // attempt would silently destroy a real crash report.
+ write("inherited.otlp", ageMsAgo = 60_000, sizeBytes = (policy.maxRecordBytes + 1).toInt())
+
+ val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) }
+
+ readable.map { it.id } shouldBe listOf("inherited.otlp")
+ File(dir, "inherited.otlp").exists() shouldBe true
+ }
+
+ // Both sort keys clamp to now, so a record written while the backlog is dated ahead of the
+ // clock lands in a tie group with it, and the order inside that group is whatever the
+ // filesystem lists. Only the explicit keepName reservation guarantees the new record
+ // survives; without it eviction is a coin flip, so one attempt would pass most of the time.
+ // Repeating drives the odds of a false pass to nil.
+ test("save never evicts the record it just wrote, whatever order the backlog lists in") {
+ repeat(25) {
+ val trialDir = Files.createTempDirectory("crashes-keepname").toFile()
+ try {
+ repeat(policy.maxRecordCount + 3) { i ->
+ val ahead = System.currentTimeMillis() + 3_600_000L + i
+ File(trialDir, "$ahead-seed$i.otlp").apply {
+ writeBytes(ByteArray(1) { 'x'.code.toByte() })
+ setLastModified(System.currentTimeMillis() + 60_000L)
+ }
+ }
+
+ FileLogStore(trialDir.path).save("new".toByteArray()) shouldBe true
+
+ val remaining = trialDir.listFiles()!!.filter { it.name.endsWith(policy.ownedSuffix) }
+ remaining.count { it.readText() == "new" } shouldBe 1
+ remaining.size shouldBe policy.maxRecordCount
+ } finally {
+ trialDir.deleteRecursively()
+ }
+ }
+ }
+
+ // The uploader paths must reclaim a backlog inherited from a build without caps —
+ // otherwise it is only trimmed the next time a crash happens to be written.
+
+ test("listReadable evicts an inherited over-cap backlog instead of returning it") {
+ repeat(policy.maxRecordCount + 10) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) }
+
+ val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) }
+
+ readable.size shouldBe policy.maxRecordCount
+ dir.listFiles()!!.count { it.name.endsWith(policy.ownedSuffix) } shouldBe policy.maxRecordCount
+ }
+
+ // A delete can fail (read-only dir, filesystem error). The record must stay unreadable
+ // regardless, and must not resurface on a later pass just because it survived.
+ test("an expired record that cannot be deleted is still withheld from readers") {
+ write("expired-stuck.otlp", ageMsAgo = policy.maxReadAgeMillis + 60_000)
+ write("fresh.otlp", ageMsAgo = 60_000)
+ // Read-only dir makes unlink fail on POSIX without making the entries unreadable.
+ dir.setWritable(false)
+
+ val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) }
+
+ dir.setWritable(true)
+ readable.map { it.id } shouldBe listOf("fresh.otlp")
+ File(dir, "expired-stuck.otlp").exists() shouldBe true
+ }
+
+ test("deleteUnrecognizedEntries evicts an inherited over-cap backlog") {
+ repeat(policy.maxRecordCount + 10) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) }
+ write("1784621689841")
+
+ val purged = runBlocking { FileLogStore(dir.path).deleteUnrecognizedEntries(minAgeMillis = 0) }
+
+ // Owned evictions are not counted as foreign purges.
+ purged shouldBe 1
+ dir.listFiles()!!.count { it.name.endsWith(policy.ownedSuffix) } shouldBe policy.maxRecordCount
+ }
})
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/logger/android/LoggerIdResolverTest.kt
similarity index 88%
rename from OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt
rename to OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/LoggerIdResolverTest.kt
index 2d342d5284..6b11310b4c 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/logger/android/LoggerIdResolverTest.kt
@@ -1,4 +1,4 @@
-package com.onesignal.debug.internal.logging.otel.android
+package com.onesignal.debug.internal.logging.logger.android
import android.content.Context
import android.content.SharedPreferences
@@ -9,7 +9,6 @@ import com.onesignal.core.internal.config.ConfigModel
import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
import com.onesignal.core.internal.preferences.PreferenceStores
import com.onesignal.debug.LogLevel
-import com.onesignal.features.FeatureFlag
import com.onesignal.user.internal.backend.IdentityConstants
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
@@ -21,7 +20,7 @@ import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace
import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameSpace
@RobolectricTest
-class OtelIdResolverTest : FunSpec({
+class LoggerIdResolverTest : FunSpec({
var appContext: Context? = null
var sharedPreferences: SharedPreferences? = null
@@ -142,7 +141,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveAppId()
@@ -167,7 +166,7 @@ class OtelIdResolverTest : FunSpec({
// Ensure commit is complete before creating resolver
Thread.sleep(10)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveAppId()
@@ -190,7 +189,7 @@ class OtelIdResolverTest : FunSpec({
// Ensure commit is complete before creating resolver
Thread.sleep(10)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveAppId()
@@ -201,7 +200,7 @@ class OtelIdResolverTest : FunSpec({
test("resolveAppId returns error appId when ConfigModelStore is null") {
// Given
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveAppId()
@@ -225,7 +224,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveAppId()
@@ -236,7 +235,7 @@ class OtelIdResolverTest : FunSpec({
test("resolveAppId returns error appId when context is null") {
// Given
- val resolver = OtelIdResolver(null)
+ val resolver = LoggerIdResolver(null)
// When
val result = resolver.resolveAppId()
@@ -251,7 +250,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json")
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveAppId()
@@ -282,7 +281,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveOnesignalId()
@@ -311,7 +310,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveOnesignalId()
@@ -341,7 +340,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveOnesignalId()
@@ -368,7 +367,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveOnesignalId()
@@ -379,7 +378,7 @@ class OtelIdResolverTest : FunSpec({
test("resolveOnesignalId returns null when IdentityModelStore is null") {
// Given
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveOnesignalId()
@@ -403,7 +402,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveOnesignalId()
@@ -418,7 +417,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, "invalid-json")
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveOnesignalId()
@@ -449,7 +448,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolvePushSubscriptionId()
@@ -478,7 +477,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolvePushSubscriptionId()
@@ -508,7 +507,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolvePushSubscriptionId()
@@ -535,7 +534,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolvePushSubscriptionId()
@@ -546,7 +545,7 @@ class OtelIdResolverTest : FunSpec({
test("resolvePushSubscriptionId returns null when ConfigModelStore is null") {
// Given
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolvePushSubscriptionId()
@@ -561,7 +560,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json")
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolvePushSubscriptionId()
@@ -589,7 +588,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLoggingEnabled() shouldBe true
}
@@ -607,7 +606,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLoggingEnabled() shouldBe true
}
@@ -625,7 +624,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
@@ -641,7 +640,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
@@ -654,12 +653,12 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
test("resolveRemoteLoggingEnabled returns false when no config exists") {
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
@@ -677,7 +676,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
@@ -706,7 +705,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveRemoteLogLevel()
@@ -738,7 +737,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveRemoteLogLevel()
@@ -768,7 +767,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveRemoteLogLevel()
@@ -795,7 +794,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveRemoteLogLevel()
@@ -827,7 +826,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveRemoteLogLevel()
@@ -838,7 +837,7 @@ class OtelIdResolverTest : FunSpec({
test("resolveRemoteLogLevel returns null when ConfigModelStore is null") {
// Given
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveRemoteLogLevel()
@@ -853,7 +852,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json")
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveRemoteLogLevel()
@@ -873,7 +872,7 @@ class OtelIdResolverTest : FunSpec({
val configArray = JSONArray().apply { put(configModel) }
writeAndVerifyConfigData(configArray)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLogLevel() shouldBe LogLevel.NONE
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
@@ -886,7 +885,7 @@ class OtelIdResolverTest : FunSpec({
val configArray = JSONArray().apply { put(configModel) }
writeAndVerifyConfigData(configArray)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLogLevel() shouldBe LogLevel.ERROR
resolver.resolveRemoteLoggingEnabled() shouldBe true
}
@@ -899,7 +898,7 @@ class OtelIdResolverTest : FunSpec({
val configArray = JSONArray().apply { put(configModel) }
writeAndVerifyConfigData(configArray)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLogLevel() shouldBe null
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
@@ -912,7 +911,7 @@ class OtelIdResolverTest : FunSpec({
val configArray = JSONArray().apply { put(configModel) }
writeAndVerifyConfigData(configArray)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLogLevel() shouldBe null
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
@@ -925,7 +924,7 @@ class OtelIdResolverTest : FunSpec({
val configArray = JSONArray().apply { put(configModel) }
writeAndVerifyConfigData(configArray)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLogLevel() shouldBe LogLevel.WARN
resolver.resolveRemoteLoggingEnabled() shouldBe true
}
@@ -938,7 +937,7 @@ class OtelIdResolverTest : FunSpec({
val configArray = JSONArray().apply { put(configModel) }
writeAndVerifyConfigData(configArray)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLogLevel() shouldBe LogLevel.ERROR
resolver.resolveRemoteLoggingEnabled() shouldBe true
}
@@ -951,7 +950,7 @@ class OtelIdResolverTest : FunSpec({
val configArray = JSONArray().apply { put(configModel) }
writeAndVerifyConfigData(configArray)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLogLevel() shouldBe null
resolver.resolveRemoteLoggingEnabled() shouldBe false
}
@@ -964,7 +963,7 @@ class OtelIdResolverTest : FunSpec({
val configArray = JSONArray().apply { put(configModel) }
writeAndVerifyConfigData(configArray)
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
resolver.resolveRemoteLogLevel() shouldBe LogLevel.ERROR
resolver.resolveRemoteLoggingEnabled() shouldBe true
}
@@ -977,7 +976,7 @@ class OtelIdResolverTest : FunSpec({
.putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id-123")
.commit()
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveInstallId()
@@ -988,7 +987,7 @@ class OtelIdResolverTest : FunSpec({
test("resolveInstallId returns default InstallId-Null when not found") {
// Given
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When
val result = resolver.resolveInstallId()
@@ -1003,7 +1002,7 @@ class OtelIdResolverTest : FunSpec({
val mockSharedPreferences = mockk(relaxed = true)
every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception")
- val resolver = OtelIdResolver(mockContext)
+ val resolver = LoggerIdResolver(mockContext)
// When
val result = resolver.resolveInstallId()
@@ -1035,7 +1034,7 @@ class OtelIdResolverTest : FunSpec({
throw AssertionError("Failed to write SharedPreferences data - test isolation issue")
}
- val resolver = OtelIdResolver(appContext!!)
+ val resolver = LoggerIdResolver(appContext!!)
// When - resolve multiple IDs
val appId1 = resolver.resolveAppId()
@@ -1049,68 +1048,4 @@ 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
- }
})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderTest.kt
similarity index 78%
rename from OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt
rename to OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderTest.kt
index ead2ce1c73..467a1074f7 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProviderTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderTest.kt
@@ -1,4 +1,4 @@
-package com.onesignal.debug.internal.logging.otel.android
+package com.onesignal.debug.internal.logging.logger.android
import android.content.Context
import android.content.SharedPreferences
@@ -31,7 +31,7 @@ import com.onesignal.user.internal.identity.IDENTITY_NAME_SPACE as identityNameS
@RobolectricTest
@Config(sdk = [Build.VERSION_CODES.O])
-class OtelPlatformProviderTest : FunSpec({
+class LoggerPlatformProviderTest : FunSpec({
var appContext: Context? = null
var sharedPreferences: SharedPreferences? = null
@@ -73,7 +73,7 @@ class OtelPlatformProviderTest : FunSpec({
test("sdkBase returns android") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.sdkBase
@@ -84,7 +84,7 @@ class OtelPlatformProviderTest : FunSpec({
test("sdkBaseVersion returns OneSignalUtils.sdkVersion") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.sdkBaseVersion
@@ -95,7 +95,7 @@ class OtelPlatformProviderTest : FunSpec({
test("appPackageId returns context.packageName") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.appPackageId
@@ -106,7 +106,7 @@ class OtelPlatformProviderTest : FunSpec({
test("appVersion returns AndroidUtils.getAppVersion") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.appVersion
@@ -118,7 +118,7 @@ class OtelPlatformProviderTest : FunSpec({
test("deviceManufacturer returns Build.MANUFACTURER") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.deviceManufacturer
@@ -129,7 +129,7 @@ class OtelPlatformProviderTest : FunSpec({
test("deviceModel returns Build.MODEL") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.deviceModel
@@ -140,7 +140,7 @@ class OtelPlatformProviderTest : FunSpec({
test("osName returns Android") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.osName
@@ -151,7 +151,7 @@ class OtelPlatformProviderTest : FunSpec({
test("osVersion returns Build.VERSION.RELEASE") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.osVersion
@@ -162,7 +162,7 @@ class OtelPlatformProviderTest : FunSpec({
test("osBuildId returns Build.ID") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.osBuildId
@@ -174,7 +174,7 @@ class OtelPlatformProviderTest : FunSpec({
test("sdkWrapper returns OneSignalWrapper.sdkType") {
// Given
OneSignalWrapper.sdkType = "Unity"
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.sdkWrapper
@@ -186,7 +186,7 @@ class OtelPlatformProviderTest : FunSpec({
test("sdkWrapper returns null when not set") {
// Given
OneSignalWrapper.sdkType = null
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.sdkWrapper
@@ -198,7 +198,7 @@ class OtelPlatformProviderTest : FunSpec({
test("sdkWrapperVersion returns OneSignalWrapper.sdkVersion") {
// Given
OneSignalWrapper.sdkVersion = "1.0.0"
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.sdkWrapperVersion
@@ -210,7 +210,7 @@ class OtelPlatformProviderTest : FunSpec({
test("sdkWrapperVersion returns null when not set") {
// Given
OneSignalWrapper.sdkVersion = null
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.sdkWrapperVersion
@@ -220,19 +220,19 @@ class OtelPlatformProviderTest : FunSpec({
}
test("kotlinVersion returns KotlinVersion.CURRENT") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.kotlinVersion shouldBe KotlinVersion.CURRENT.toString()
}
test("swiftVersion defaults to null on Android") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.swiftVersion shouldBe null
}
test("additionalVersionAttributes includes android_api_level only") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
val attrs = provider.additionalVersionAttributes
attrs shouldBe mapOf("android_api_level" to Build.VERSION.SDK_INT.toString())
@@ -243,7 +243,7 @@ class OtelPlatformProviderTest : FunSpec({
// ===== Lazy ID Properties Tests =====
- test("appId returns resolved appId from OtelIdResolver") {
+ test("appId returns resolved appId from LoggerIdResolver") {
// Given
val configModel = JSONObject().apply {
put(ConfigModel::appId.name, "test-app-id-123")
@@ -255,7 +255,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.appId
@@ -266,7 +266,7 @@ class OtelPlatformProviderTest : FunSpec({
test("appId returns error UUID when not available") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.appId
@@ -276,7 +276,7 @@ class OtelPlatformProviderTest : FunSpec({
result shouldContain "e1100000-0000-4000-a000-"
}
- test("onesignalId returns resolved onesignalId from OtelIdResolver") {
+ test("onesignalId returns resolved onesignalId from LoggerIdResolver") {
// Given
val identityModel = JSONObject().apply {
put(IdentityConstants.ONESIGNAL_ID, "test-onesignal-id-123")
@@ -288,7 +288,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + identityNameSpace, identityArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.onesignalId
@@ -299,7 +299,7 @@ class OtelPlatformProviderTest : FunSpec({
test("onesignalId returns null when not available") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.onesignalId
@@ -308,7 +308,7 @@ class OtelPlatformProviderTest : FunSpec({
result shouldBe null
}
- test("pushSubscriptionId returns resolved pushSubscriptionId from OtelIdResolver") {
+ test("pushSubscriptionId returns resolved pushSubscriptionId from LoggerIdResolver") {
// Given
val configModel = JSONObject().apply {
put(ConfigModel::pushSubscriptionId.name, "test-push-sub-id-123")
@@ -320,7 +320,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.pushSubscriptionId
@@ -331,7 +331,7 @@ class OtelPlatformProviderTest : FunSpec({
test("pushSubscriptionId returns null when not available") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.pushSubscriptionId
@@ -345,14 +345,14 @@ class OtelPlatformProviderTest : FunSpec({
test("appState returns foreground when getIsInForeground returns true") {
// Given
val getIsInForeground: () -> Boolean? = { true }
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = appContext,
getIsInForeground = getIsInForeground
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
// When
val result = provider.appState
@@ -364,14 +364,14 @@ class OtelPlatformProviderTest : FunSpec({
test("appState returns background when getIsInForeground returns false") {
// Given
val getIsInForeground: () -> Boolean? = { false }
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = appContext,
getIsInForeground = getIsInForeground
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
// When
val result = provider.appState
@@ -382,14 +382,14 @@ class OtelPlatformProviderTest : FunSpec({
test("appState falls back to ActivityManager when getIsInForeground is null") {
// Given
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = appContext,
getIsInForeground = null
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
// When
val result = provider.appState
@@ -400,14 +400,14 @@ class OtelPlatformProviderTest : FunSpec({
test("appState returns unknown when context is null and getIsInForeground is null") {
// Given
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = null,
getIsInForeground = null
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
// When
val result = provider.appState
@@ -420,14 +420,14 @@ class OtelPlatformProviderTest : FunSpec({
// Given
val mockContext = mockk(relaxed = true)
every { mockContext.getSystemService(any()) } throws RuntimeException("Test exception")
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = mockContext,
getIsInForeground = null
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
// When
val result = provider.appState
@@ -440,7 +440,7 @@ class OtelPlatformProviderTest : FunSpec({
test("processUptime returns uptime in milliseconds") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.processUptime
@@ -454,7 +454,7 @@ class OtelPlatformProviderTest : FunSpec({
test("currentThreadName returns current thread name") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.currentThreadName
@@ -469,12 +469,12 @@ class OtelPlatformProviderTest : FunSpec({
test("crashStoragePath returns configured path") {
// Given
val expectedPath = "/test/crash/path"
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = expectedPath,
appPackageId = "com.test",
appVersion = "1.0"
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
// When
val result = provider.crashStoragePath
@@ -487,12 +487,12 @@ class OtelPlatformProviderTest : FunSpec({
// Given
val logSlot = slot()
val expectedPath = "/test/crash/path"
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = expectedPath,
appPackageId = "com.test",
appVersion = "1.0"
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
// When
val result = provider.crashStoragePath
@@ -503,12 +503,13 @@ class OtelPlatformProviderTest : FunSpec({
// but the behavior is tested by ensuring the path is returned correctly
}
- test("createAndroidOtelPlatformProvider sets correct crashStoragePath") {
+ test("createAndroidLoggerPlatformProvider sets correct crashStoragePath") {
// Given & When
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// Then
provider.crashStoragePath shouldContain "onesignal"
+ // Path segment is inherited from the removed otel module so upgrades keep pending records.
provider.crashStoragePath shouldContain "otel"
provider.crashStoragePath shouldContain "crashes"
}
@@ -517,7 +518,7 @@ class OtelPlatformProviderTest : FunSpec({
test("minFileAgeForReadMillis returns default value") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.minFileAgeForReadMillis
@@ -530,7 +531,7 @@ class OtelPlatformProviderTest : FunSpec({
// Derived from logLevel presence: empty logging_config → disabled, has log_level → enabled
test("isRemoteLoggingEnabled returns false when no config exists") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.isRemoteLoggingEnabled shouldBe false
}
@@ -548,7 +549,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.isRemoteLoggingEnabled shouldBe true
}
@@ -564,7 +565,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.isRemoteLoggingEnabled shouldBe false
}
@@ -582,20 +583,20 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.isRemoteLoggingEnabled shouldBe false
}
test("isRemoteLoggingEnabled returns false when exception occurs") {
val mockContext = mockk(relaxed = true)
every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception")
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = mockContext
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
provider.isRemoteLoggingEnabled shouldBe false
}
@@ -603,7 +604,7 @@ class OtelPlatformProviderTest : FunSpec({
test("remoteLogLevel returns null when no config exists (disabled)") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.remoteLogLevel
@@ -625,7 +626,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.remoteLogLevel
@@ -649,7 +650,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.remoteLogLevel
@@ -673,7 +674,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.remoteLogLevel
@@ -697,7 +698,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.remoteLogLevel
@@ -710,13 +711,13 @@ class OtelPlatformProviderTest : FunSpec({
// Given
val mockContext = mockk(relaxed = true)
every { mockContext.getSharedPreferences(any(), any()) } throws RuntimeException("Test exception")
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = mockContext
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
// When
val result = provider.remoteLogLevel
@@ -728,7 +729,7 @@ class OtelPlatformProviderTest : FunSpec({
// ===== enabledFeatureFlags Tests =====
test("enabledFeatureFlags returns empty list when FeatureManager has no enabled flags") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.enabledFeatureFlags shouldBe emptyList()
}
@@ -736,7 +737,7 @@ class OtelPlatformProviderTest : FunSpec({
val states = mutableListOf("sdk_background_threading")
val fm = mockk()
every { fm.enabledFeatureKeys() } answers { states.toList() }
- val provider = createAndroidOtelPlatformProvider(appContext!!) { fm }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { fm }
provider.enabledFeatureFlags shouldBe listOf("sdk_background_threading")
@@ -747,13 +748,13 @@ class OtelPlatformProviderTest : FunSpec({
test("enabledFeatureFlags returns empty list when FeatureManager throws") {
val fm = mockk()
every { fm.enabledFeatureKeys() } throws RuntimeException("boom")
- val provider = createAndroidOtelPlatformProvider(appContext!!) { fm }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { fm }
provider.enabledFeatureFlags shouldBe emptyList()
}
test("enabledFeatureFlags returns empty list when the supplier itself throws") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) {
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) {
throw RuntimeException("supplier boom")
}
@@ -774,7 +775,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString())
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.appIdForHeaders
@@ -785,7 +786,7 @@ class OtelPlatformProviderTest : FunSpec({
test("appIdForHeaders returns empty string when appId is null") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = provider.appIdForHeaders
@@ -797,7 +798,7 @@ class OtelPlatformProviderTest : FunSpec({
// ===== apiBaseUrl Tests =====
test("apiBaseUrl returns the core module base URL") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.apiBaseUrl shouldBe com.onesignal.core.internal.http.OneSignalService.ONESIGNAL_API_BASE_URL
}
@@ -810,7 +811,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.PREFS_OS_INSTALL_ID, "test-install-id-123")
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = runBlocking { provider.getInstallId() }
@@ -821,7 +822,7 @@ class OtelPlatformProviderTest : FunSpec({
test("getInstallId returns default when not found") {
// Given
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// When
val result = runBlocking { provider.getInstallId() }
@@ -832,9 +833,9 @@ class OtelPlatformProviderTest : FunSpec({
// ===== Factory Function Tests =====
- test("createAndroidOtelPlatformProvider creates provider with correct config") {
+ test("createAndroidLoggerPlatformProvider creates provider with correct config") {
// Given & When
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
// Then
provider.appPackageId shouldBe appContext!!.packageName
@@ -845,7 +846,7 @@ class OtelPlatformProviderTest : FunSpec({
// ===== Fresh install / all-missing scenario =====
test("fresh install: all lazy properties return safe defaults without crashing") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.appId shouldContain "e1100000-0000-4000-a000-"
provider.onesignalId shouldBe null
@@ -859,7 +860,7 @@ class OtelPlatformProviderTest : FunSpec({
}
test("lazy properties cache the initial value and ignore later SharedPreferences changes") {
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.isRemoteLoggingEnabled shouldBe false
provider.remoteLogLevel shouldBe null
@@ -878,38 +879,38 @@ class OtelPlatformProviderTest : FunSpec({
}
test("getIsInForeground callback throws — appState returns unknown") {
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = appContext,
getIsInForeground = { throw RuntimeException("callback boom") }
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
provider.appState shouldBe "unknown"
}
test("getIsInForeground returns null — falls back to ActivityManager") {
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = appContext,
getIsInForeground = { null }
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
provider.appState shouldBeOneOf listOf("foreground", "background", "unknown")
}
test("null context and null callback — all provider properties return safe defaults") {
- val config = OtelPlatformProviderConfig(
+ val config = LoggerPlatformProviderConfig(
crashStoragePath = "/test/path",
appPackageId = "com.test",
appVersion = "1.0",
context = null,
getIsInForeground = null
)
- val provider = OtelPlatformProvider(config) { emptyFeatureManager() }
+ val provider = LoggerPlatformProvider(config) { emptyFeatureManager() }
provider.appState shouldBe "unknown"
provider.appPackageId shouldBe "com.test"
@@ -924,7 +925,7 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "not valid json {{{")
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.isRemoteLoggingEnabled shouldBe false
provider.remoteLogLevel shouldBe null
}
@@ -934,13 +935,13 @@ class OtelPlatformProviderTest : FunSpec({
.putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "not valid json {{{")
.commit()
- val provider = createAndroidOtelPlatformProvider(appContext!!) { emptyFeatureManager() }
+ val provider = createAndroidLoggerPlatformProvider(appContext!!) { emptyFeatureManager() }
provider.appId shouldContain "e1100000-0000-4000-a000-"
}
// ===== Factory Function Tests =====
- test("createAndroidOtelPlatformProvider handles null appVersion gracefully") {
+ test("createAndroidLoggerPlatformProvider handles null appVersion gracefully") {
// Given
val mockContext = mockk(relaxed = true)
val mockPackageManager = mockk(relaxed = true)
@@ -952,7 +953,7 @@ class OtelPlatformProviderTest : FunSpec({
every { mockPackageManager.getPackageInfo(any(), any()) } throws android.content.pm.PackageManager.NameNotFoundException()
// When
- val provider: OtelPlatformProvider = createAndroidOtelPlatformProvider(mockContext) { emptyFeatureManager() }
+ val provider: LoggerPlatformProvider = createAndroidLoggerPlatformProvider(mockContext) { emptyFeatureManager() }
// Then
provider.appVersion shouldBe "unknown"
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLoggerTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLoggerTest.kt
deleted file mode 100644
index 67336bd367..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLoggerTest.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package com.onesignal.debug.internal.logging.otel.android
-
-import com.onesignal.debug.LogLevel
-import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.otel.IOtelLogger
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.types.shouldBeInstanceOf
-
-class AndroidOtelLoggerTest : FunSpec({
- // Save original log level to restore after tests
- val originalLogLevel = Logging.logLevel
-
- beforeEach {
- // Disable logging during tests to avoid polluting test output
- Logging.logLevel = LogLevel.NONE
- }
-
- afterEach {
- // Restore original log level
- Logging.logLevel = originalLogLevel
- }
-
- test("should implement IOtelLogger interface") {
- val logger = AndroidOtelLogger()
-
- logger.shouldBeInstanceOf()
- }
-
- test("error should not throw") {
- val logger = AndroidOtelLogger()
-
- // Should not throw
- logger.error("test error message")
- }
-
- test("warn should not throw") {
- val logger = AndroidOtelLogger()
-
- // Should not throw
- logger.warn("test warn message")
- }
-
- test("info should not throw") {
- val logger = AndroidOtelLogger()
-
- // Should not throw
- logger.info("test info message")
- }
-
- test("debug should not throw") {
- val logger = AndroidOtelLogger()
-
- // Should not throw
- logger.debug("test debug message")
- }
-
- test("should handle empty messages") {
- val logger = AndroidOtelLogger()
-
- // Should not throw with empty messages
- logger.error("")
- logger.warn("")
- logger.info("")
- logger.debug("")
- }
-
- test("should handle messages with special characters") {
- val logger = AndroidOtelLogger()
-
- // Should not throw with special characters
- logger.error("Error: \n\t special chars: @#$%^&*()")
- logger.info("Unicode: 日本語 中文 한국어")
- }
-})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt
new file mode 100644
index 0000000000..e297a2492f
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt
@@ -0,0 +1,504 @@
+package com.onesignal.internal
+
+import android.content.Context
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
+import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.core.internal.features.IFeatureManager
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.crash.ObservabilitySdkSupport
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.logger.ILogAnrDetector
+import com.onesignal.logger.ILogCrashHandler
+import com.onesignal.logger.ILogFileStore
+import com.onesignal.logger.ILogTelemetryRemote
+import com.onesignal.logger.ILogger
+import com.onesignal.logger.ILoggerPlatformProvider
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.robolectric.annotation.Config
+
+/**
+ * Fault-isolation coverage for [LoggerLifecycleManager]: one failing component must never stop
+ * the others from starting, and nothing may propagate to the caller — the lifecycle manager runs
+ * inside SDK init, where a throw would take down the host app.
+ */
+@RobolectricTest
+@Config(sdk = [Build.VERSION_CODES.O])
+class LoggerLifecycleManagerFaultTest : FunSpec({
+ lateinit var context: Context
+ lateinit var featureManager: IFeatureManager
+ var originalHandler: Thread.UncaughtExceptionHandler? = null
+
+ beforeEach {
+ context = ApplicationProvider.getApplicationContext()
+ featureManager = mockk().also {
+ every { it.enabledFeatureKeys() } returns emptyList()
+ }
+ originalHandler = Thread.getDefaultUncaughtExceptionHandler()
+ ObservabilitySdkSupport.isSupported = true
+ }
+
+ afterEach {
+ ObservabilitySdkSupport.reset()
+ Thread.setDefaultUncaughtExceptionHandler(originalHandler)
+ // Enabling installs a mock sink into the process-global Logging; leaving one
+ // attached would leak into every later spec in this JVM.
+ Logging.setLoggerTelemetry(null) { false }
+ }
+
+ fun enabledConfig(logLevel: LogLevel = LogLevel.ERROR): ConfigModel =
+ ConfigModel().apply {
+ remoteLoggingParams.isEnabled = true
+ remoteLoggingParams.logLevel = logLevel
+ }
+
+ fun disabledConfig(): ConfigModel =
+ ConfigModel().apply { remoteLoggingParams.isEnabled = false }
+
+ /** Collaborators are all relaxed mocks unless a factory is overridden to throw. */
+ fun managerWith(
+ crashHandler: () -> ILogCrashHandler = { mockk(relaxed = true) },
+ anrDetector: () -> ILogAnrDetector = { mockk(relaxed = true) },
+ remoteTelemetry: () -> ILogTelemetryRemote = { mockk(relaxed = true) },
+ platformProvider: () -> ILoggerPlatformProvider = { mockk(relaxed = true) },
+ ): LoggerLifecycleManager =
+ LoggerLifecycleManager(
+ context = context,
+ featureManagerProvider = { featureManager },
+ platformProviderFactory = { _, _ -> platformProvider() },
+ logger = mockk(relaxed = true),
+ fileStoreFactory = { mockk(relaxed = true) },
+ crashHandlerFactory = { _, _, _ -> crashHandler() },
+ anrDetectorFactory = { _, _, _ -> anrDetector() },
+ remoteTelemetryFactory = { _, _ -> remoteTelemetry() },
+ )
+
+ // ===== One failing collaborator must not block the others =====
+
+ test("crash handler factory throws — ANR and logging still start") {
+ val detector = mockk(relaxed = true)
+ val manager = managerWith(
+ crashHandler = { throw RuntimeException("crash handler factory boom") },
+ anrDetector = { detector },
+ )
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { detector.start() }
+ }
+
+ test("ANR factory throws — crash handler and logging still start") {
+ val handler = mockk(relaxed = true)
+ val manager = managerWith(
+ crashHandler = { handler },
+ anrDetector = { throw RuntimeException("anr factory boom") },
+ )
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { handler.initialize() }
+ }
+
+ test("telemetry factory throws — crash handler and ANR still start") {
+ val handler = mockk(relaxed = true)
+ val detector = mockk(relaxed = true)
+ val manager = managerWith(
+ crashHandler = { handler },
+ anrDetector = { detector },
+ remoteTelemetry = { throw RuntimeException("telemetry factory boom") },
+ )
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { handler.initialize() }
+ verify { detector.start() }
+ }
+
+ test("all three factories throw — no exception propagates") {
+ val manager = managerWith(
+ crashHandler = { throw RuntimeException("a") },
+ anrDetector = { throw RuntimeException("b") },
+ remoteTelemetry = { throw RuntimeException("c") },
+ )
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ }
+
+ // ===== Collaborator methods throwing on the way up =====
+
+ test("crash handler initialize() throws — ANR and logging still start") {
+ val handler = mockk(relaxed = true)
+ every { handler.initialize() } throws RuntimeException("initialize boom")
+ val detector = mockk(relaxed = true)
+ val manager = managerWith(crashHandler = { handler }, anrDetector = { detector })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { detector.start() }
+ }
+
+ test("ANR detector start() throws — crash handler and logging still start") {
+ val handler = mockk(relaxed = true)
+ val detector = mockk(relaxed = true)
+ every { detector.start() } throws RuntimeException("start boom")
+ val manager = managerWith(crashHandler = { handler }, anrDetector = { detector })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { handler.initialize() }
+ }
+
+ // ===== Collaborator methods throwing on the way down =====
+
+ test("ANR stop() throws during disable — crash unregister and telemetry shutdown still run") {
+ val handler = mockk(relaxed = true)
+ val detector = mockk(relaxed = true)
+ every { detector.stop() } throws RuntimeException("stop boom")
+ val telemetry = mockk(relaxed = true)
+ val manager = managerWith(
+ crashHandler = { handler },
+ anrDetector = { detector },
+ remoteTelemetry = { telemetry },
+ )
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { handler.unregister() }
+ verify { telemetry.shutdown() }
+ }
+
+ test("crash handler unregister() throws during disable — telemetry shutdown still runs") {
+ val handler = mockk(relaxed = true)
+ every { handler.unregister() } throws RuntimeException("unregister boom")
+ val telemetry = mockk(relaxed = true)
+ val manager = managerWith(crashHandler = { handler }, remoteTelemetry = { telemetry })
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { telemetry.shutdown() }
+ }
+
+ test("telemetry shutdown() throws during disable — no exception propagates") {
+ val telemetry = mockk(relaxed = true)
+ every { telemetry.shutdown() } throws RuntimeException("shutdown boom")
+ val manager = managerWith(remoteTelemetry = { telemetry })
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+ }
+
+ // ===== Cold-start and update paths =====
+
+ test("platform provider factory throws — initializeFromCachedConfig does not propagate") {
+ val manager = managerWith(
+ platformProvider = { throw RuntimeException("provider boom") },
+ )
+
+ manager.initializeFromCachedConfig()
+ }
+
+ test("a throwing shutdown() during a level change still installs the replacement sink") {
+ // Clearing the field after the teardown call would strand the dead instance there and
+ // leave remote logging down for the session.
+ val failing = mockk(relaxed = true)
+ every { failing.shutdown() } throws RuntimeException("shutdown boom")
+ val replacement = mockk(relaxed = true)
+ var calls = 0
+ val manager = managerWith(remoteTelemetry = { if (calls++ == 0) failing else replacement })
+
+ manager.onModelReplaced(enabledConfig(LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(LogLevel.WARN), ModelChangeTags.HYDRATE)
+
+ calls shouldBe 2
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+ verify { replacement.shutdown() }
+ }
+
+ test("telemetry factory throws during log level update — no exception propagates") {
+ var calls = 0
+ val manager = managerWith(
+ remoteTelemetry = {
+ calls++
+ if (calls == 1) mockk(relaxed = true) else throw RuntimeException("update boom")
+ },
+ )
+ manager.onModelReplaced(enabledConfig(LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ manager.onModelReplaced(enabledConfig(LogLevel.WARN), ModelChangeTags.HYDRATE)
+ }
+
+ // ===== Idempotency and full lifecycle =====
+
+ test("a repeated identical config is a no-op and does not rebuild collaborators") {
+ var handlerCount = 0
+ var detectorCount = 0
+ val manager = managerWith(
+ crashHandler = { handlerCount++; mockk(relaxed = true) },
+ anrDetector = { detectorCount++; mockk(relaxed = true) },
+ )
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ // The second HYDRATE evaluates to NoChange, so enableFeatures is never re-entered.
+ handlerCount shouldBe 1
+ detectorCount shouldBe 1
+ }
+
+ // A component that failed to start must be retried on the next config refresh: committing
+ // currentConfig after a partial failure would collapse the next identical HYDRATE to NoChange.
+
+ test("a crash handler that failed to start is retried on the next identical config") {
+ val failing = mockk(relaxed = true)
+ every { failing.initialize() } throws RuntimeException("initialize boom")
+ val replacement = mockk(relaxed = true)
+ var calls = 0
+ val manager = managerWith(crashHandler = { if (calls++ == 0) failing else replacement })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { replacement.initialize() }
+ }
+
+ test("an ANR detector that failed to start is retried on the next identical config") {
+ val failing = mockk(relaxed = true)
+ every { failing.start() } throws RuntimeException("start boom")
+ val replacement = mockk(relaxed = true)
+ var calls = 0
+ val manager = managerWith(anrDetector = { if (calls++ == 0) failing else replacement })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { replacement.start() }
+ }
+
+ test("a retry does not tear down the components that did start") {
+ val handler = mockk(relaxed = true)
+ val failingDetector = mockk(relaxed = true)
+ every { failingDetector.start() } throws RuntimeException("start boom")
+ var detectorCalls = 0
+ var handlerCalls = 0
+ val manager =
+ managerWith(
+ crashHandler = { handlerCalls++; handler },
+ anrDetector = { detectorCalls++; if (detectorCalls == 1) failingDetector else mockk(relaxed = true) },
+ )
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ // Only the ANR detector is rebuilt; the healthy crash handler is left alone.
+ handlerCalls shouldBe 1
+ detectorCalls shouldBe 2
+ }
+
+ test("once every component is up an identical config stops retrying") {
+ var handlerCalls = 0
+ val manager = managerWith(crashHandler = { handlerCalls++; mockk(relaxed = true) })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ handlerCalls shouldBe 1
+ }
+
+ test("disable then re-enable builds fresh collaborators") {
+ var handlerCount = 0
+ var detectorCount = 0
+ val manager = managerWith(
+ crashHandler = { handlerCount++; mockk(relaxed = true) },
+ anrDetector = { detectorCount++; mockk(relaxed = true) },
+ )
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ handlerCount shouldBe 2
+ detectorCount shouldBe 2
+ }
+
+ // Teardown clears each reference before calling the collaborator, so a throwing
+ // stop()/unregister() cannot leave the start guards treating a dead component as running.
+
+ test("a throwing ANR stop() still allows the detector to restart on re-enable") {
+ val failing = mockk(relaxed = true)
+ every { failing.stop() } throws RuntimeException("stop boom")
+ val replacement = mockk(relaxed = true)
+ var calls = 0
+ val manager = managerWith(anrDetector = { if (calls++ == 0) failing else replacement })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { replacement.start() }
+ }
+
+ test("a throwing crash-handler unregister() still allows the handler to restart on re-enable") {
+ val failing = mockk(relaxed = true)
+ every { failing.unregister() } throws RuntimeException("unregister boom")
+ val replacement = mockk(relaxed = true)
+ var calls = 0
+ val manager = managerWith(crashHandler = { if (calls++ == 0) failing else replacement })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { replacement.initialize() }
+ }
+
+ // ===== Desired config vs. actual liveness =====
+ // A partial-failure Enable leaves components running under a config that was never
+ // committed, so the evaluator alone cannot be trusted to decide these two.
+
+ test("disable tears down live components after a partial-failure enable") {
+ val detector = mockk(relaxed = true)
+ val telemetry = mockk(relaxed = true)
+ val manager = managerWith(
+ crashHandler = { throw RuntimeException("crash handler factory boom") },
+ anrDetector = { detector },
+ remoteTelemetry = { telemetry },
+ )
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+
+ // currentConfig is still null here, so a config-only diff would read null -> disabled
+ // as NoChange and leave the remote kill switch unhonored for the session.
+ verify { detector.stop() }
+ verify { telemetry.shutdown() }
+ }
+
+ test("an enable retry moves a live sink to the newly requested level") {
+ val failingDetector = mockk(relaxed = true)
+ every { failingDetector.start() } throws RuntimeException("start boom")
+ var telemetryCalls = 0
+ val manager = managerWith(
+ anrDetector = { failingDetector },
+ remoteTelemetry = { telemetryCalls++; mockk(relaxed = true) },
+ )
+
+ manager.onModelReplaced(enabledConfig(LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(LogLevel.DEBUG), ModelChangeTags.HYDRATE)
+
+ // The retry re-enters enableFeatures with a healthy ERROR sink; skipping startLogging
+ // there would commit DEBUG while the sink kept filtering at ERROR.
+ telemetryCalls shouldBe 2
+ }
+
+ // ===== Partial starts must be unwound, not abandoned =====
+
+ test("a crash handler that throws after installing itself is unregistered") {
+ val failing = mockk(relaxed = true)
+ every { failing.initialize() } throws RuntimeException("initialize boom")
+ val manager = managerWith(crashHandler = { failing })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ // initialize() can chain onto the process-global handler before throwing; leaving it
+ // installed and unreferenced means the retry chains a second one and double-reports.
+ verify { failing.unregister() }
+ }
+
+ test("an ANR detector that throws after starting is stopped") {
+ val failing = mockk(relaxed = true)
+ every { failing.start() } throws RuntimeException("start boom")
+ val manager = managerWith(anrDetector = { failing })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+
+ verify { failing.stop() }
+ }
+
+ test("a failed log level update is retried on the next identical config") {
+ var calls = 0
+ val manager = managerWith(
+ remoteTelemetry = {
+ calls++
+ if (calls == 2) throw RuntimeException("update boom") else mockk(relaxed = true)
+ },
+ )
+
+ manager.onModelReplaced(enabledConfig(LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(LogLevel.WARN), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(LogLevel.WARN), ModelChangeTags.HYDRATE)
+
+ // The failed update never commits, so currentConfig still reads ERROR and the repeat
+ // WARN payload is another UpdateLogLevel rather than a NoChange.
+ calls shouldBe 3
+ }
+
+ test("enable creates all three features and disable tears all down") {
+ val handler = mockk(relaxed = true)
+ val detector = mockk(relaxed = true)
+ val telemetry = mockk(relaxed = true)
+ val manager = managerWith(
+ crashHandler = { handler },
+ anrDetector = { detector },
+ remoteTelemetry = { telemetry },
+ )
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ verify { handler.initialize() }
+ verify { detector.start() }
+
+ manager.onModelReplaced(disabledConfig(), ModelChangeTags.HYDRATE)
+ verify { detector.stop() }
+ verify { handler.unregister() }
+ verify { telemetry.shutdown() }
+ }
+
+ test("update log level shuts down old telemetry and creates new one") {
+ val first = mockk(relaxed = true)
+ val second = mockk(relaxed = true)
+ var calls = 0
+ val manager = managerWith(remoteTelemetry = { if (calls++ == 0) first else second })
+
+ manager.onModelReplaced(enabledConfig(LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(enabledConfig(LogLevel.WARN), ModelChangeTags.HYDRATE)
+
+ verify { first.shutdown() }
+ calls shouldBe 2
+ }
+
+ // ===== Errors, not just Exceptions =====
+ // The catch clauses are on Throwable; these pin that intent so a later narrowing to
+ // Exception cannot silently make SDK init crash the host app.
+
+ test("OutOfMemoryError from factory does not propagate") {
+ val manager = managerWith(crashHandler = { throw OutOfMemoryError("oom") })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ }
+
+ test("StackOverflowError from factory does not propagate") {
+ val manager = managerWith(anrDetector = { throw StackOverflowError("so") })
+
+ manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE)
+ }
+
+ test("initializeFromCachedConfig catches factory failure and does not propagate") {
+ val provider = mockk(relaxed = true)
+ every { provider.isRemoteLoggingEnabled } returns true
+ every { provider.remoteLogLevel } returns "ERROR"
+ val manager = managerWith(
+ crashHandler = { throw RuntimeException("cold start boom") },
+ platformProvider = { provider },
+ )
+
+ manager.initializeFromCachedConfig()
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt
new file mode 100644
index 0000000000..dcd952b724
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt
@@ -0,0 +1,215 @@
+package com.onesignal.internal
+
+import android.content.Context
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
+import com.onesignal.common.modeling.ModelChangeTags
+import com.onesignal.core.internal.config.ConfigModel
+import com.onesignal.core.internal.features.IFeatureManager
+import com.onesignal.debug.LogLevel
+import com.onesignal.debug.internal.crash.ObservabilitySdkSupport
+import com.onesignal.debug.internal.logging.Logging
+import com.onesignal.debug.internal.logging.logger.android.AndroidLogCrashHandler
+import com.onesignal.logger.ILogAnrDetector
+import com.onesignal.logger.ILogFileStore
+import com.onesignal.logger.ILogTelemetryRemote
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.types.shouldBeInstanceOf
+import io.mockk.clearMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.robolectric.annotation.Config
+
+/** Covers the config state machine that brings the logger pipeline up and tears it down. */
+@RobolectricTest
+@Config(sdk = [Build.VERSION_CODES.O])
+class LoggerLifecycleManagerTest : FunSpec({
+ lateinit var context: Context
+ lateinit var featureManager: IFeatureManager
+ var originalHandler: Thread.UncaughtExceptionHandler? = null
+
+ /**
+ * Real platform provider and crash handler, so the assertions below observe genuine
+ * [Thread.UncaughtExceptionHandler] registration. The ANR detector, file store and remote
+ * sink are stubbed: the real detector spawns a daemon watchdog thread that would outlive
+ * the spec and write into the Robolectric cache dir other specs assert on.
+ */
+ fun newManager(): LoggerLifecycleManager =
+ LoggerLifecycleManager(
+ context = context,
+ featureManagerProvider = { featureManager },
+ fileStoreFactory = { mockk(relaxed = true) },
+ anrDetectorFactory = { _, _, _ -> mockk(relaxed = true) },
+ remoteTelemetryFactory = { _, _ -> mockk(relaxed = true) },
+ )
+
+ beforeEach {
+ context = ApplicationProvider.getApplicationContext()
+ featureManager = mockk().also {
+ every { it.enabledFeatureKeys() } returns emptyList()
+ }
+ originalHandler = Thread.getDefaultUncaughtExceptionHandler()
+ ObservabilitySdkSupport.isSupported = true
+ }
+
+ afterEach {
+ ObservabilitySdkSupport.reset()
+ Thread.setDefaultUncaughtExceptionHandler(originalHandler)
+ // Logging holds the sink in a global; leaving one attached would leak into other specs.
+ Logging.setLoggerTelemetry(null) { false }
+ }
+
+ test("initializeFromCachedConfig is a no-op when the SDK level is unsupported") {
+ ObservabilitySdkSupport.isSupported = false
+
+ newManager().initializeFromCachedConfig()
+
+ Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler
+ }
+
+ test("initializeFromCachedConfig with no cached config leaves features off") {
+ newManager().initializeFromCachedConfig()
+
+ Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler
+ }
+
+ test("a HYDRATE with remote logging enabled installs the crash handler") {
+ val manager = newManager()
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ Thread.getDefaultUncaughtExceptionHandler().shouldBeInstanceOf()
+ }
+
+ test("onModelReplaced is ignored when the SDK level is unsupported") {
+ ObservabilitySdkSupport.isSupported = false
+ val manager = newManager()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler
+ }
+
+ test("onModelReplaced ignores non-HYDRATE tags") {
+ val manager = newManager()
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.NORMAL)
+
+ Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler
+ }
+
+ test("disabling remotely unregisters the crash handler") {
+ val manager = newManager()
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+
+ Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler
+ }
+
+ test("full lifecycle: enable, change level, disable, re-enable") {
+ val manager = newManager()
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.INFO), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.DEBUG), ModelChangeTags.HYDRATE)
+
+ Thread.getDefaultUncaughtExceptionHandler().shouldBeInstanceOf()
+ }
+
+ test("repeating the same config does not re-register the handler") {
+ val manager = newManager()
+ manager.initializeFromCachedConfig()
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ val afterFirst = Thread.getDefaultUncaughtExceptionHandler()
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+
+ Thread.getDefaultUncaughtExceptionHandler() shouldBe afterFirst
+ }
+
+ // The ANR watchdog and the remote sink are not observable through the process-global
+ // uncaught-exception handler, so these drive them through the injected factories.
+
+ test("enabling starts the ANR detector and disabling stops it") {
+ val detector = mockk(relaxed = true)
+ val manager =
+ LoggerLifecycleManager(
+ context = context,
+ featureManagerProvider = { featureManager },
+ platformProviderFactory = { _, _ -> mockk(relaxed = true) },
+ logger = mockk(relaxed = true),
+ fileStoreFactory = { mockk(relaxed = true) },
+ crashHandlerFactory = { _, _, _ -> mockk(relaxed = true) },
+ anrDetectorFactory = { _, _, _ -> detector },
+ remoteTelemetryFactory = { _, _ -> mockk(relaxed = true) },
+ )
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ verify { detector.start() }
+
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+ verify { detector.stop() }
+ }
+
+ test("enabling wires the remote sink and disabling shuts it down and clears it") {
+ // Emission hops to a background scope, so wait on a signal from the sink rather than a
+ // fixed sleep. The negative case has no such event, so it settles for a bounded wait —
+ // but only after a real emit has established that the pipeline is warm.
+ val emitted = CompletableDeferred()
+ val telemetry = mockk(relaxed = true)
+ coEvery { telemetry.emit(any()) } answers { emitted.complete(Unit); Unit }
+ val manager =
+ LoggerLifecycleManager(
+ context = context,
+ featureManagerProvider = { featureManager },
+ platformProviderFactory = { _, _ -> mockk(relaxed = true) },
+ logger = mockk(relaxed = true),
+ fileStoreFactory = { mockk(relaxed = true) },
+ crashHandlerFactory = { _, _, _ -> mockk(relaxed = true) },
+ anrDetectorFactory = { _, _, _ -> mockk(relaxed = true) },
+ remoteTelemetryFactory = { _, _ -> telemetry },
+ )
+
+ manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
+ Logging.error("routed while enabled")
+ runBlocking { withTimeout(SINK_TIMEOUT_MS) { emitted.await() } }
+ coVerify { telemetry.emit(any()) }
+
+ manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
+ verify { telemetry.shutdown() }
+
+ clearMocks(telemetry, answers = false)
+ Logging.error("dropped after disable")
+ runBlocking { delay(SINK_QUIET_MS) }
+ coVerify(exactly = 0) { telemetry.emit(any()) }
+ }
+})
+
+/** Generous upper bound on a signal we expect; only a hang burns the full budget. */
+private const val SINK_TIMEOUT_MS = 5_000L
+
+/** Settle window for asserting the detached sink stays silent. */
+private const val SINK_QUIET_MS = 200L
+
+private fun configWith(isEnabled: Boolean, logLevel: LogLevel?): ConfigModel {
+ val config = ConfigModel()
+ config.remoteLoggingParams.isEnabled = isEnabled
+ logLevel?.let { config.remoteLoggingParams.logLevel = it }
+ return config
+}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/ObservabilityConfigEvaluatorTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/ObservabilityConfigEvaluatorTest.kt
new file mode 100644
index 0000000000..bf4c571aa0
--- /dev/null
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/ObservabilityConfigEvaluatorTest.kt
@@ -0,0 +1,102 @@
+package com.onesignal.internal
+
+import com.onesignal.debug.LogLevel
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.types.shouldBeInstanceOf
+
+class ObservabilityConfigEvaluatorTest : FunSpec({
+
+ // ---- null -> enabled ----
+
+ test("null old config and new enabled returns Enable with the configured level") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = null,
+ new = ObservabilityConfig(isEnabled = true, logLevel = LogLevel.WARN),
+ )
+ result.shouldBeInstanceOf()
+ result.logLevel shouldBe LogLevel.WARN
+ }
+
+ test("null old config and new enabled with null logLevel defaults to ERROR") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = null,
+ new = ObservabilityConfig(isEnabled = true, logLevel = null),
+ )
+ result.shouldBeInstanceOf()
+ result.logLevel shouldBe LogLevel.ERROR
+ }
+
+ // ---- null -> disabled ----
+
+ test("null old config and new disabled returns NoChange") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = null,
+ new = ObservabilityConfig(isEnabled = false, logLevel = null),
+ )
+ result shouldBe ObservabilityConfigAction.NoChange
+ }
+
+ // ---- disabled -> enabled ----
+
+ test("disabled to enabled returns Enable") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = ObservabilityConfig.DISABLED,
+ new = ObservabilityConfig(isEnabled = true, logLevel = LogLevel.INFO),
+ )
+ result.shouldBeInstanceOf()
+ result.logLevel shouldBe LogLevel.INFO
+ }
+
+ // ---- enabled -> disabled ----
+
+ test("enabled to disabled returns Disable") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = ObservabilityConfig(isEnabled = true, logLevel = LogLevel.ERROR),
+ new = ObservabilityConfig(isEnabled = false, logLevel = null),
+ )
+ result shouldBe ObservabilityConfigAction.Disable
+ }
+
+ // ---- enabled -> enabled (level changed) ----
+
+ test("enabled to enabled with different log level returns UpdateLogLevel") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = ObservabilityConfig(isEnabled = true, logLevel = LogLevel.ERROR),
+ new = ObservabilityConfig(isEnabled = true, logLevel = LogLevel.WARN),
+ )
+ result.shouldBeInstanceOf()
+ result.oldLevel shouldBe LogLevel.ERROR
+ result.newLevel shouldBe LogLevel.WARN
+ }
+
+ test("enabled with null level to enabled with explicit level returns UpdateLogLevel") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = ObservabilityConfig(isEnabled = true, logLevel = null),
+ new = ObservabilityConfig(isEnabled = true, logLevel = LogLevel.WARN),
+ )
+ result.shouldBeInstanceOf()
+ result.oldLevel shouldBe LogLevel.ERROR
+ result.newLevel shouldBe LogLevel.WARN
+ }
+
+ // ---- enabled -> enabled (same level) ----
+
+ test("enabled to enabled with same level returns NoChange") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = ObservabilityConfig(isEnabled = true, logLevel = LogLevel.ERROR),
+ new = ObservabilityConfig(isEnabled = true, logLevel = LogLevel.ERROR),
+ )
+ result shouldBe ObservabilityConfigAction.NoChange
+ }
+
+ // ---- disabled -> disabled ----
+
+ test("disabled to disabled returns NoChange") {
+ val result = ObservabilityConfigEvaluator.evaluate(
+ old = ObservabilityConfig.DISABLED,
+ new = ObservabilityConfig.DISABLED,
+ )
+ result shouldBe ObservabilityConfigAction.NoChange
+ }
+})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt
deleted file mode 100644
index 6fd5478cdd..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt
+++ /dev/null
@@ -1,102 +0,0 @@
-package com.onesignal.internal
-
-import com.onesignal.debug.LogLevel
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.types.shouldBeInstanceOf
-
-class OtelConfigEvaluatorTest : FunSpec({
-
- // ---- null -> enabled ----
-
- test("null old config and new enabled returns Enable with the configured level") {
- val result = OtelConfigEvaluator.evaluate(
- old = null,
- new = OtelConfig(isEnabled = true, logLevel = LogLevel.WARN),
- )
- result.shouldBeInstanceOf()
- result.logLevel shouldBe LogLevel.WARN
- }
-
- test("null old config and new enabled with null logLevel defaults to ERROR") {
- val result = OtelConfigEvaluator.evaluate(
- old = null,
- new = OtelConfig(isEnabled = true, logLevel = null),
- )
- result.shouldBeInstanceOf()
- result.logLevel shouldBe LogLevel.ERROR
- }
-
- // ---- null -> disabled ----
-
- test("null old config and new disabled returns NoChange") {
- val result = OtelConfigEvaluator.evaluate(
- old = null,
- new = OtelConfig(isEnabled = false, logLevel = null),
- )
- result shouldBe OtelConfigAction.NoChange
- }
-
- // ---- disabled -> enabled ----
-
- test("disabled to enabled returns Enable") {
- val result = OtelConfigEvaluator.evaluate(
- old = OtelConfig.DISABLED,
- new = OtelConfig(isEnabled = true, logLevel = LogLevel.INFO),
- )
- result.shouldBeInstanceOf()
- result.logLevel shouldBe LogLevel.INFO
- }
-
- // ---- enabled -> disabled ----
-
- test("enabled to disabled returns Disable") {
- val result = OtelConfigEvaluator.evaluate(
- old = OtelConfig(isEnabled = true, logLevel = LogLevel.ERROR),
- new = OtelConfig(isEnabled = false, logLevel = null),
- )
- result shouldBe OtelConfigAction.Disable
- }
-
- // ---- enabled -> enabled (level changed) ----
-
- test("enabled to enabled with different log level returns UpdateLogLevel") {
- val result = OtelConfigEvaluator.evaluate(
- old = OtelConfig(isEnabled = true, logLevel = LogLevel.ERROR),
- new = OtelConfig(isEnabled = true, logLevel = LogLevel.WARN),
- )
- result.shouldBeInstanceOf()
- result.oldLevel shouldBe LogLevel.ERROR
- result.newLevel shouldBe LogLevel.WARN
- }
-
- test("enabled with null level to enabled with explicit level returns UpdateLogLevel") {
- val result = OtelConfigEvaluator.evaluate(
- old = OtelConfig(isEnabled = true, logLevel = null),
- new = OtelConfig(isEnabled = true, logLevel = LogLevel.WARN),
- )
- result.shouldBeInstanceOf()
- result.oldLevel shouldBe LogLevel.ERROR
- result.newLevel shouldBe LogLevel.WARN
- }
-
- // ---- enabled -> enabled (same level) ----
-
- test("enabled to enabled with same level returns NoChange") {
- val result = OtelConfigEvaluator.evaluate(
- old = OtelConfig(isEnabled = true, logLevel = LogLevel.ERROR),
- new = OtelConfig(isEnabled = true, logLevel = LogLevel.ERROR),
- )
- result shouldBe OtelConfigAction.NoChange
- }
-
- // ---- disabled -> disabled ----
-
- test("disabled to disabled returns NoChange") {
- val result = OtelConfigEvaluator.evaluate(
- old = OtelConfig.DISABLED,
- new = OtelConfig.DISABLED,
- )
- result shouldBe OtelConfigAction.NoChange
- }
-})
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt
deleted file mode 100644
index 1d8720ec2d..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt
+++ /dev/null
@@ -1,318 +0,0 @@
-package com.onesignal.internal
-
-import android.content.Context
-import android.os.Build
-import androidx.test.core.app.ApplicationProvider
-import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
-import com.onesignal.common.modeling.ModelChangeTags
-import com.onesignal.core.internal.config.ConfigModel
-import com.onesignal.core.internal.features.IFeatureManager
-import com.onesignal.debug.LogLevel
-import com.onesignal.debug.internal.crash.OtelSdkSupport
-import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider
-import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProviderConfig
-import com.onesignal.otel.IOtelCrashHandler
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.IOtelOpenTelemetryRemote
-import com.onesignal.otel.IOtelPlatformProvider
-import com.onesignal.otel.crash.IOtelAnrDetector
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.verify
-import org.robolectric.annotation.Config
-
-/**
- * Fault injection tests that prove all try/catch(Throwable) wrappers in
- * [OtelLifecycleManager] actually catch and suppress exceptions, and that
- * a failure in one feature does not prevent others from starting.
- */
-@RobolectricTest
-@Config(sdk = [Build.VERSION_CODES.O])
-class OtelLifecycleManagerFaultTest : FunSpec({
-
- lateinit var context: Context
- lateinit var mockCrashHandler: IOtelCrashHandler
- lateinit var mockAnrDetector: IOtelAnrDetector
- lateinit var mockTelemetry: IOtelOpenTelemetryRemote
- lateinit var mockLogger: IOtelLogger
- lateinit var mockPlatformProvider: OtelPlatformProvider
- lateinit var mockFeatureManager: IFeatureManager
-
- beforeEach {
- context = ApplicationProvider.getApplicationContext()
- OtelSdkSupport.isSupported = true
-
- mockCrashHandler = mockk(relaxed = true)
- mockAnrDetector = mockk(relaxed = true)
- mockTelemetry = mockk(relaxed = true)
- mockLogger = mockk(relaxed = true)
- mockFeatureManager = mockk().also {
- every { it.enabledFeatureKeys() } returns emptyList()
- }
- mockPlatformProvider = OtelPlatformProvider(
- OtelPlatformProviderConfig(
- crashStoragePath = "/test/path",
- appPackageId = "com.test",
- appVersion = "1.0",
- context = context,
- ),
- featureManagerProvider = { mockFeatureManager },
- )
- }
-
- afterEach {
- OtelSdkSupport.reset()
- Logging.setOtelTelemetry(null) { false }
- }
-
- fun createManager(
- crashFactory: (Context, IOtelLogger, () -> IFeatureManager) -> IOtelCrashHandler = { _, _, _ -> mockCrashHandler },
- anrFactory: (IOtelPlatformProvider, IOtelLogger, Long, Long) -> IOtelAnrDetector = { _, _, _, _ -> mockAnrDetector },
- telemetryFactory: (IOtelPlatformProvider) -> IOtelOpenTelemetryRemote = { mockTelemetry },
- ppFactory: (Context, () -> IFeatureManager) -> OtelPlatformProvider = { _, _ -> mockPlatformProvider },
- ): OtelLifecycleManager =
- OtelLifecycleManager(
- context = context,
- featureManagerProvider = { mockFeatureManager },
- crashHandlerFactory = crashFactory,
- anrDetectorFactory = anrFactory,
- remoteTelemetryFactory = telemetryFactory,
- platformProviderFactory = ppFactory,
- loggerFactory = { mockLogger },
- )
-
- // ------------------------------------------------------------------
- // Factory-level fault injection: factory itself throws
- // ------------------------------------------------------------------
-
- test("crash handler factory throws — ANR and logging still start") {
- var telemetryCreated = false
- val manager = createManager(
- crashFactory = { _, _, _ -> throw RuntimeException("crash factory boom") },
- telemetryFactory = { telemetryCreated = true; mockTelemetry },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockAnrDetector.start() }
- telemetryCreated shouldBe true
- }
-
- test("ANR factory throws — crash handler and logging still start") {
- var telemetryCreated = false
- val manager = createManager(
- anrFactory = { _, _, _, _ -> throw RuntimeException("anr factory boom") },
- telemetryFactory = { telemetryCreated = true; mockTelemetry },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockCrashHandler.initialize() }
- telemetryCreated shouldBe true
- }
-
- test("telemetry factory throws — crash handler and ANR still start") {
- val manager = createManager(
- telemetryFactory = { throw RuntimeException("telemetry factory boom") },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockCrashHandler.initialize() }
- verify(exactly = 1) { mockAnrDetector.start() }
- }
-
- test("all three factories throw — no exception propagates") {
- val manager = createManager(
- crashFactory = { _, _, _ -> throw RuntimeException("crash") },
- anrFactory = { _, _, _, _ -> throw RuntimeException("anr") },
- telemetryFactory = { throw RuntimeException("telemetry") },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- }
-
- // ------------------------------------------------------------------
- // Initialize-level fault injection: object created but init throws
- // ------------------------------------------------------------------
-
- test("crash handler initialize() throws — ANR and logging still start") {
- every { mockCrashHandler.initialize() } throws RuntimeException("init boom")
- var telemetryCreated = false
-
- val manager = createManager(
- telemetryFactory = { telemetryCreated = true; mockTelemetry },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockAnrDetector.start() }
- telemetryCreated shouldBe true
- }
-
- test("ANR detector start() throws — crash handler and logging still start") {
- every { mockAnrDetector.start() } throws RuntimeException("start boom")
- var telemetryCreated = false
-
- val manager = createManager(
- telemetryFactory = { telemetryCreated = true; mockTelemetry },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockCrashHandler.initialize() }
- telemetryCreated shouldBe true
- }
-
- // ------------------------------------------------------------------
- // Disable-level fault injection: shutdown/stop/unregister throws
- // ------------------------------------------------------------------
-
- test("ANR stop() throws during disable — crash unregister and telemetry shutdown still run") {
- every { mockAnrDetector.stop() } throws RuntimeException("stop boom")
-
- val manager = createManager()
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockCrashHandler.unregister() }
- verify(exactly = 1) { mockTelemetry.shutdown() }
- }
-
- test("crash handler unregister() throws during disable — telemetry shutdown still runs") {
- every { mockCrashHandler.unregister() } throws RuntimeException("unregister boom")
-
- val manager = createManager()
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockTelemetry.shutdown() }
- }
-
- test("telemetry shutdown() throws during disable — no exception propagates") {
- every { mockTelemetry.shutdown() } throws RuntimeException("shutdown boom")
-
- val manager = createManager()
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockAnrDetector.stop() }
- verify(exactly = 1) { mockCrashHandler.unregister() }
- }
-
- // ------------------------------------------------------------------
- // Platform provider fault injection
- // ------------------------------------------------------------------
-
- test("platform provider factory throws — initializeFromCachedConfig does not propagate") {
- val manager = createManager(
- ppFactory = { _, _ -> throw RuntimeException("provider boom") },
- )
- manager.initializeFromCachedConfig()
- }
-
- // ------------------------------------------------------------------
- // UpdateLogLevel fault injection
- // ------------------------------------------------------------------
-
- test("telemetry factory throws during log level update — no exception propagates") {
- var callCount = 0
- val manager = createManager(
- telemetryFactory = {
- callCount++
- if (callCount > 1) throw RuntimeException("second create boom")
- mockTelemetry
- },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
- }
-
- // ------------------------------------------------------------------
- // Idempotency: calling enable twice doesn't double-create
- // ------------------------------------------------------------------
-
- test("enable called twice does not create duplicate crash handler or ANR detector") {
- val manager = createManager()
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
-
- verify(exactly = 2) { mockCrashHandler.initialize() }
- verify(exactly = 2) { mockAnrDetector.start() }
- }
-
- // ------------------------------------------------------------------
- // Verify mock interactions in happy path
- // ------------------------------------------------------------------
-
- test("enable creates all three features and disable tears all down") {
- val manager = createManager()
-
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- verify(exactly = 1) { mockCrashHandler.initialize() }
- verify(exactly = 1) { mockAnrDetector.start() }
-
- manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
- verify(exactly = 1) { mockCrashHandler.unregister() }
- verify(exactly = 1) { mockAnrDetector.stop() }
- verify { mockTelemetry.shutdown() }
- }
-
- test("update log level shuts down old telemetry and creates new one") {
- var createCount = 0
- val telemetry1 = mockk(relaxed = true)
- val telemetry2 = mockk(relaxed = true)
- val manager = createManager(
- telemetryFactory = {
- createCount++
- if (createCount == 1) telemetry1 else telemetry2
- },
- )
-
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { telemetry1.shutdown() }
- createCount shouldBe 2
- }
-
- // ------------------------------------------------------------------
- // Error type coverage: OutOfMemoryError, StackOverflowError
- // ------------------------------------------------------------------
-
- test("OutOfMemoryError from factory does not propagate") {
- val manager = createManager(
- crashFactory = { _, _, _ -> throw OutOfMemoryError("oom") },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockAnrDetector.start() }
- }
-
- test("StackOverflowError from factory does not propagate") {
- val manager = createManager(
- anrFactory = { _, _, _, _ -> throw StackOverflowError("stack overflow") },
- )
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
-
- verify(exactly = 1) { mockCrashHandler.initialize() }
- }
-
- // ------------------------------------------------------------------
- // initializeFromCachedConfig fault injection
- // ------------------------------------------------------------------
-
- test("initializeFromCachedConfig catches factory failure and does not propagate") {
- val manager = createManager(
- crashFactory = { _, _, _ -> throw RuntimeException("crash") },
- anrFactory = { _, _, _, _ -> throw RuntimeException("anr") },
- telemetryFactory = { throw RuntimeException("telemetry") },
- )
- manager.initializeFromCachedConfig()
- }
-})
-
-private fun configWith(isEnabled: Boolean, logLevel: LogLevel?): ConfigModel {
- val config = ConfigModel()
- config.remoteLoggingParams.isEnabled = isEnabled
- logLevel?.let { config.remoteLoggingParams.logLevel = it }
- return config
-}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt
deleted file mode 100644
index 5de65dfbc3..0000000000
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt
+++ /dev/null
@@ -1,146 +0,0 @@
-package com.onesignal.internal
-
-import android.content.Context
-import android.os.Build
-import androidx.test.core.app.ApplicationProvider
-import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest
-import com.onesignal.common.modeling.ModelChangeTags
-import com.onesignal.core.internal.config.ConfigModel
-import com.onesignal.core.internal.features.IFeatureManager
-import com.onesignal.debug.LogLevel
-import com.onesignal.debug.internal.crash.OtelSdkSupport
-import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.debug.internal.logging.otel.android.OtelPlatformProvider
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.mockk.every
-import io.mockk.mockk
-import org.robolectric.annotation.Config
-
-@RobolectricTest
-@Config(sdk = [Build.VERSION_CODES.O])
-class OtelLifecycleManagerTest : FunSpec({
- lateinit var context: Context
- lateinit var featureManager: IFeatureManager
-
- fun newManager(
- fm: IFeatureManager = featureManager,
- platformProviderFactory: ((Context, () -> IFeatureManager) -> OtelPlatformProvider)? = null,
- ): OtelLifecycleManager =
- if (platformProviderFactory != null) {
- OtelLifecycleManager(
- context = context,
- featureManagerProvider = { fm },
- platformProviderFactory = platformProviderFactory,
- )
- } else {
- OtelLifecycleManager(context = context, featureManagerProvider = { fm })
- }
-
- beforeEach {
- context = ApplicationProvider.getApplicationContext()
- featureManager = mockk().also {
- every { it.enabledFeatureKeys() } returns emptyList()
- }
- OtelSdkSupport.isSupported = true
- }
-
- afterEach {
- OtelSdkSupport.reset()
- }
-
- test("initializeFromCachedConfig does not crash when SDK unsupported") {
- OtelSdkSupport.isSupported = false
- val manager = newManager()
- manager.initializeFromCachedConfig()
- }
-
- test("initializeFromCachedConfig does not throw on supported SDK") {
- val manager = newManager()
- manager.initializeFromCachedConfig()
- }
-
- test("onModelReplaced does not crash when SDK unsupported") {
- OtelSdkSupport.isSupported = false
- val manager = newManager()
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- }
-
- test("onModelReplaced ignores non-HYDRATE tags") {
- val manager = newManager()
- manager.initializeFromCachedConfig()
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.NORMAL)
- }
-
- test("onModelReplaced enable then disable does not throw") {
- val manager = newManager()
- manager.initializeFromCachedConfig()
-
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
- }
-
- test("onModelReplaced updates log level without throwing") {
- val manager = newManager()
- manager.initializeFromCachedConfig()
-
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
- }
-
- test("onModelReplaced with same config is no-op") {
- val manager = newManager()
- manager.initializeFromCachedConfig()
-
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- }
-
- test("disable clears Otel telemetry from Logging") {
- val manager = newManager()
- manager.initializeFromCachedConfig()
-
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
-
- Logging.info("test message after otel disabled")
- }
-
- test("full lifecycle: init -> enable -> update level -> disable -> re-enable") {
- val manager = newManager()
- manager.initializeFromCachedConfig()
-
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.WARN), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.INFO), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE)
- manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.DEBUG), ModelChangeTags.HYDRATE)
- }
-
- test("FeatureManager supplier is forwarded to the platform provider via the factory") {
- var capturedSupplier: (() -> IFeatureManager)? = null
- val pp = mockk(relaxed = true)
- val fm = mockk()
- every { fm.enabledFeatureKeys() } returns listOf("sdk_background_threading")
-
- val manager = newManager(
- fm = fm,
- platformProviderFactory = { _, supplier ->
- capturedSupplier = supplier
- pp
- },
- )
- manager.initializeFromCachedConfig()
-
- // Supplier was passed through and resolves to the manager wired up in `newManager`.
- capturedSupplier?.invoke() shouldBe fm
- capturedSupplier?.invoke()?.enabledFeatureKeys() shouldBe listOf("sdk_background_threading")
- }
-})
-
-private fun configWith(isEnabled: Boolean, logLevel: LogLevel?): ConfigModel {
- val config = ConfigModel()
- config.remoteLoggingParams.isEnabled = isEnabled
- logLevel?.let { config.remoteLoggingParams.logLevel = it }
- return config
-}
diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/StartupDiagnosticsTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/StartupDiagnosticsTest.kt
index 509154425d..5767c9b82c 100644
--- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/StartupDiagnosticsTest.kt
+++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/StartupDiagnosticsTest.kt
@@ -6,7 +6,7 @@ import com.onesignal.debug.ILogListener
import com.onesignal.debug.LogLevel
import com.onesignal.debug.OneSignalLogEvent
import com.onesignal.debug.internal.logging.Logging
-import com.onesignal.debug.internal.logging.otel.android.getOtelCrashStoragePath
+import com.onesignal.debug.internal.logging.logger.android.getCrashStoragePath
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
@@ -37,13 +37,12 @@ class StartupDiagnosticsTest : FunSpec({
val oneSignal = OneSignalImp()
Logging.logLevel = LogLevel.NONE
- oneSignal.logStartupDiagnostics(context, useLoggerModule = false)
+ oneSignal.logStartupDiagnostics(context)
events.size shouldBe 1
events.single().level shouldBe LogLevel.WARN
- events.single().entry shouldContain "observabilityModule=otel"
- events.single().entry shouldContain "SDK_CUSTOM_LOGGING=false"
+ events.single().entry shouldContain "observabilityModule=logger"
events.single().entry shouldContain "app=com.example@1.2.3"
- events.single().entry shouldContain "crashDir=${getOtelCrashStoragePath(context)}"
+ events.single().entry shouldContain "crashDir=${getCrashStoragePath(context)}"
}
})
diff --git a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt
index fe7c2d958f..b27f4ca20f 100644
--- a/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt
+++ b/OneSignalSDK/onesignal/in-app-messages/src/main/java/com/onesignal/inAppMessages/internal/display/impl/WebViewManager.kt
@@ -208,7 +208,7 @@ internal class WebViewManager(
// benign/recoverable reasons (e.g. JS not yet defined when the activity
// rotates, custom IAM template, partial metadata). The previous
// `getJSONObject("rect")` raised `JSONException` which we caught and logged
- // at ERROR with a full stack trace, flooding OTel/Datadog with non-actionable
+ // at ERROR with a full stack trace, flooding remote logs/Datadog with non-actionable
// alerts. Use `optJSONObject` and `optInt` so missing fields are a structured
// null/sentinel instead, and downgrade the log to a single WARN line.
val rect = jsonObject.optJSONObject("rect")
@@ -231,7 +231,7 @@ internal class WebViewManager(
}
/**
- * Trim [body] to a short, single-line snippet safe for logcat / OTel. See
+ * Trim [body] to a short, single-line snippet safe for logcat / remote logs. See
* SDK-4494 - we only want enough context to debug shape mismatches without
* dumping the full WebView payload into log pipelines.
*/
@@ -704,7 +704,7 @@ internal class WebViewManager(
private val MARGIN_PX_SIZE = ViewUtils.dpToPx(24)
// SDK-4494: cap the body snippet included in WARN logs so a malformed/large
- // WebView payload can't blow up the OTel log entry. Same pattern as
+ // WebView payload can't blow up the remote log entry. Same pattern as
// FeatureFlagsBackendService.
private const val LOG_BODY_SNIPPET_MAX_CHARS = 200
diff --git a/OneSignalSDK/onesignal/otel/.gitignore b/OneSignalSDK/onesignal/otel/.gitignore
deleted file mode 100644
index 796b96d1c4..0000000000
--- a/OneSignalSDK/onesignal/otel/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
diff --git a/OneSignalSDK/onesignal/otel/build.gradle b/OneSignalSDK/onesignal/otel/build.gradle
deleted file mode 100644
index a8d232d39a..0000000000
--- a/OneSignalSDK/onesignal/otel/build.gradle
+++ /dev/null
@@ -1,71 +0,0 @@
-plugins {
- id 'com.android.library'
- id 'kotlin-android'
- id 'com.diffplug.spotless'
- id 'com.vanniktech.maven.publish'
- id 'io.gitlab.arturbosch.detekt'
-}
-
-android {
- namespace 'com.onesignal.otel'
- compileSdkVersion rootProject.buildVersions.compileSdkVersion
-
- defaultConfig {
- minSdkVersion 26
- consumerProguardFiles "consumer-rules.pro"
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- }
-
- buildTypes {
- original {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
- }
- release {
- minifyEnabled false
- }
- unity {
- minifyEnabled false
- }
- }
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = '1.8'
- freeCompilerArgs += ['-module-name', namespace, '-Xskip-metadata-version-check']
- }
-}
-
-ext {
- projectName = "OneSignal SDK Otel"
- // Published for SDK packaging only — not a supported public API for app/wrapper authors.
- projectDescription = "OneSignal Android SDK - OpenTelemetry Module (internal; not for public use)"
-}
-
-dependencies {
- implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion"
-
- implementation platform("io.opentelemetry:opentelemetry-bom:$rootProject.opentelemetryBomVersion")
- implementation('io.opentelemetry:opentelemetry-api')
- implementation('io.opentelemetry:opentelemetry-sdk')
- implementation('io.opentelemetry:opentelemetry-exporter-otlp')
- implementation("io.opentelemetry.semconv:opentelemetry-semconv:$rootProject.opentelemetrySemconvVersion")
- implementation("io.opentelemetry.contrib:opentelemetry-disk-buffering:$rootProject.opentelemetryDiskBufferingVersion")
-
- testImplementation(project(':OneSignal:testhelpers'))
- testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion")
- testImplementation("io.kotest:kotest-runner-junit5-jvm:$kotestVersion")
- testImplementation("io.kotest:kotest-assertions-core:$kotestVersion")
- testImplementation("io.mockk:mockk:$ioMockVersion")
- testImplementation("junit:junit:4.13.2")
- testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
-}
-
-apply from: '../detekt.gradle'
-apply from: '../spotless.gradle'
-apply from: '../maven-push.gradle'
diff --git a/OneSignalSDK/onesignal/otel/consumer-rules.pro b/OneSignalSDK/onesignal/otel/consumer-rules.pro
deleted file mode 100644
index 76b04edace..0000000000
--- a/OneSignalSDK/onesignal/otel/consumer-rules.pro
+++ /dev/null
@@ -1,17 +0,0 @@
-# OpenTelemetry OTLP exporter references Jackson core classes that are optional on Android.
-# Suppress R8 missing-class errors when apps don't include jackson-core.
--dontwarn com.fasterxml.jackson.core.**
-
-# OTel (e.g. sdk-logs AutoValue-generated types) references Google Auto Value annotations that are
-# not on the app classpath. Wildcard covers inner types and extensions (e.g. Memoized).
--dontwarn com.google.auto.value.**
-
-# Suppress R8 missing-class errors from OpenTelemetry version skew. When a host app bumps the
-# transitive opentelemetry-bom, removed internal classes (e.g. io.opentelemetry.api.internal.ApiUsageLogger)
-# leave dangling references from the unused opentelemetry-api-incubator alpha (ExtendedDefaultTracer).
-# R8 suppresses a "Missing class" diagnostic when the MISSING class matches -dontwarn, so match the
-# io.opentelemetry.api.internal package directly (referrer-independent). Scoped to api.internal (not a
-# broad **.internal.** wildcard) so genuine missing-class errors in the sdk/exporter internals that
-# OneSignal actively uses still surface. The incubator rule covers the unused ExtendedDefaultTracer path.
--dontwarn io.opentelemetry.api.incubator.**
--dontwarn io.opentelemetry.api.internal.**
diff --git a/OneSignalSDK/onesignal/otel/proguard-rules.pro b/OneSignalSDK/onesignal/otel/proguard-rules.pro
deleted file mode 100644
index f1b424510d..0000000000
--- a/OneSignalSDK/onesignal/otel/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
diff --git a/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml b/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml
deleted file mode 100644
index 8bdb7e14b3..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt
deleted file mode 100644
index 93b31fc75f..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.onesignal.otel
-
-/**
- * Platform-agnostic crash handler interface.
- * This should be initialized as early as possible and be independent of service architecture.
- */
-interface IOtelCrashHandler {
- /**
- * Initialize the crash handler. This should be called as early as possible,
- * before any other initialization that might crash.
- */
- fun initialize()
-
- /**
- * Unregisters this crash handler, restoring the previous default handler.
- * Safe to call even if [initialize] was never called (no-op in that case).
- */
- fun unregister()
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt
deleted file mode 100644
index 44f6000ba0..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.onesignal.otel
-
-/**
- * Platform-agnostic crash reporter interface.
- */
-interface IOtelCrashReporter {
- /**
- * Records a fatal, crash-class event on the retained, disk-buffered crash telemetry
- * (Severity.FATAL). Use for real crashes and foreground ANRs.
- */
- suspend fun saveCrash(thread: Thread, throwable: Throwable)
-
- /**
- * Records a non-fatal event on the same retained, disk-buffered crash telemetry, but at
- * Severity.WARN and tagged as non-fatal, so it stays out of any severity-based crash/ANR metric
- * while remaining queryable. Use for backgrounded main-thread blocks and other retained warnings
- * that are not user-visible crashes.
- */
- suspend fun saveNonFatal(thread: Thread, throwable: Throwable)
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt
deleted file mode 100644
index 510ffab2eb..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.onesignal.otel
-
-/**
- * Platform-agnostic logger interface for the Otel module.
- * Implementations should be provided by the platform (Android/iOS).
- */
-interface IOtelLogger {
- /**
- * Logs an error message.
- *
- * @param message The error message to log
- */
- fun error(message: String)
-
- /**
- * Logs a warning message.
- *
- * @param message The warning message to log
- */
- fun warn(message: String)
-
- /**
- * Logs an informational message.
- *
- * @param message The info message to log
- */
- fun info(message: String)
-
- /**
- * Logs a debug message.
- *
- * @param message The debug message to log
- */
- fun debug(message: String)
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt
deleted file mode 100644
index 156df29ffd..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.onesignal.otel
-
-import io.opentelemetry.api.logs.LogRecordBuilder
-import io.opentelemetry.sdk.common.CompletableResultCode
-import io.opentelemetry.sdk.logs.export.LogRecordExporter
-
-/**
- * Platform-agnostic OpenTelemetry interface.
- */
-interface IOtelOpenTelemetry {
- /**
- * Gets a LogRecordBuilder for creating log records.
- * This is a suspend function as it may need to initialize the SDK on first call.
- *
- * @return A LogRecordBuilder instance for building log records
- */
- suspend fun getLogger(): LogRecordBuilder
-
- /**
- * Forces a flush of all pending log records.
- * This ensures all buffered logs are exported immediately.
- *
- * @return A CompletableResultCode indicating the flush operation result
- */
- suspend fun forceFlush(): CompletableResultCode
-
- /**
- * Shuts down the underlying OpenTelemetry SDK, flushing pending data
- * and releasing resources (exporters, logger providers, etc.).
- * After this call the instance must not be reused.
- */
- fun shutdown()
-}
-
-/**
- * Interface for crash-specific OpenTelemetry (local file storage).
- */
-interface IOtelOpenTelemetryCrash : IOtelOpenTelemetry
-
-/**
- * Interface for remote OpenTelemetry (network export).
- */
-interface IOtelOpenTelemetryRemote : IOtelOpenTelemetry {
- val logExporter: LogRecordExporter
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt
deleted file mode 100644
index 747b9718de..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-package com.onesignal.otel
-
-/**
- * Platform-agnostic provider interface for injecting platform-specific values.
- * All Android/iOS specific values should be provided through this interface.
- *
- * **SDK-internal only — not a public implementor surface.** Although `:otel` is a
- * published artifact and [OtelFactory] accepts this type, app and wrapper authors
- * must not implement or depend on this interface. The sole production implementor
- * is `OtelPlatformProvider` in `:core`.
- *
- * New abstract members are a deliberate source-compatibility break for any
- * external implementor; there are no known consumers outside the SDK.
- */
-interface IOtelPlatformProvider {
- // Top-level attributes (static, calculated once)
- /**
- * Gets the installation ID for this device.
- * This is an async operation as it may need to generate a new ID if one doesn't exist.
- *
- * @return The installation ID as a string
- */
- suspend fun getInstallId(): String
- val sdkBase: String
- val sdkBaseVersion: String
- val appPackageId: String
- val appVersion: String
- val deviceManufacturer: String
- val deviceModel: String
- val osName: String
- val osVersion: String
- val osBuildId: String
- val sdkWrapper: String?
- val sdkWrapperVersion: String?
-
- /**
- * Kotlin language / stdlib version of the host app when applicable
- * (e.g. [KotlinVersion.CURRENT]). Null on non-Kotlin hosts. Emitted as
- * `ossdk.kotlin_version` when non-blank.
- *
- * SDK-internal injection seam only — the sole production implementor is
- * `OtelPlatformProvider` in `:core`. Not a public compatibility surface for
- * app or wrapper authors.
- */
- val kotlinVersion: String?
-
- /**
- * Swift language version when applicable. Null on Android / non-Swift hosts.
- * Emitted as `ossdk.swift_version` when non-blank.
- *
- * Same SDK-internal caveat as [kotlinVersion].
- */
- val swiftVersion: String?
-
- /**
- * Extra static version labels for dashboard filtering (`ossdk.`).
- * Dedicated [kotlinVersion] / [swiftVersion] and core resource attrs win on clash.
- *
- * Same SDK-internal caveat as [kotlinVersion].
- */
- val additionalVersionAttributes: Map
-
- /**
- * The canonical keys of feature flags currently enabled for this device, as resolved by
- * the platform's feature flag source (on Android, a constructor-injected `IFeatureManager`).
- * Read fresh on every access so per-event OTel attributes always reflect the current state.
- *
- * Empty when no flags are enabled or the platform source returns/throws nothing usable.
- * Defaults to an empty list so existing platform implementations remain source/binary
- * compatible — platforms that want to populate `ossdk.feature_flags` should override.
- *
- * The order is not guaranteed; consumers that need a deterministic encoding (e.g. for
- * stable log payloads) should sort before serializing.
- */
- val enabledFeatureFlags: List
- get() = emptyList()
-
- // Per-event attributes (dynamic, calculated per event)
- val appId: String?
- val onesignalId: String?
- val pushSubscriptionId: String?
- val appState: String // "foreground" or "background"
- val processUptime: Long // in milliseconds
- val currentThreadName: String
-
- // Crash-specific configuration
- val crashStoragePath: String
- val minFileAgeForReadMillis: Long
-
- // Remote logging configuration
- /**
- * Whether remote logging (crash reporting, ANR detection, remote log shipping) is enabled.
- * Derived from the presence of a valid log_level in logging_config:
- * - "logging_config": {} → false (not on allowlist)
- * - "logging_config": {"log_level": "ERROR"} → true (on allowlist)
- * Defaults to false on first launch (before remote config is cached).
- */
- val isRemoteLoggingEnabled: Boolean
-
- /**
- * The minimum log level to send remotely as a string (e.g., "ERROR", "WARN").
- * Null when logging_config is empty or not yet cached (disabled).
- * Valid values: "NONE", "FATAL", "ERROR", "WARN", "INFO", "DEBUG", "VERBOSE"
- */
- val remoteLogLevel: String?
-
- /**
- * Debug-only toggle for local exporter diagnostics.
- * When true, Otel exporter request/response success/failure logs are emitted to logcat.
- */
- val isOtelExporterLoggingEnabled: Boolean
-
- val appIdForHeaders: String
-
- /**
- * Base URL for the OneSignal API (e.g. "https://api.onesignal.com").
- * The Otel exporter appends "sdk/otel/v1/logs" to this.
- * Sourced from the core module so all SDK traffic hits the same host.
- */
- val apiBaseUrl: String
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt
deleted file mode 100644
index ea66980ab3..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-package com.onesignal.otel
-
-import com.onesignal.otel.attributes.OtelFieldsPerEvent
-import com.onesignal.otel.attributes.OtelFieldsTopLevel
-import com.onesignal.otel.config.OtelConfigCrashFile
-import com.onesignal.otel.config.OtelConfigRemoteOneSignal
-import com.onesignal.otel.config.OtelConfigShared
-import io.opentelemetry.api.logs.LogRecordBuilder
-import io.opentelemetry.sdk.OpenTelemetrySdk
-import io.opentelemetry.sdk.common.CompletableResultCode
-import java.util.concurrent.TimeUnit
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-
-internal fun LogRecordBuilder.setAllAttributes(attributes: Map): LogRecordBuilder {
- attributes.forEach { this.setAttribute(it.key, it.value) }
- return this
-}
-
-internal abstract class OneSignalOpenTelemetryBase(
- private val osTopLevelFields: OtelFieldsTopLevel,
- private val osPerEventFields: OtelFieldsPerEvent,
-) : IOtelOpenTelemetry {
- private val lock = Any()
- private var sdkCachedValue: OpenTelemetrySdk? = null
-
- protected suspend fun getSdk(): OpenTelemetrySdk {
- val attributes = osTopLevelFields.getAttributes()
- synchronized(lock) {
- var localSdk = sdkCachedValue
- if (localSdk != null) {
- return localSdk
- }
-
- localSdk = getSdkInstance(attributes)
- sdkCachedValue = localSdk
- return localSdk
- }
- }
-
- protected abstract fun getSdkInstance(attributes: Map): OpenTelemetrySdk
-
- override suspend fun forceFlush(): CompletableResultCode {
- val sdkLoggerProvider = getSdk().sdkLoggerProvider
- return suspendCoroutine {
- it.resume(
- sdkLoggerProvider.forceFlush().join(FORCE_FLUSH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
- )
- }
- }
-
- @Suppress("TooGenericExceptionCaught")
- override fun shutdown() {
- synchronized(lock) {
- try {
- sdkCachedValue?.shutdown()
- } catch (_: Throwable) {
- // Best-effort cleanup — never propagate Otel teardown failures
- }
- sdkCachedValue = null
- }
- }
-
- companion object {
- private const val FORCE_FLUSH_TIMEOUT_SECONDS = 10L
- }
-
- override suspend fun getLogger(): LogRecordBuilder =
- getSdk()
- .sdkLoggerProvider
- .loggerBuilder("loggerBuilder")
- .build()
- .logRecordBuilder()
- .setAllAttributes(osPerEventFields.getAttributes())
-}
-
-internal class OneSignalOpenTelemetryRemote(
- private val platformProvider: IOtelPlatformProvider,
- osTopLevelFields: OtelFieldsTopLevel,
- osPerEventFields: OtelFieldsPerEvent,
-) : OneSignalOpenTelemetryBase(osTopLevelFields, osPerEventFields),
- IOtelOpenTelemetryRemote {
-
- private val appId: String get() = platformProvider.appIdForHeaders
-
- val extraHttpHeaders: Map by lazy {
- mapOf(
- "SDK-Version" to "onesignal/${platformProvider.sdkBase}/${platformProvider.sdkBaseVersion}",
- )
- }
-
- private val apiBaseUrl: String get() = platformProvider.apiBaseUrl
-
- override val logExporter by lazy {
- OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(
- extraHttpHeaders,
- appId,
- apiBaseUrl,
- platformProvider.isOtelExporterLoggingEnabled,
- )
- }
-
- override fun getSdkInstance(attributes: Map): OpenTelemetrySdk =
- OpenTelemetrySdk
- .builder()
- .setLoggerProvider(
- OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create(
- OtelConfigShared.ResourceConfig.create(attributes),
- extraHttpHeaders,
- appId,
- apiBaseUrl,
- platformProvider.isOtelExporterLoggingEnabled,
- )
- ).build()
-}
-
-internal class OneSignalOpenTelemetryCrashLocal(
- private val platformProvider: IOtelPlatformProvider,
- osTopLevelFields: OtelFieldsTopLevel,
- osPerEventFields: OtelFieldsPerEvent,
-) : OneSignalOpenTelemetryBase(osTopLevelFields, osPerEventFields),
- IOtelOpenTelemetryCrash {
- override fun getSdkInstance(attributes: Map): OpenTelemetrySdk =
- OpenTelemetrySdk
- .builder()
- .setLoggerProvider(
- OtelConfigCrashFile.SdkLoggerProviderConfig.create(
- OtelConfigShared.ResourceConfig.create(
- attributes
- ),
- platformProvider.crashStoragePath,
- platformProvider.minFileAgeForReadMillis,
- )
- ).build()
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt
deleted file mode 100644
index aba1539ec4..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-package com.onesignal.otel
-
-import com.onesignal.otel.attributes.OtelFieldsPerEvent
-import com.onesignal.otel.attributes.OtelFieldsTopLevel
-import com.onesignal.otel.crash.OtelCrashHandler
-import com.onesignal.otel.crash.OtelCrashReporter
-import com.onesignal.otel.crash.OtelCrashUploader
-
-/**
- * Factory class for creating Otel components.
- * This allows for fast initialization of the crash handler with all dependencies
- * pre-populated.
- *
- * SDK-internal only — not for public use by app or wrapper authors. Callers must
- * supply an [IOtelPlatformProvider] owned by the SDK (see that type's KDoc).
- */
-object OtelFactory {
- /**
- * Creates a fully configured crash handler that can be initialized immediately.
- * All fields are pre-populated for fast initialization.
- *
- * This method composes other factory methods to create the crash handler,
- * ensuring consistency and reducing duplication.
- */
- fun createCrashHandler(
- platformProvider: IOtelPlatformProvider,
- logger: IOtelLogger,
- ): IOtelCrashHandler {
- val crashLocal = createCrashLocalTelemetry(platformProvider)
- val crashReporter = createCrashReporter(crashLocal, logger)
- return OtelCrashHandler(crashReporter, logger)
- }
-
- /**
- * Creates a crash uploader for sending crash reports to the server.
- *
- * This is platform-agnostic and can be used in KMP projects.
- * All platform-specific values must be provided through IOtelPlatformProvider.
- *
- * @param platformProvider Platform-specific provider that injects all required values
- * @param logger Platform-specific logger implementation
- * @return Platform-agnostic crash uploader that can be used on Android/iOS
- */
- fun createCrashUploader(
- platformProvider: IOtelPlatformProvider,
- logger: IOtelLogger,
- ): OtelCrashUploader {
- val topLevelFields = OtelFieldsTopLevel(platformProvider)
- val perEventFields = OtelFieldsPerEvent(platformProvider)
- val remote = OneSignalOpenTelemetryRemote(
- platformProvider,
- topLevelFields,
- perEventFields
- )
- return OtelCrashUploader(remote, platformProvider, logger)
- }
-
- /**
- * Creates a remote OpenTelemetry instance for logging SDK events.
- *
- * This is platform-agnostic and can be used in KMP projects.
- * All platform-specific values must be provided through IOtelPlatformProvider.
- *
- * @param platformProvider Platform-specific provider that injects all required values
- * @return Platform-agnostic remote telemetry instance for logging
- */
- fun createRemoteTelemetry(
- platformProvider: IOtelPlatformProvider,
- ): IOtelOpenTelemetryRemote {
- val topLevelFields = OtelFieldsTopLevel(platformProvider)
- val perEventFields = OtelFieldsPerEvent(platformProvider)
- return OneSignalOpenTelemetryRemote(
- platformProvider,
- topLevelFields,
- perEventFields
- )
- }
-
- /**
- * Creates a local OpenTelemetry crash instance for saving crash reports locally.
- *
- * This is platform-agnostic and can be used in KMP projects.
- * All platform-specific values must be provided through IOtelPlatformProvider.
- *
- * @param platformProvider Platform-specific provider that injects all required values
- * @return Platform-agnostic crash local telemetry instance
- */
- fun createCrashLocalTelemetry(
- platformProvider: IOtelPlatformProvider,
- ): IOtelOpenTelemetryCrash {
- val topLevelFields = OtelFieldsTopLevel(platformProvider)
- val perEventFields = OtelFieldsPerEvent(platformProvider)
- return OneSignalOpenTelemetryCrashLocal(
- platformProvider,
- topLevelFields,
- perEventFields
- )
- }
-
- /**
- * Creates a crash reporter for saving crash reports.
- *
- * This is platform-agnostic and can be used in KMP projects.
- *
- * @param openTelemetryCrash The crash telemetry instance to use
- * @param logger Platform-specific logger implementation
- * @return Platform-agnostic crash reporter
- */
- fun createCrashReporter(
- openTelemetryCrash: IOtelOpenTelemetryCrash,
- logger: IOtelLogger,
- ): IOtelCrashReporter {
- return OtelCrashReporter(openTelemetryCrash, logger)
- }
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt
deleted file mode 100644
index 8b1c85c7b0..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.onesignal.otel
-
-import io.opentelemetry.api.common.Attributes
-import io.opentelemetry.api.logs.Severity
-import java.time.Instant
-
-/**
- * Helper class for logging to Otel from the Logging class.
- * This abstracts away OpenTelemetry-specific types so the core module
- * doesn't need direct OpenTelemetry dependencies.
- */
-object OtelLoggingHelper {
- /**
- * Logs a message to Otel remote telemetry.
- * This method handles all OpenTelemetry-specific types internally.
- *
- * @param telemetry The Otel remote telemetry instance
- * @param level The log level as a string (VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL)
- * @param message The log message
- * @param exceptionType Optional exception type
- * @param exceptionMessage Optional exception message
- * @param exceptionStacktrace Optional exception stacktrace
- */
- suspend fun logToOtel(
- telemetry: IOtelOpenTelemetryRemote,
- level: String,
- message: String,
- exceptionType: String? = null,
- exceptionMessage: String? = null,
- exceptionStacktrace: String? = null,
- ) {
- val severity = when (level.uppercase()) {
- "VERBOSE" -> Severity.TRACE
- "DEBUG" -> Severity.DEBUG
- "INFO" -> Severity.INFO
- "WARN" -> Severity.WARN
- "ERROR" -> Severity.ERROR
- "FATAL" -> Severity.FATAL
- else -> Severity.INFO
- }
-
- val attributes = Attributes.builder()
- .put("log.message", message)
- .put("log.level", level)
- .apply {
- if (exceptionType != null) {
- put("exception.type", exceptionType)
- }
- if (exceptionMessage != null) {
- put("exception.message", exceptionMessage)
- }
- if (exceptionStacktrace != null) {
- put("exception.stacktrace", exceptionStacktrace)
- }
- }
- .build()
-
- val logRecordBuilder = telemetry.getLogger()
- logRecordBuilder.setAllAttributes(attributes)
- logRecordBuilder.setSeverity(severity)
- logRecordBuilder.setBody(message)
- logRecordBuilder.setTimestamp(Instant.now())
- logRecordBuilder.emit()
- }
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt
deleted file mode 100644
index 0e7a4b825d..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.onesignal.otel.attributes
-
-import com.onesignal.otel.IOtelPlatformProvider
-import com.squareup.wire.internal.toUnmodifiableMap
-import java.util.UUID
-
-/**
- * Purpose: Fields to be included in each individual Otel event.
- * These can change during runtime.
- */
-internal class OtelFieldsPerEvent(
- private val platformProvider: IOtelPlatformProvider,
-) {
- fun getAttributes(): Map {
- val attributes: MutableMap = mutableMapOf()
-
- attributes["log.record.uid"] = recordId.toString()
-
- attributes
- .putIfValueNotNull("ossdk.app_id", platformProvider.appId)
- .putIfValueNotNull("ossdk.onesignal_id", platformProvider.onesignalId)
- .putIfValueNotNull("ossdk.push_subscription_id", platformProvider.pushSubscriptionId)
-
- // Use platform-agnostic attribute name (works for both Android and iOS)
- attributes["app.state"] = platformProvider.appState
- attributes["process.uptime"] = platformProvider.processUptime.toString()
- attributes["thread.name"] = platformProvider.currentThreadName
-
- // Encode the currently-enabled feature flag keys as a single sorted, comma-separated
- // string. Read fresh on every emission so each record reflects the FeatureManager view
- // at the moment the log was written — no SDK rebuild required for IMMEDIATE-mode flag
- // changes. Easily queryable in Google Cloud Logs Explorer via the `:` (contains)
- // operator; omitted entirely when no flags are enabled to keep payloads compact.
- val enabledFlags = platformProvider.enabledFeatureFlags
- if (enabledFlags.isNotEmpty()) {
- attributes["ossdk.feature_flags"] = enabledFlags.sorted().joinToString(",")
- }
-
- return attributes.toUnmodifiableMap()
- }
-
- // idempotency so the backend can filter on duplicate events
- // https://opentelemetry.io/docs/specs/semconv/general/logs/#general-log-identification-attributes
- private val recordId: UUID get() = UUID.randomUUID()
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt
deleted file mode 100644
index 0f3be7d517..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package com.onesignal.otel.attributes
-
-import com.onesignal.otel.IOtelPlatformProvider
-import com.squareup.wire.internal.toUnmodifiableMap
-
-/**
- * Purpose: Fields to be included in every Otel request that goes out.
- * Requirements: Only include fields that can NOT change during runtime,
- * as these are only fetched once. (Calculated fields are ok)
- *
- * Optional host language / toolchain versions (`ossdk.kotlin_version`,
- * `ossdk.swift_version`, plus any `additionalVersionAttributes`) ride along so
- * dashboards can filter by the app's language stack — mirrors KMP LogFieldsTopLevel.
- */
-internal class OtelFieldsTopLevel(
- private val platformProvider: IOtelPlatformProvider,
-) {
- suspend fun getAttributes(): Map {
- val attributes: MutableMap = mutableMapOf()
-
- // Extras first so dedicated / core fields below always win on clash.
- for ((key, value) in platformProvider.additionalVersionAttributes) {
- val suffix = normalizeOssdkAttributeSuffix(key)
- if (suffix.isNotEmpty() && !value.isNullOrBlank()) {
- attributes["ossdk.$suffix"] = value
- }
- }
-
- attributes.putAll(
- mapOf(
- "ossdk.install_id" to platformProvider.getInstallId(),
- "ossdk.sdk_base" to platformProvider.sdkBase,
- "ossdk.sdk_base_version" to platformProvider.sdkBaseVersion,
- "ossdk.app_package_id" to platformProvider.appPackageId,
- "ossdk.app_version" to platformProvider.appVersion,
- "device.manufacturer" to platformProvider.deviceManufacturer,
- "device.model.identifier" to platformProvider.deviceModel,
- "os.name" to platformProvider.osName,
- "os.version" to platformProvider.osVersion,
- "os.build_id" to platformProvider.osBuildId,
- ),
- )
-
- attributes
- .putIfValueNotNull("ossdk.sdk_wrapper", platformProvider.sdkWrapper)
- .putIfValueNotNull("ossdk.sdk_wrapper_version", platformProvider.sdkWrapperVersion)
- .putIfValueNotBlank("ossdk.kotlin_version", platformProvider.kotlinVersion)
- .putIfValueNotBlank("ossdk.swift_version", platformProvider.swiftVersion)
-
- return attributes.toUnmodifiableMap()
- }
-}
-
-internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap {
- if (value != null) {
- this[key] = value
- }
- return this
-}
-
-/** Like [putIfValueNotNull], but also skips blank strings so filter attrs stay sparse. */
-internal fun MutableMap.putIfValueNotBlank(
- key: String,
- value: String?,
-): MutableMap {
- if (!value.isNullOrBlank()) {
- this[key] = value
- }
- return this
-}
-
-/**
- * Hosts may pass bare suffixes (`java_version`) or accidentally include the
- * `ossdk.` prefix (with optional surrounding whitespace); trim first so
- * `" ossdk.foo"` does not become `ossdk.ossdk.foo`.
- */
-internal fun normalizeOssdkAttributeSuffix(key: String): String =
- key.trim().removePrefix("ossdk.").trim()
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt
deleted file mode 100644
index aa99748589..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.onesignal.otel.config
-
-import io.opentelemetry.contrib.disk.buffering.exporters.LogRecordToDiskExporter
-import io.opentelemetry.contrib.disk.buffering.storage.impl.FileLogRecordStorage
-import io.opentelemetry.contrib.disk.buffering.storage.impl.FileStorageConfiguration
-import io.opentelemetry.sdk.logs.SdkLoggerProvider
-import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor
-import java.io.File
-import kotlin.time.Duration.Companion.hours
-
-internal class OtelConfigCrashFile {
- internal object SdkLoggerProviderConfig {
- // NOTE: Only use such as small maxFileAgeForWrite for
- // crashes, as we want to send them as soon as possible
- // without having to wait too long for buffers.
- private const val MAX_FILE_AGE_FOR_WRITE_MILLIS = 2_000L
-
- fun getFileLogRecordStorage(
- rootDir: String,
- minFileAgeForReadMillis: Long
- ): FileLogRecordStorage =
- FileLogRecordStorage.create(
- File(rootDir),
- FileStorageConfiguration
- .builder()
- .setMaxFileAgeForWriteMillis(MAX_FILE_AGE_FOR_WRITE_MILLIS)
- .setMinFileAgeForReadMillis(minFileAgeForReadMillis)
- .setMaxFileAgeForReadMillis(72.hours.inWholeMilliseconds)
- .build()
- )
-
- fun create(
- resource: io.opentelemetry.sdk.resources.Resource,
- rootDir: String,
- minFileAgeForReadMillis: Long,
- ): SdkLoggerProvider {
- val logToDiskExporter =
- LogRecordToDiskExporter
- .builder(getFileLogRecordStorage(rootDir, minFileAgeForReadMillis))
- .build()
- return SdkLoggerProvider
- .builder()
- .setResource(resource)
- .addLogRecordProcessor(
- BatchLogRecordProcessor.builder(logToDiskExporter).build()
- ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits)
- .build()
- }
- }
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt
deleted file mode 100644
index 8ecbcbf905..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt
+++ /dev/null
@@ -1,134 +0,0 @@
-package com.onesignal.otel.config
-
-import android.util.Log
-import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter
-import io.opentelemetry.sdk.common.CompletableResultCode
-import io.opentelemetry.sdk.logs.SdkLoggerProvider
-import io.opentelemetry.sdk.logs.data.LogRecordData
-import io.opentelemetry.sdk.logs.export.LogRecordExporter
-import java.time.Duration
-
-internal class OtelConfigRemoteOneSignal {
- companion object {
- const val OTEL_PATH = "sdk"
-
- fun buildEndpoint(apiBaseUrl: String, appId: String): String =
- "$apiBaseUrl$OTEL_PATH/log?app_id=$appId"
- }
-
- object LogRecordExporterConfig {
- private const val EXPORTER_TIMEOUT_SECONDS = 10L
-
- fun otlpHttpLogRecordExporter(
- headers: Map,
- endpoint: String,
- ): LogRecordExporter {
- val builder = OtlpHttpLogRecordExporter.builder()
- headers.forEach { builder.addHeader(it.key, it.value) }
- builder
- .setEndpoint(endpoint)
- .setTimeout(Duration.ofSeconds(EXPORTER_TIMEOUT_SECONDS))
- return builder.build()
- }
- }
-
- object SdkLoggerProviderConfig {
- fun create(
- resource: io.opentelemetry.sdk.resources.Resource,
- extraHttpHeaders: Map,
- appId: String,
- apiBaseUrl: String,
- enableExporterLogging: Boolean
- ): SdkLoggerProvider =
- SdkLoggerProvider
- .builder()
- .setResource(resource)
- .addLogRecordProcessor(
- OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor(
- HttpRecordBatchExporter.create(
- extraHttpHeaders,
- appId,
- apiBaseUrl,
- enableExporterLogging,
- )
- )
- ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits)
- .build()
- }
-
- object HttpRecordBatchExporter {
- fun create(
- extraHttpHeaders: Map,
- appId: String,
- apiBaseUrl: String,
- enableExporterLogging: Boolean,
- ): LogRecordExporter {
- val exporter =
- LogRecordExporterConfig.otlpHttpLogRecordExporter(
- extraHttpHeaders,
- buildEndpoint(apiBaseUrl, appId)
- )
-
- return if (enableExporterLogging) {
- ExporterLoggingConfig.loggingExporter(exporter)
- } else {
- exporter
- }
- }
- }
-
- object ExporterLoggingConfig {
- private const val TAG = "OneSignalOtel"
-
- fun loggingExporter(delegate: LogRecordExporter): LogRecordExporter = LoggingLogRecordExporter(delegate)
-
- private class LoggingLogRecordExporter(
- private val delegate: LogRecordExporter
- ) : LogRecordExporter {
- @Suppress("TooGenericExceptionCaught")
- private fun resolveHttpFailureMessage(throwable: Throwable?): String {
- if (throwable == null) return "unknown"
-
- return try {
- if (!throwable.javaClass.name.endsWith("FailedExportException\$HttpExportException")) {
- return throwable.message ?: "unknown"
- }
-
- val response = throwable.javaClass.getMethod("getResponse").invoke(throwable) ?: return throwable.message ?: "unknown"
- val statusCode = response.javaClass.getMethod("statusCode").invoke(response)
- val statusMessage = response.javaClass.getMethod("statusMessage").invoke(response)
- val responseBodyBytes = response.javaClass.getMethod("responseBody").invoke(response) as? ByteArray
- val responseBody = responseBodyBytes?.decodeToString()
-
- "status=$statusCode message=$statusMessage" +
- (if (responseBody.isNullOrBlank()) "" else " body=$responseBody")
- } catch (_: Throwable) {
- throwable.message ?: "unknown"
- }
- }
-
- override fun export(logs: Collection): CompletableResultCode {
- Log.d(TAG, "OTEL export request sent to backend. count=${logs.size}")
- val result = delegate.export(logs)
- result.whenComplete {
- if (result.isSuccess) {
- Log.d(TAG, "OTEL export response received: success")
- } else {
- val throwable = result.failureThrowable
- val failureMessage = resolveHttpFailureMessage(throwable)
- Log.e(
- TAG,
- "OTEL export response received: failed - $failureMessage",
- throwable
- )
- }
- }
- return result
- }
-
- override fun flush(): CompletableResultCode = delegate.flush()
-
- override fun shutdown(): CompletableResultCode = delegate.shutdown()
- }
- }
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt
deleted file mode 100644
index f54b3d5590..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package com.onesignal.otel.config
-
-import io.opentelemetry.sdk.logs.LogLimits
-import io.opentelemetry.sdk.logs.LogRecordProcessor
-import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor
-import io.opentelemetry.sdk.logs.export.LogRecordExporter
-import io.opentelemetry.sdk.resources.Resource
-import io.opentelemetry.sdk.resources.ResourceBuilder
-import io.opentelemetry.semconv.ServiceAttributes
-import java.time.Duration
-
-internal fun ResourceBuilder.putAll(attributes: Map): ResourceBuilder {
- attributes.forEach { this.put(it.key, it.value) }
- return this
-}
-
-internal class OtelConfigShared {
- object ResourceConfig {
- fun create(attributes: Map): Resource =
- Resource
- .getDefault()
- .toBuilder()
- .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK")
- .putAll(attributes)
- .build()
- }
-
- object LogRecordProcessorConfig {
- private const val MAX_QUEUE_SIZE = 100
- private const val MAX_EXPORT_BATCH_SIZE = 100
- private const val EXPORTER_TIMEOUT_SECONDS = 30L
- private const val SCHEDULE_DELAY_SECONDS = 1L
-
- fun batchLogRecordProcessor(logRecordExporter: LogRecordExporter): LogRecordProcessor =
- BatchLogRecordProcessor
- .builder(logRecordExporter)
- .setMaxQueueSize(MAX_QUEUE_SIZE)
- .setMaxExportBatchSize(MAX_EXPORT_BATCH_SIZE)
- .setExporterTimeout(Duration.ofSeconds(EXPORTER_TIMEOUT_SECONDS))
- .setScheduleDelay(Duration.ofSeconds(SCHEDULE_DELAY_SECONDS))
- .build()
- }
-
- object LogLimitsConfig {
- private const val MAX_NUMBER_OF_ATTRIBUTES = 128
-
- // We want a high value max length as the exception.stacktrace
- // value can be lengthly.
- private const val MAX_ATTRIBUTE_VALUE_LENGTH = 32000
-
- fun logLimits(): LogLimits =
- LogLimits
- .builder()
- .setMaxNumberOfAttributes(MAX_NUMBER_OF_ATTRIBUTES)
- .setMaxAttributeValueLength(MAX_ATTRIBUTE_VALUE_LENGTH)
- .build()
- }
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt
deleted file mode 100644
index b7b5027ee8..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.onesignal.otel.crash
-
-/**
- * Platform-agnostic interface for ANR (Application Not Responding) detection.
- *
- * ANRs occur when the main thread is blocked for too long (typically >5 seconds on Android).
- * Unlike crashes, ANRs don't throw exceptions - they're detected by monitoring thread responsiveness.
- */
-interface IOtelAnrDetector {
- /**
- * Starts monitoring for ANRs.
- * This should be called early in the app lifecycle, ideally right after crash handler initialization.
- */
- fun start()
-
- /**
- * Stops monitoring for ANRs.
- * Should be called when the app is shutting down or when monitoring is no longer needed.
- */
- fun stop()
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt
deleted file mode 100644
index 9581c069ea..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt
+++ /dev/null
@@ -1,127 +0,0 @@
-package com.onesignal.otel.crash
-
-import com.onesignal.otel.IOtelCrashReporter
-import com.onesignal.otel.IOtelLogger
-import kotlinx.coroutines.runBlocking
-
-/**
- * Purpose: Writes any crashes involving OneSignal to a file where they can
- * later be send to OneSignal to help improve reliability.
- * NOTE: For future refactors, code is written assuming this is a singleton
- *
- * This should be initialized as early as possible, before any other initialization
- * that might crash. All fields must be pre-populated before initialization.
- */
-internal class OtelCrashHandler(
- private val crashReporter: IOtelCrashReporter,
- private val logger: IOtelLogger,
-) : Thread.UncaughtExceptionHandler, com.onesignal.otel.IOtelCrashHandler {
- private var existingHandler: Thread.UncaughtExceptionHandler? = null
- private val seenThrowables: MutableList = mutableListOf()
-
- @Volatile
- private var initialized = false
-
- override fun initialize() {
- if (initialized) {
- logger.warn("OtelCrashHandler already initialized, skipping")
- return
- }
- logger.info("OtelCrashHandler: Setting up uncaught exception handler...")
- existingHandler = Thread.getDefaultUncaughtExceptionHandler()
- Thread.setDefaultUncaughtExceptionHandler(this)
- initialized = true
- logger.info("OtelCrashHandler: ✅ Successfully initialized and registered as default uncaught exception handler")
- }
-
- override fun unregister() {
- if (!initialized) {
- logger.debug("OtelCrashHandler: Not initialized, nothing to unregister")
- return
- }
- logger.info("OtelCrashHandler: Unregistering — restoring previous exception handler")
- Thread.setDefaultUncaughtExceptionHandler(existingHandler)
- existingHandler = null
- initialized = false
- }
-
- override fun uncaughtException(thread: Thread, throwable: Throwable) {
- // Ensure we never attempt to process the same throwable instance
- // more than once. This would only happen if there was another crash
- // handler and was faulty in a specific way.
- synchronized(seenThrowables) {
- if (seenThrowables.contains(throwable)) {
- logger.warn("OtelCrashHandler: Ignoring duplicate throwable instance")
- return
- }
- seenThrowables.add(throwable)
- }
-
- logger.info("OtelCrashHandler: Uncaught exception detected - ${throwable.javaClass.simpleName}: ${throwable.message}")
-
- // Check if this is an ANR exception (though standalone ANR detector already handles ANRs)
- // This would only catch ANRs if they're thrown as exceptions, which is rare
- val isAnr = throwable.javaClass.simpleName.contains("ApplicationNotResponding", ignoreCase = true) ||
- throwable.message?.contains("Application Not Responding", ignoreCase = true) == true
-
- // NOTE: Future improvements:
- // - Catch anything we may throw and print only to logcat
- // - Send a stop command to OneSignalCrashUploader, give a bit of time to finish
- // and then call existingHandler. This way the app doesn't have to open a 2nd
- // time to get the crash report and should help prevent duplicated reports.
- // NOTE: ANRs are typically detected by the standalone OtelAnrDetector, which only
- // reports OneSignal-related ANRs. This handler would only catch ANRs if they're
- // thrown as exceptions (unlikely), and we still check if OneSignal is at fault.
- if (!isAnr && !isOneSignalAtFault(throwable)) {
- logger.debug("OtelCrashHandler: Crash is not OneSignal-related, delegating to existing handler")
- existingHandler?.uncaughtException(thread, throwable)
- return
- }
-
- if (isAnr) {
- logger.info("OtelCrashHandler: ANR exception caught (unusual - ANRs are usually detected by standalone detector)")
- }
-
- logger.info("OtelCrashHandler: OneSignal-related crash detected, saving crash report...")
-
- /**
- * NOTE: The order and running sequentially is important as:
- * The existingHandler.uncaughtException can immediately terminate the
- * process, either directly (if this is Android's
- * KillApplicationHandler) OR the app's handler / 3rd party SDK (either
- * directly or more likely, by it calling Android's
- * KillApplicationHandler).
- * Given this, we can't parallelize the existingHandler work with ours.
- * The safest thing is to try to finish our work as fast as possible
- * (including ensuring our logging write buffers are flushed) then call
- * the existingHandler so any crash handlers the app also has gets the
- * crash even too.
- *
- * NOTE: addShutdownHook() isn't a workaround as it doesn't fire for
- * Process.killProcess, which KillApplicationHandler calls.
- */
- try {
- runBlocking { crashReporter.saveCrash(thread, throwable) }
- logger.info("OtelCrashHandler: Crash report saved successfully")
- } catch (t: Throwable) {
- logger.error("OtelCrashHandler: Failed to save crash report: ${t.message} - ${t.javaClass.simpleName}")
- }
- logger.info("OtelCrashHandler: Delegating to existing crash handler")
- existingHandler?.uncaughtException(thread, throwable)
- }
-}
-
-/**
- * Checks if a throwable's stack trace indicates OneSignal is at fault.
- * Centralized logic used by both crash handler and ANR detector.
- */
-internal fun isOneSignalAtFault(throwable: Throwable): Boolean =
- isOneSignalAtFault(throwable.stackTrace)
-
-/**
- * Helper function to check if a stack trace indicates OneSignal is at fault.
- * Centralized logic used by both crash handler and ANR detector.
- * Made public so it can be accessed from core module.
- */
-fun isOneSignalAtFault(stackTrace: Array): Boolean =
- stackTrace.any { it.className.startsWith("com.onesignal") }
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt
deleted file mode 100644
index 44972ebdd0..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.onesignal.otel.crash
-
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.IOtelOpenTelemetryCrash
-import io.opentelemetry.api.common.Attributes
-import io.opentelemetry.api.logs.Severity
-import java.time.Instant
-
-internal class OtelCrashReporter(
- private val openTelemetry: IOtelOpenTelemetryCrash,
- private val logger: IOtelLogger,
-) : com.onesignal.otel.IOtelCrashReporter {
- companion object {
- private const val OTEL_EXCEPTION_TYPE = "exception.type"
- private const val OTEL_EXCEPTION_MESSAGE = "exception.message"
- private const val OTEL_EXCEPTION_STACKTRACE = "exception.stacktrace"
- private const val OTEL_EXCEPTION_THREAD_NAME = "ossdk.exception.thread.name"
-
- // Explicit, SDK-owned fatal flag. The backend can segment crash/ANR metrics on this stable
- // attribute rather than inferring intent from severity or exception.type alone, so a
- // non-fatal record can never be double-counted as a crash even if a mapping changes.
- private const val OTEL_FATAL = "ossdk.crash.fatal"
- }
-
- override suspend fun saveCrash(thread: Thread, throwable: Throwable) =
- save(thread, throwable, severity = Severity.FATAL, fatal = true)
-
- override suspend fun saveNonFatal(thread: Thread, throwable: Throwable) =
- save(thread, throwable, severity = Severity.WARN, fatal = false)
-
- private suspend fun save(
- thread: Thread,
- throwable: Throwable,
- severity: Severity,
- fatal: Boolean,
- ) {
- // Capitalized so the fatal path keeps its existing "Crash report ..." log wording.
- val label = if (fatal) "Crash report" else "Non-fatal report"
- try {
- logger.info("OtelCrashReporter: Starting to save ${label.lowercase()} for ${throwable.javaClass.simpleName}")
-
- val attributes =
- Attributes
- .builder()
- .put(OTEL_EXCEPTION_MESSAGE, throwable.message ?: "")
- .put(OTEL_EXCEPTION_STACKTRACE, throwable.stackTraceToString())
- .put(OTEL_EXCEPTION_TYPE, throwable.javaClass.name)
- // This matches the top level thread.name today, but it may not
- // always if things are refactored to use a different thread.
- .put(OTEL_EXCEPTION_THREAD_NAME, thread.name)
- .put(OTEL_FATAL, fatal)
- .build()
-
- logger.debug("OtelCrashReporter: Creating log record with attributes...")
- openTelemetry
- .getLogger()
- .setAllAttributes(attributes)
- .setSeverity(severity)
- .setTimestamp(Instant.now())
- .emit()
-
- logger.debug("OtelCrashReporter: Flushing ${label.lowercase()} to disk...")
- openTelemetry.forceFlush()
-
- // Note: forceFlush() returns CompletableResultCode which is async
- // We wait for it in the implementation, so if we get here, it succeeded
- logger.info("OtelCrashReporter: ✅ $label saved and flushed successfully to disk")
- } catch (e: RuntimeException) {
- // If we fail to log the crash, at least try to log the failure
- logger.error("OtelCrashReporter: Failed to save crash report: ${e.message} - ${e.javaClass.simpleName}")
- throw e // Re-throw so caller knows it failed
- } catch (e: java.io.IOException) {
- // Handle IO errors specifically
- logger.error("OtelCrashReporter: IO error saving crash report: ${e.message}")
- throw e
- } catch (e: IllegalStateException) {
- // Handle illegal state errors
- logger.error("OtelCrashReporter: Illegal state error saving crash report: ${e.message}")
- throw e
- }
- }
-}
diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt
deleted file mode 100644
index 31f1573e90..0000000000
--- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt
+++ /dev/null
@@ -1,127 +0,0 @@
-package com.onesignal.otel.crash
-
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.IOtelOpenTelemetryRemote
-import com.onesignal.otel.IOtelPlatformProvider
-import com.onesignal.otel.config.OtelConfigCrashFile
-import io.opentelemetry.sdk.logs.data.LogRecordData
-import kotlinx.coroutines.delay
-import java.io.File
-import java.util.concurrent.TimeUnit
-
-/**
- * Purpose: This reads a local crash report files created by OneSignal's
- * crash handler and sends them to OneSignal on the app's next start.
- *
- * This is fully platform-agnostic and can be used in KMP projects.
- * All platform-specific values are injected through IOtelPlatformProvider.
- *
- * Dependencies (all platform-agnostic):
- * - IOtelOpenTelemetryRemote: For network export (created via OtelFactory)
- * - IOtelPlatformProvider: Injects all platform values (Android/iOS)
- * - IOtelLogger: Platform logging interface (Android/iOS)
- *
- * Usage:
- * ```kotlin
- * val uploader = OtelFactory.createCrashUploader(platformProvider, logger)
- * coroutineScope.launch {
- * uploader.start()
- * }
- * ```
- */
-class OtelCrashUploader(
- private val openTelemetryRemote: IOtelOpenTelemetryRemote,
- private val platformProvider: IOtelPlatformProvider,
- private val logger: IOtelLogger,
-) {
- companion object {
- const val SEND_TIMEOUT_SECONDS = 30L
- private const val MAX_PREVIEW_RECORDS = 3
- private const val MAX_BODY_PREVIEW_CHARS = 120
- private const val MAX_PREVIEW_ATTR_KEYS = 8
- }
-
- private fun getReports() =
- OtelConfigCrashFile.SdkLoggerProviderConfig
- .getFileLogRecordStorage(
- platformProvider.crashStoragePath,
- platformProvider.minFileAgeForReadMillis
- ).iterator()
-
- /**
- * Starts the crash uploader process.
- * This will periodically check for crash reports on disk and upload them to OneSignal.
- * If remote logging is disabled (NONE level), this function returns immediately without doing anything.
- */
- suspend fun start() {
- val remoteLogLevel = platformProvider.remoteLogLevel
- if (remoteLogLevel == null || remoteLogLevel == "NONE") {
- logger.info("OtelCrashUploader: remote logging disabled (level: $remoteLogLevel)")
- return
- }
-
- logger.info(
- "OtelCrashUploader: starting path=${platformProvider.crashStoragePath} " +
- "minFileAgeMs=${platformProvider.minFileAgeForReadMillis} level=$remoteLogLevel",
- )
- logDiskFiles("before-read")
- internalStart()
- }
-
- /**
- * NOTE: sendCrashReports is called twice for the these reasons:
- * 1. We want to send crash reports as soon as possible.
- * - App may crash quickly after starting a 2nd time.
- * 2. Reports could be delayed until the 2nd start after a crash
- * - Otel doesn't let you read a file it could be writing so we must
- * wait a minium amount of time after a crash to ensure we get the
- * report from the last crash.
- */
- suspend fun internalStart() {
- sendCrashReports(getReports())
- delay(platformProvider.minFileAgeForReadMillis)
- sendCrashReports(getReports())
- logDiskFiles("after-upload-passes")
- }
-
- internal fun sendCrashReports(reports: Iterator>) {
- val networkExporter = openTelemetryRemote.logExporter
- var failed = false
- var sentBatches = 0
- // NOTE: next() will delete the previous report, so we only want to send
- // another one if there isn't an issue making network calls.
- while (reports.hasNext() && !failed) {
- val batch = reports.next()
- logger.info(
- "OtelCrashUploader: posting batch records=${batch.size} preview=[${summarizeRecords(batch)}]",
- )
- val future = networkExporter.export(batch)
- val result = future.join(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS)
- failed = !result.isSuccess
- if (!failed) sentBatches++
- logger.info("OtelCrashUploader: batch done failed=$failed")
- }
- logger.info("OtelCrashUploader: pass complete sentBatches=$sentBatches stoppedOnFailure=$failed")
- }
-
- internal fun logDiskFiles(label: String) {
- val dir = File(platformProvider.crashStoragePath)
- val files = dir.listFiles()?.filter { it.isFile }.orEmpty()
- if (files.isEmpty()) {
- logger.info("OtelCrashUploader: disk $label — no files in ${dir.path}")
- return
- }
- val summary =
- files.joinToString(separator = "; ") { file ->
- "name=${file.name} bytes=${file.length()}"
- }
- logger.info("OtelCrashUploader: disk $label count=${files.size} [$summary]")
- }
-
- internal fun summarizeRecords(batch: Collection): String =
- batch.take(MAX_PREVIEW_RECORDS).joinToString(separator = " | ") { record ->
- val body = runCatching { record.body.asString() }.getOrNull()?.take(MAX_BODY_PREVIEW_CHARS)
- val attrs = record.attributes.asMap().keys.take(MAX_PREVIEW_ATTR_KEYS).joinToString(",")
- "severity=${record.severityText} body=$body attrs=[$attrs]"
- }
-}
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt
deleted file mode 100644
index 775c1ad047..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt
+++ /dev/null
@@ -1,175 +0,0 @@
-package com.onesignal.otel
-
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.maps.shouldContainKey
-import io.kotest.matchers.maps.shouldNotContainKey
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.shouldNotBe
-import io.kotest.matchers.types.shouldBeInstanceOf
-import io.mockk.clearMocks
-import io.mockk.coEvery
-import io.mockk.every
-import io.mockk.mockk
-import io.opentelemetry.api.logs.LogRecordBuilder
-import kotlinx.coroutines.runBlocking
-
-class OneSignalOpenTelemetryTest : FunSpec({
- val mockPlatformProvider = mockk(relaxed = true)
-
- fun setupDefaultMocks() {
- coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id"
- every { mockPlatformProvider.sdkBase } returns "android"
- every { mockPlatformProvider.sdkBaseVersion } returns "5.0.0"
- every { mockPlatformProvider.appPackageId } returns "com.test.app"
- every { mockPlatformProvider.appVersion } returns "1.0.0"
- every { mockPlatformProvider.deviceManufacturer } returns "TestManufacturer"
- every { mockPlatformProvider.deviceModel } returns "TestModel"
- every { mockPlatformProvider.osName } returns "Android"
- every { mockPlatformProvider.osVersion } returns "13"
- every { mockPlatformProvider.osBuildId } returns "TEST123"
- every { mockPlatformProvider.sdkWrapper } returns null
- every { mockPlatformProvider.sdkWrapperVersion } returns null
- every { mockPlatformProvider.appId } returns "test-app-id"
- every { mockPlatformProvider.appIdForHeaders } returns "test-app-id"
- every { mockPlatformProvider.onesignalId } returns "test-onesignal-id"
- every { mockPlatformProvider.pushSubscriptionId } returns "test-subscription-id"
- every { mockPlatformProvider.appState } returns "foreground"
- every { mockPlatformProvider.processUptime } returns 100L
- every { mockPlatformProvider.currentThreadName } returns "main"
- every { mockPlatformProvider.crashStoragePath } returns "/test/path"
- every { mockPlatformProvider.minFileAgeForReadMillis } returns 5000L
- every { mockPlatformProvider.remoteLogLevel } returns "ERROR"
- every { mockPlatformProvider.apiBaseUrl } returns "https://api.onesignal.com"
- }
-
- beforeEach {
- clearMocks(mockPlatformProvider)
- setupDefaultMocks()
- }
-
- // ===== Remote Telemetry Tests =====
-
- test("createRemoteTelemetry should return IOtelOpenTelemetryRemote") {
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- remoteTelemetry.shouldBeInstanceOf()
- }
-
- test("remote telemetry should have logExporter") {
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- remoteTelemetry.logExporter shouldNotBe null
- }
-
- test("remote telemetry getLogger should return LogRecordBuilder") {
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- runBlocking {
- val logger = remoteTelemetry.getLogger()
- logger.shouldBeInstanceOf()
- }
- }
-
- test("remote telemetry forceFlush should not throw") {
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- runBlocking {
- // Should not throw
- remoteTelemetry.forceFlush()
- }
- }
-
- test("remote telemetry should only send SDK-Version header and not legacy OneSignal SDK header") {
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) as OneSignalOpenTelemetryRemote
- val headers = remoteTelemetry.extraHttpHeaders
-
- headers.shouldContainKey("SDK-Version")
- headers["SDK-Version"] shouldBe "onesignal/android/5.0.0"
- headers.shouldNotContainKey("X-OneSignal-SDK-Version")
- }
-
- // ===== Crash Local Telemetry Tests =====
-
- test("createCrashLocalTelemetry should return IOtelOpenTelemetryCrash") {
- // Use temp directory for crash storage
- val tempDir = System.getProperty("java.io.tmpdir") + "/otel-test-" + System.currentTimeMillis()
- java.io.File(tempDir).mkdirs()
- every { mockPlatformProvider.crashStoragePath } returns tempDir
-
- try {
- val crashTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider)
-
- crashTelemetry.shouldBeInstanceOf()
- } finally {
- java.io.File(tempDir).deleteRecursively()
- }
- }
-
- test("crash telemetry getLogger should return LogRecordBuilder") {
- val tempDir = System.getProperty("java.io.tmpdir") + "/otel-test-" + System.currentTimeMillis()
- java.io.File(tempDir).mkdirs()
- every { mockPlatformProvider.crashStoragePath } returns tempDir
-
- try {
- val crashTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider)
-
- runBlocking {
- val logger = crashTelemetry.getLogger()
- logger.shouldBeInstanceOf()
- }
- } finally {
- java.io.File(tempDir).deleteRecursively()
- }
- }
-
- // ===== LogRecordBuilder Extension Tests =====
-
- test("setAllAttributes with Map should set all string attributes") {
- val mockBuilder = mockk(relaxed = true)
- val attributes = mapOf(
- "key1" to "value1",
- "key2" to "value2"
- )
-
- mockBuilder.setAllAttributes(attributes)
-
- io.mockk.verify { mockBuilder.setAttribute("key1", "value1") }
- io.mockk.verify { mockBuilder.setAttribute("key2", "value2") }
- }
-
- // ===== SDK Caching Tests =====
-
- test("remote telemetry should cache SDK instance") {
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- runBlocking {
- val logger1 = remoteTelemetry.getLogger()
- val logger2 = remoteTelemetry.getLogger()
-
- // Both calls should succeed (SDK is cached internally)
- logger1 shouldNotBe null
- logger2 shouldNotBe null
- }
- }
-
- // ===== Integration with Factory Tests =====
-
- test("factory should create independent instances") {
- val remote1 = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
- val remote2 = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- remote1 shouldNotBe remote2
- }
-
- test("factory should work with null optional fields") {
- every { mockPlatformProvider.appId } returns null
- every { mockPlatformProvider.onesignalId } returns null
- every { mockPlatformProvider.pushSubscriptionId } returns null
- every { mockPlatformProvider.sdkWrapper } returns null
- every { mockPlatformProvider.sdkWrapperVersion } returns null
-
- // Should not throw
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
- remoteTelemetry shouldNotBe null
- }
-})
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt
deleted file mode 100644
index 56f2ce5cc4..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt
+++ /dev/null
@@ -1,204 +0,0 @@
-package com.onesignal.otel
-
-import com.onesignal.otel.crash.OtelCrashUploader
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldNotBe
-import io.kotest.matchers.types.shouldBeInstanceOf
-import io.mockk.coEvery
-import io.mockk.every
-import io.mockk.mockk
-
-class OtelFactoryTest : FunSpec({
- val mockPlatformProvider = mockk(relaxed = true)
- val mockLogger = mockk(relaxed = true)
-
- beforeEach {
- // Setup default values
- every { mockPlatformProvider.sdkBase } returns "android"
- every { mockPlatformProvider.sdkBaseVersion } returns "1.0.0"
- every { mockPlatformProvider.appPackageId } returns "com.test.app"
- every { mockPlatformProvider.appVersion } returns "1.0"
- every { mockPlatformProvider.deviceManufacturer } returns "Test"
- every { mockPlatformProvider.deviceModel } returns "TestDevice"
- every { mockPlatformProvider.osName } returns "Android"
- every { mockPlatformProvider.osVersion } returns "10"
- every { mockPlatformProvider.osBuildId } returns "TEST123"
- every { mockPlatformProvider.sdkWrapper } returns null
- every { mockPlatformProvider.sdkWrapperVersion } returns null
- every { mockPlatformProvider.appId } returns null
- every { mockPlatformProvider.onesignalId } returns null
- every { mockPlatformProvider.pushSubscriptionId } returns null
- every { mockPlatformProvider.appState } returns "foreground"
- every { mockPlatformProvider.processUptime } returns 100L
- every { mockPlatformProvider.currentThreadName } returns "main"
- every { mockPlatformProvider.crashStoragePath } returns "/test/path"
- every { mockPlatformProvider.minFileAgeForReadMillis } returns 5000L
- every { mockPlatformProvider.remoteLogLevel } returns "ERROR"
- every { mockPlatformProvider.appIdForHeaders } returns "test-app-id"
- every { mockPlatformProvider.apiBaseUrl } returns "https://api.onesignal.com"
- coEvery { mockPlatformProvider.getInstallId() } returns "test-install-id"
- }
-
- // ===== createCrashHandler Tests =====
-
- test("createCrashHandler should return IOtelCrashHandler") {
- // When
- val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger)
-
- // Then
- handler.shouldBeInstanceOf()
- }
-
- test("createCrashHandler should create handler with correct dependencies") {
- // When
- val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger)
-
- // Then
- handler shouldNotBe null
- // Handler should be initializable
- handler.initialize()
- }
-
- test("createCrashHandler should create handler that can be initialized multiple times") {
- // Given
- val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger)
-
- // When
- handler.initialize()
- handler.initialize() // Should not throw
-
- // Then - no exception thrown
- }
-
- // ===== createCrashUploader Tests =====
-
- test("createCrashUploader should return OtelCrashUploader") {
- // When
- val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger)
-
- // Then
- uploader shouldNotBe null
- uploader.shouldBeInstanceOf()
- }
-
- test("createCrashUploader should create uploader with correct dependencies") {
- // When
- val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger)
-
- // Then
- uploader shouldNotBe null
- }
-
- // ===== createRemoteTelemetry Tests =====
-
- test("createRemoteTelemetry should return IOtelOpenTelemetryRemote") {
- // When
- val telemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- // Then
- telemetry shouldNotBe null
- telemetry.shouldBeInstanceOf()
- }
-
- test("createRemoteTelemetry should have logExporter") {
- // When
- val telemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- // Then
- telemetry.logExporter shouldNotBe null
- }
-
- // ===== createCrashLocalTelemetry Tests =====
-
- test("createCrashLocalTelemetry should return IOtelOpenTelemetryCrash") {
- // When
- val telemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider)
-
- // Then
- telemetry shouldNotBe null
- telemetry.shouldBeInstanceOf()
- }
-
- test("createCrashLocalTelemetry should be different instance from remote") {
- // When
- val localTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider)
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
-
- // Then
- localTelemetry shouldNotBe remoteTelemetry
- }
-
- // ===== createCrashReporter Tests =====
-
- test("createCrashReporter should return IOtelCrashReporter") {
- // Given
- val crashTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider)
-
- // When
- val reporter = OtelFactory.createCrashReporter(crashTelemetry, mockLogger)
-
- // Then
- reporter shouldNotBe null
- reporter.shouldBeInstanceOf()
- }
-
- test("createCrashReporter should work with different telemetry instances") {
- // Given
- val crashTelemetry1 = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider)
- val crashTelemetry2 = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider)
-
- // When
- val reporter1 = OtelFactory.createCrashReporter(crashTelemetry1, mockLogger)
- val reporter2 = OtelFactory.createCrashReporter(crashTelemetry2, mockLogger)
-
- // Then
- reporter1 shouldNotBe null
- reporter2 shouldNotBe null
- reporter1 shouldNotBe reporter2
- }
-
- // ===== Integration Tests =====
-
- test("createCrashHandler uses platform provider values correctly") {
- // Given
- every { mockPlatformProvider.appId } returns "test-app-id"
- every { mockPlatformProvider.onesignalId } returns "test-onesignal-id"
-
- // When
- val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger)
-
- // Then
- handler shouldNotBe null
- handler.initialize() // Should work with provided values
- }
-
- test("createCrashUploader uses platform provider values correctly") {
- // Given
- every { mockPlatformProvider.appId } returns "test-app-id"
- every { mockPlatformProvider.crashStoragePath } returns "/custom/path"
-
- // When
- val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger)
-
- // Then
- uploader shouldNotBe null
- }
-
- test("all factory methods work with null appId") {
- // Given
- every { mockPlatformProvider.appId } returns null
-
- // When & Then - should not throw
- val handler = OtelFactory.createCrashHandler(mockPlatformProvider, mockLogger)
- handler shouldNotBe null
-
- val uploader = OtelFactory.createCrashUploader(mockPlatformProvider, mockLogger)
- uploader shouldNotBe null
-
- val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider)
- remoteTelemetry shouldNotBe null
-
- val localTelemetry = OtelFactory.createCrashLocalTelemetry(mockPlatformProvider)
- localTelemetry shouldNotBe null
- }
-})
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelLoggingHelperTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelLoggingHelperTest.kt
deleted file mode 100644
index 16b195754e..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelLoggingHelperTest.kt
+++ /dev/null
@@ -1,145 +0,0 @@
-package com.onesignal.otel
-
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.slot
-import io.mockk.verify
-import io.opentelemetry.api.logs.LogRecordBuilder
-import io.opentelemetry.api.logs.Severity
-import kotlinx.coroutines.runBlocking
-
-class OtelLoggingHelperTest : FunSpec({
- val mockTelemetry = mockk(relaxed = true)
- val mockLogRecordBuilder = mockk(relaxed = true)
-
- beforeEach {
- coEvery { mockTelemetry.getLogger() } returns mockLogRecordBuilder
- }
-
- test("logToOtel should set correct severity for VERBOSE level") {
- val severitySlot = slot()
- every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "VERBOSE", "test message")
- }
-
- severitySlot.captured shouldBe Severity.TRACE
- }
-
- test("logToOtel should set correct severity for DEBUG level") {
- val severitySlot = slot()
- every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "DEBUG", "test message")
- }
-
- severitySlot.captured shouldBe Severity.DEBUG
- }
-
- test("logToOtel should set correct severity for INFO level") {
- val severitySlot = slot()
- every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "INFO", "test message")
- }
-
- severitySlot.captured shouldBe Severity.INFO
- }
-
- test("logToOtel should set correct severity for WARN level") {
- val severitySlot = slot()
- every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "WARN", "test message")
- }
-
- severitySlot.captured shouldBe Severity.WARN
- }
-
- test("logToOtel should set correct severity for ERROR level") {
- val severitySlot = slot()
- every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "ERROR", "test message")
- }
-
- severitySlot.captured shouldBe Severity.ERROR
- }
-
- test("logToOtel should set correct severity for FATAL level") {
- val severitySlot = slot()
- every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "FATAL", "test message")
- }
-
- severitySlot.captured shouldBe Severity.FATAL
- }
-
- test("logToOtel should default to INFO for unknown level") {
- val severitySlot = slot()
- every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "UNKNOWN", "test message")
- }
-
- severitySlot.captured shouldBe Severity.INFO
- }
-
- test("logToOtel should set body with message") {
- val bodySlot = slot()
- every { mockLogRecordBuilder.setBody(capture(bodySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "INFO", "my test message")
- }
-
- bodySlot.captured shouldBe "my test message"
- }
-
- test("logToOtel should emit the log record") {
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "INFO", "test message")
- }
-
- verify { mockLogRecordBuilder.emit() }
- }
-
- test("logToOtel should include exception attributes when provided") {
- runBlocking {
- OtelLoggingHelper.logToOtel(
- telemetry = mockTelemetry,
- level = "ERROR",
- message = "error occurred",
- exceptionType = "java.lang.RuntimeException",
- exceptionMessage = "something went wrong",
- exceptionStacktrace = "at com.test.Class.method(Class.kt:10)"
- )
- }
-
- coVerify { mockTelemetry.getLogger() }
- verify { mockLogRecordBuilder.emit() }
- }
-
- test("logToOtel should handle case-insensitive log levels") {
- val severitySlot = slot()
- every { mockLogRecordBuilder.setSeverity(capture(severitySlot)) } returns mockLogRecordBuilder
-
- runBlocking {
- OtelLoggingHelper.logToOtel(mockTelemetry, "error", "test message")
- }
-
- severitySlot.captured shouldBe Severity.ERROR
- }
-})
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt
deleted file mode 100644
index 969a8e7d9a..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt
+++ /dev/null
@@ -1,119 +0,0 @@
-package com.onesignal.otel.attributes
-
-import com.onesignal.otel.IOtelPlatformProvider
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.collections.shouldContain
-import io.kotest.matchers.collections.shouldNotContain
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.shouldNotBe
-import io.mockk.clearMocks
-import io.mockk.every
-import io.mockk.mockk
-
-class OtelFieldsPerEventTest : FunSpec({
- val mockPlatformProvider = mockk(relaxed = true)
- val fields = OtelFieldsPerEvent(mockPlatformProvider)
-
- fun setupDefaultMocks(
- appId: String? = "test-app-id",
- onesignalId: String? = "test-onesignal-id",
- pushSubscriptionId: String? = "test-subscription-id",
- appState: String = "foreground",
- processUptime: Long = 100,
- threadName: String = "main-thread",
- enabledFeatureFlags: List = emptyList()
- ) {
- every { mockPlatformProvider.appId } returns appId
- every { mockPlatformProvider.onesignalId } returns onesignalId
- every { mockPlatformProvider.pushSubscriptionId } returns pushSubscriptionId
- every { mockPlatformProvider.appState } returns appState
- every { mockPlatformProvider.processUptime } returns processUptime
- every { mockPlatformProvider.currentThreadName } returns threadName
- every { mockPlatformProvider.enabledFeatureFlags } returns enabledFeatureFlags
- }
-
- beforeEach { clearMocks(mockPlatformProvider) }
-
- test("getAttributes should include all per-event fields when all values present") {
- setupDefaultMocks()
-
- val attributes = fields.getAttributes()
-
- attributes.keys shouldContain "log.record.uid"
- attributes["log.record.uid"] shouldNotBe null
- attributes["ossdk.app_id"] shouldBe "test-app-id"
- attributes["ossdk.onesignal_id"] shouldBe "test-onesignal-id"
- attributes["ossdk.push_subscription_id"] shouldBe "test-subscription-id"
- attributes["app.state"] shouldBe "foreground"
- attributes["process.uptime"] shouldBe "100"
- attributes["thread.name"] shouldBe "main-thread"
- }
-
- test("getAttributes should exclude null optional fields") {
- setupDefaultMocks(appId = null, onesignalId = null, pushSubscriptionId = null, appState = "background")
-
- val attributes = fields.getAttributes()
-
- attributes.keys shouldNotContain "ossdk.app_id"
- attributes.keys shouldNotContain "ossdk.onesignal_id"
- attributes.keys shouldNotContain "ossdk.push_subscription_id"
- attributes["app.state"] shouldBe "background"
- }
-
- test("getAttributes should generate unique record IDs on each call") {
- setupDefaultMocks()
-
- val uid1 = fields.getAttributes()["log.record.uid"]
- val uid2 = fields.getAttributes()["log.record.uid"]
-
- uid1 shouldNotBe uid2
- }
-
- test("getAttributes should omit ossdk.feature_flags when no feature flags are enabled") {
- setupDefaultMocks(enabledFeatureFlags = emptyList())
-
- val attributes = fields.getAttributes()
-
- attributes.keys shouldNotContain "ossdk.feature_flags"
- }
-
- test("getAttributes should encode a single enabled feature flag") {
- setupDefaultMocks(enabledFeatureFlags = listOf("sdk_background_threading"))
-
- val attributes = fields.getAttributes()
-
- attributes["ossdk.feature_flags"] shouldBe "sdk_background_threading"
- }
-
- test("getAttributes should encode multiple flags as a sorted comma-separated string") {
- setupDefaultMocks(
- enabledFeatureFlags = listOf(
- "sdk_zeta_feature",
- "sdk_background_threading",
- "sdk_alpha_feature",
- )
- )
-
- val attributes = fields.getAttributes()
-
- attributes["ossdk.feature_flags"] shouldBe
- "sdk_alpha_feature,sdk_background_threading,sdk_zeta_feature"
- }
-
- test("getAttributes should re-read enabledFeatureFlags on every call (per-event)") {
- val states = mutableListOf("sdk_background_threading")
- every { mockPlatformProvider.appId } returns "test-app-id"
- every { mockPlatformProvider.onesignalId } returns "test-onesignal-id"
- every { mockPlatformProvider.pushSubscriptionId } returns "test-subscription-id"
- every { mockPlatformProvider.appState } returns "foreground"
- every { mockPlatformProvider.processUptime } returns 100
- every { mockPlatformProvider.currentThreadName } returns "main-thread"
- every { mockPlatformProvider.enabledFeatureFlags } answers { states.toList() }
-
- fields.getAttributes()["ossdk.feature_flags"] shouldBe "sdk_background_threading"
-
- states.add("sdk_other_flag")
- fields.getAttributes()["ossdk.feature_flags"] shouldBe
- "sdk_background_threading,sdk_other_flag"
- }
-})
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt
deleted file mode 100644
index bf669f9ee3..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt
+++ /dev/null
@@ -1,147 +0,0 @@
-package com.onesignal.otel.attributes
-
-import com.onesignal.otel.IOtelPlatformProvider
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.collections.shouldNotContain
-import io.kotest.matchers.shouldBe
-import io.mockk.clearMocks
-import io.mockk.coEvery
-import io.mockk.every
-import io.mockk.mockk
-import kotlinx.coroutines.runBlocking
-
-class OtelFieldsTopLevelTest : FunSpec({
- val mockPlatformProvider = mockk(relaxed = true)
- val fields = OtelFieldsTopLevel(mockPlatformProvider)
-
- fun setupDefaultMocks(
- installId: String = "test-install-id",
- sdkWrapper: String? = null,
- sdkWrapperVersion: String? = null,
- kotlinVersion: String? = null,
- swiftVersion: String? = null,
- additionalVersionAttributes: Map = emptyMap(),
- ) {
- coEvery { mockPlatformProvider.getInstallId() } returns installId
- every { mockPlatformProvider.sdkBase } returns "android"
- every { mockPlatformProvider.sdkBaseVersion } returns "1.0.0"
- every { mockPlatformProvider.appPackageId } returns "com.test.app"
- every { mockPlatformProvider.appVersion } returns "1.0"
- every { mockPlatformProvider.deviceManufacturer } returns "TestManufacturer"
- every { mockPlatformProvider.deviceModel } returns "TestModel"
- every { mockPlatformProvider.osName } returns "Android"
- every { mockPlatformProvider.osVersion } returns "10"
- every { mockPlatformProvider.osBuildId } returns "TEST123"
- every { mockPlatformProvider.sdkWrapper } returns sdkWrapper
- every { mockPlatformProvider.sdkWrapperVersion } returns sdkWrapperVersion
- every { mockPlatformProvider.kotlinVersion } returns kotlinVersion
- every { mockPlatformProvider.swiftVersion } returns swiftVersion
- every { mockPlatformProvider.additionalVersionAttributes } returns additionalVersionAttributes
- }
-
- beforeEach { clearMocks(mockPlatformProvider) }
-
- test("getAttributes should include all required top-level fields") {
- setupDefaultMocks()
-
- runBlocking {
- val attributes = fields.getAttributes()
-
- attributes["ossdk.install_id"] shouldBe "test-install-id"
- attributes["ossdk.sdk_base"] shouldBe "android"
- attributes["ossdk.sdk_base_version"] shouldBe "1.0.0"
- attributes["ossdk.app_package_id"] shouldBe "com.test.app"
- attributes["ossdk.app_version"] shouldBe "1.0"
- attributes["device.manufacturer"] shouldBe "TestManufacturer"
- attributes["device.model.identifier"] shouldBe "TestModel"
- attributes["os.name"] shouldBe "Android"
- attributes["os.version"] shouldBe "10"
- attributes["os.build_id"] shouldBe "TEST123"
- attributes.keys shouldNotContain "ossdk.kotlin_version"
- attributes.keys shouldNotContain "ossdk.swift_version"
- }
- }
-
- test("getAttributes should include wrapper fields when present") {
- setupDefaultMocks(sdkWrapper = "unity", sdkWrapperVersion = "2.0.0")
-
- runBlocking {
- val attributes = fields.getAttributes()
-
- attributes["ossdk.sdk_wrapper"] shouldBe "unity"
- attributes["ossdk.sdk_wrapper_version"] shouldBe "2.0.0"
- }
- }
-
- test("getAttributes should exclude null wrapper fields") {
- setupDefaultMocks(sdkWrapper = null, sdkWrapperVersion = null)
-
- runBlocking {
- val attributes = fields.getAttributes()
-
- attributes.keys shouldNotContain "ossdk.sdk_wrapper"
- attributes.keys shouldNotContain "ossdk.sdk_wrapper_version"
- }
- }
-
- test("getAttributes should include kotlin and swift versions when provided") {
- setupDefaultMocks(kotlinVersion = "1.9.25", swiftVersion = "5.10")
-
- runBlocking {
- val attributes = fields.getAttributes()
-
- attributes["ossdk.kotlin_version"] shouldBe "1.9.25"
- attributes["ossdk.swift_version"] shouldBe "5.10"
- }
- }
-
- test("getAttributes should omit blank language versions") {
- setupDefaultMocks(kotlinVersion = " ", swiftVersion = "")
-
- runBlocking {
- val attributes = fields.getAttributes()
-
- attributes.keys shouldNotContain "ossdk.kotlin_version"
- attributes.keys shouldNotContain "ossdk.swift_version"
- }
- }
-
- test("getAttributes should merge additionalVersionAttributes under ossdk prefix") {
- setupDefaultMocks(
- kotlinVersion = "1.9.25",
- additionalVersionAttributes =
- mapOf(
- "java_version" to "17",
- "kotlin_version" to "should-not-win",
- "install_id" to "forged-install",
- "ossdk.ndk_version" to "26.1",
- // Leading whitespace must still strip ossdk. (no ossdk.ossdk.*).
- " ossdk.agp_version" to "8.8.2",
- "agp_blank" to " ",
- ),
- )
-
- runBlocking {
- val attributes = fields.getAttributes()
-
- attributes["ossdk.java_version"] shouldBe "17"
- attributes["ossdk.ndk_version"] shouldBe "26.1"
- attributes["ossdk.agp_version"] shouldBe "8.8.2"
- attributes["ossdk.kotlin_version"] shouldBe "1.9.25"
- attributes["ossdk.install_id"] shouldBe "test-install-id"
- attributes.keys shouldNotContain "ossdk.ossdk.ndk_version"
- attributes.keys shouldNotContain "ossdk.ossdk.agp_version"
- attributes.keys shouldNotContain "ossdk.agp_blank"
- }
- }
-
- test("getAttributes should never include ossdk.feature_flags (now per-event)") {
- setupDefaultMocks()
-
- runBlocking {
- val attributes = fields.getAttributes()
-
- attributes.keys shouldNotContain "ossdk.feature_flags"
- }
- }
-})
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt
deleted file mode 100644
index e31fdfea96..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt
+++ /dev/null
@@ -1,137 +0,0 @@
-package com.onesignal.otel.config
-
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.shouldNotBe
-import io.opentelemetry.semconv.ServiceAttributes
-
-class OtelConfigTest : FunSpec({
-
- // ===== OtelConfigShared.ResourceConfig Tests =====
-
- test("ResourceConfig should create resource with service name") {
- val resource = OtelConfigShared.ResourceConfig.create(emptyMap())
-
- resource.attributes.get(ServiceAttributes.SERVICE_NAME) shouldBe "OneSignalDeviceSDK"
- }
-
- test("ResourceConfig should include custom attributes") {
- val customAttributes = mapOf(
- "custom.key1" to "value1",
- "custom.key2" to "value2"
- )
-
- val resource = OtelConfigShared.ResourceConfig.create(customAttributes)
-
- resource.attributes.get(ServiceAttributes.SERVICE_NAME) shouldBe "OneSignalDeviceSDK"
- resource.attributes.asMap().entries.any { it.key.key == "custom.key1" } shouldBe true
- resource.attributes.asMap().entries.any { it.key.key == "custom.key2" } shouldBe true
- }
-
- test("ResourceConfig should handle empty attributes map") {
- val resource = OtelConfigShared.ResourceConfig.create(emptyMap())
-
- resource shouldNotBe null
- resource.attributes.get(ServiceAttributes.SERVICE_NAME) shouldBe "OneSignalDeviceSDK"
- }
-
- // ===== OtelConfigShared.LogLimitsConfig Tests =====
-
- test("LogLimitsConfig should create valid log limits") {
- val logLimits = OtelConfigShared.LogLimitsConfig.logLimits()
-
- logLimits shouldNotBe null
- logLimits.maxNumberOfAttributes shouldBe 128
- logLimits.maxAttributeValueLength shouldBe 32000
- }
-
- // ===== OtelConfigShared.LogRecordProcessorConfig Tests =====
-
- test("LogRecordProcessorConfig should create batch processor") {
- val mockExporter = io.mockk.mockk(relaxed = true)
-
- val processor = OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor(mockExporter)
-
- processor shouldNotBe null
- }
-
- // ===== OtelConfigRemoteOneSignal Tests =====
-
- test("buildEndpoint should construct correct URL from base and appId") {
- val endpoint = OtelConfigRemoteOneSignal.buildEndpoint("https://api.onesignal.com", "my-app")
- endpoint shouldBe "https://api.onesignal.com/sdk/log?app_id=my-app"
- }
-
- test("HttpRecordBatchExporter should create exporter with correct endpoint") {
- val headers = mapOf("X-Test-Header" to "test-value")
- val appId = "test-app-id"
- val apiBaseUrl = "https://api.onesignal.com"
-
- val exporter = OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(headers, appId, apiBaseUrl, false)
-
- exporter shouldNotBe null
- }
-
- test("LogRecordExporterConfig should create OTLP HTTP exporter") {
- val headers = mapOf("Authorization" to "Bearer token")
- val endpoint = "https://example.com/v1/logs"
-
- val exporter = OtelConfigRemoteOneSignal.LogRecordExporterConfig.otlpHttpLogRecordExporter(
- headers,
- endpoint
- )
-
- exporter shouldNotBe null
- }
-
- test("SdkLoggerProviderConfig should create logger provider") {
- val resource = OtelConfigShared.ResourceConfig.create(emptyMap())
- val headers = mapOf("X-OneSignal-App-Id" to "test-app-id")
-
- val provider = OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create(
- resource,
- headers,
- "test-app-id",
- "https://api.onesignal.com",
- false,
- )
-
- provider shouldNotBe null
- }
-
- // ===== OtelConfigCrashFile Tests =====
-
- test("OtelConfigCrashFile should create file log storage") {
- val tempDir = System.getProperty("java.io.tmpdir") + "/otel-test-" + System.currentTimeMillis()
- java.io.File(tempDir).mkdirs()
-
- try {
- val storage = OtelConfigCrashFile.SdkLoggerProviderConfig.getFileLogRecordStorage(
- tempDir,
- 5000L
- )
-
- storage shouldNotBe null
- } finally {
- java.io.File(tempDir).deleteRecursively()
- }
- }
-
- test("OtelConfigCrashFile should create logger provider") {
- val resource = OtelConfigShared.ResourceConfig.create(emptyMap())
- val tempDir = System.getProperty("java.io.tmpdir") + "/otel-test-" + System.currentTimeMillis()
- java.io.File(tempDir).mkdirs()
-
- try {
- val provider = OtelConfigCrashFile.SdkLoggerProviderConfig.create(
- resource,
- tempDir,
- 5000L
- )
-
- provider shouldNotBe null
- } finally {
- java.io.File(tempDir).deleteRecursively()
- }
- }
-})
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt
deleted file mode 100644
index 2572c2f162..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-package com.onesignal.otel.crash
-
-import com.onesignal.otel.IOtelCrashReporter
-import com.onesignal.otel.IOtelLogger
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.mockk.clearMocks
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.mockk
-import io.mockk.verify
-
-class OtelCrashHandlerTest : FunSpec({
- val mockCrashReporter = mockk(relaxed = true)
- val mockLogger = mockk(relaxed = true)
-
- fun createFreshHandler() = OtelCrashHandler(mockCrashReporter, mockLogger)
-
- beforeEach {
- clearMocks(mockCrashReporter, mockLogger)
- }
-
- test("initialize should set up uncaught exception handler") {
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
- val crashHandler = createFreshHandler()
-
- crashHandler.initialize()
-
- Thread.getDefaultUncaughtExceptionHandler() shouldBe crashHandler
- verify { mockLogger.info(match { it.contains("Setting up uncaught exception handler") }) }
- verify { mockLogger.info(match { it.contains("Successfully initialized") }) }
-
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("initialize should not initialize twice") {
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
- val crashHandler = createFreshHandler()
-
- crashHandler.initialize()
- crashHandler.initialize()
-
- verify(exactly = 1) { mockLogger.warn("OtelCrashHandler already initialized, skipping") }
-
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("uncaughtException should not process non-OneSignal crashes") {
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
- val mockHandler = mockk(relaxed = true)
- Thread.setDefaultUncaughtExceptionHandler(mockHandler)
- val crashHandler = createFreshHandler()
- crashHandler.initialize()
-
- val throwable = RuntimeException("Non-OneSignal crash")
- val thread = Thread.currentThread()
-
- crashHandler.uncaughtException(thread, throwable)
-
- coVerify(exactly = 0) { mockCrashReporter.saveCrash(any(), any()) }
- verify { mockHandler.uncaughtException(thread, throwable) }
-
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("uncaughtException should process OneSignal crashes") {
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
- val mockHandler = mockk(relaxed = true)
- Thread.setDefaultUncaughtExceptionHandler(mockHandler)
- val crashHandler = createFreshHandler()
- crashHandler.initialize()
-
- val throwable = RuntimeException("OneSignal crash").apply {
- stackTrace = arrayOf(
- StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10)
- )
- }
- val thread = Thread.currentThread()
-
- coEvery { mockCrashReporter.saveCrash(any(), any()) } returns Unit
-
- crashHandler.uncaughtException(thread, throwable)
-
- coVerify(exactly = 1) { mockCrashReporter.saveCrash(thread, throwable) }
- verify { mockHandler.uncaughtException(thread, throwable) }
-
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("uncaughtException should not process same throwable twice") {
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
- val crashHandler = createFreshHandler()
- crashHandler.initialize()
-
- val throwable = RuntimeException("OneSignal crash").apply {
- stackTrace = arrayOf(
- StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10)
- )
- }
- val thread = Thread.currentThread()
-
- coEvery { mockCrashReporter.saveCrash(any(), any()) } returns Unit
-
- crashHandler.uncaughtException(thread, throwable)
- crashHandler.uncaughtException(thread, throwable)
-
- coVerify(exactly = 1) { mockCrashReporter.saveCrash(any(), any()) }
-
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- test("uncaughtException should handle crash reporter failures gracefully") {
- val originalHandler = Thread.getDefaultUncaughtExceptionHandler()
- val mockHandler = mockk(relaxed = true)
- Thread.setDefaultUncaughtExceptionHandler(mockHandler)
- val crashHandler = createFreshHandler()
- crashHandler.initialize()
-
- val throwable = RuntimeException("OneSignal crash").apply {
- stackTrace = arrayOf(
- StackTraceElement("com.onesignal.SomeClass", "someMethod", "SomeClass.kt", 10)
- )
- }
- val thread = Thread.currentThread()
-
- coEvery { mockCrashReporter.saveCrash(any(), any()) } throws RuntimeException("Reporter failed")
-
- crashHandler.uncaughtException(thread, throwable)
-
- verify { mockLogger.error(match { it.contains("Failed to save crash report") }) }
- verify { mockHandler.uncaughtException(thread, throwable) }
-
- Thread.setDefaultUncaughtExceptionHandler(originalHandler)
- }
-
- // ===== isOneSignalAtFault Tests =====
-
- test("isOneSignalAtFault should return true for OneSignal stack traces") {
- val stackTrace = arrayOf(
- StackTraceElement("com.onesignal.core.SomeClass", "method", "File.kt", 10)
- )
-
- isOneSignalAtFault(stackTrace) shouldBe true
- }
-
- test("isOneSignalAtFault should return false for non-OneSignal stack traces") {
- val stackTrace = arrayOf(
- StackTraceElement("com.example.app.SomeClass", "method", "File.kt", 10)
- )
-
- isOneSignalAtFault(stackTrace) shouldBe false
- }
-
- test("isOneSignalAtFault should return false for empty stack traces") {
- val stackTrace = emptyArray()
-
- isOneSignalAtFault(stackTrace) shouldBe false
- }
-
- test("isOneSignalAtFault with throwable should check throwable stack trace") {
- val throwable = RuntimeException("test").apply {
- stackTrace = arrayOf(
- StackTraceElement("com.onesignal.SomeClass", "method", "File.kt", 10)
- )
- }
-
- isOneSignalAtFault(throwable) shouldBe true
- }
-})
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt
deleted file mode 100644
index e94591d833..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt
+++ /dev/null
@@ -1,190 +0,0 @@
-package com.onesignal.otel.crash
-
-import com.onesignal.otel.IOtelCrashReporter
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.IOtelOpenTelemetryCrash
-import io.kotest.assertions.throwables.shouldThrow
-import io.kotest.core.spec.style.FunSpec
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.types.shouldBeInstanceOf
-import io.mockk.clearMocks
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.slot
-import io.mockk.verify
-import io.opentelemetry.api.common.AttributeKey
-import io.opentelemetry.api.common.Attributes
-import io.opentelemetry.api.logs.LogRecordBuilder
-import io.opentelemetry.api.logs.Severity
-import io.opentelemetry.sdk.common.CompletableResultCode
-import kotlinx.coroutines.runBlocking
-
-class OtelCrashReporterTest : FunSpec({
- val mockOpenTelemetry = mockk(relaxed = true)
- val mockLogger = mockk(relaxed = true)
- val mockLogRecordBuilder = mockk(relaxed = true)
- val mockCompletableResult = mockk(relaxed = true)
-
- fun setupDefaultMocks() {
- coEvery { mockOpenTelemetry.getLogger() } returns mockLogRecordBuilder
- coEvery { mockOpenTelemetry.forceFlush() } returns mockCompletableResult
- every { mockLogRecordBuilder.setSeverity(any()) } returns mockLogRecordBuilder
- every { mockLogRecordBuilder.setTimestamp(any()) } returns mockLogRecordBuilder
- every { mockLogRecordBuilder.emit() } returns Unit
- }
-
- beforeEach {
- clearMocks(mockOpenTelemetry, mockLogger, mockLogRecordBuilder, mockCompletableResult)
- setupDefaultMocks()
- }
-
- test("should implement IOtelCrashReporter interface") {
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
-
- crashReporter.shouldBeInstanceOf()
- }
-
- test("saveCrash should get logger and emit log record") {
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
- val throwable = RuntimeException("Test crash")
- val thread = Thread.currentThread()
-
- runBlocking {
- crashReporter.saveCrash(thread, throwable)
- }
-
- coVerify(exactly = 1) { mockOpenTelemetry.getLogger() }
- coVerify(exactly = 1) { mockOpenTelemetry.forceFlush() }
- verify { mockLogRecordBuilder.setSeverity(Severity.FATAL) }
- verify { mockLogRecordBuilder.emit() }
- }
-
- test("saveCrash should emit at FATAL severity and tag the record fatal") {
- val attrsSlot = slot()
- every { mockLogRecordBuilder.setAllAttributes(capture(attrsSlot)) } returns mockLogRecordBuilder
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
-
- runBlocking {
- crashReporter.saveCrash(Thread.currentThread(), RuntimeException("boom"))
- }
-
- verify { mockLogRecordBuilder.setSeverity(Severity.FATAL) }
- attrsSlot.captured.get(AttributeKey.booleanKey("ossdk.crash.fatal")) shouldBe true
- }
-
- test("saveNonFatal should emit at WARN severity and tag the record non-fatal") {
- val attrsSlot = slot()
- every { mockLogRecordBuilder.setAllAttributes(capture(attrsSlot)) } returns mockLogRecordBuilder
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
-
- runBlocking {
- crashReporter.saveNonFatal(Thread.currentThread(), RuntimeException("background block"))
- }
-
- // A background block must never ride the fatal severity that feeds the crash/ANR metric.
- verify { mockLogRecordBuilder.setSeverity(Severity.WARN) }
- verify(exactly = 0) { mockLogRecordBuilder.setSeverity(Severity.FATAL) }
- attrsSlot.captured.get(AttributeKey.booleanKey("ossdk.crash.fatal")) shouldBe false
- }
-
- test("saveNonFatal should still get logger, emit, and flush to the retained crash telemetry") {
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
-
- runBlocking {
- crashReporter.saveNonFatal(Thread.currentThread(), RuntimeException("background block"))
- }
-
- coVerify(exactly = 1) { mockOpenTelemetry.getLogger() }
- coVerify(exactly = 1) { mockOpenTelemetry.forceFlush() }
- verify { mockLogRecordBuilder.emit() }
- }
-
- test("saveCrash should log info messages") {
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
- val throwable = RuntimeException("Test crash")
- val thread = Thread.currentThread()
-
- runBlocking {
- crashReporter.saveCrash(thread, throwable)
- }
-
- verify { mockLogger.info(match { it.contains("Starting to save crash report") }) }
- verify { mockLogger.info(match { it.contains("Crash report saved and flushed successfully") }) }
- }
-
- test("saveCrash should handle null exception message") {
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
- val throwable = RuntimeException() // No message
- val thread = Thread.currentThread()
-
- runBlocking {
- crashReporter.saveCrash(thread, throwable)
- }
-
- coVerify { mockOpenTelemetry.getLogger() }
- verify { mockLogRecordBuilder.emit() }
- }
-
- test("saveCrash should re-throw RuntimeException on failure") {
- coEvery { mockOpenTelemetry.getLogger() } throws RuntimeException("OpenTelemetry failed")
-
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
- val throwable = RuntimeException("Test crash")
- val thread = Thread.currentThread()
-
- shouldThrow {
- runBlocking {
- crashReporter.saveCrash(thread, throwable)
- }
- }
-
- verify { mockLogger.error(match { it.contains("Failed to save crash report") }) }
- }
-
- test("saveCrash should re-throw IOException on IO failure") {
- coEvery { mockOpenTelemetry.getLogger() } throws java.io.IOException("IO failed")
-
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
- val throwable = RuntimeException("Test crash")
- val thread = Thread.currentThread()
-
- shouldThrow {
- runBlocking {
- crashReporter.saveCrash(thread, throwable)
- }
- }
-
- verify { mockLogger.error(match { it.contains("IO error saving crash report") }) }
- }
-
- test("saveCrash should re-throw IllegalStateException") {
- coEvery { mockOpenTelemetry.getLogger() } throws IllegalStateException("Illegal state")
-
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
- val throwable = RuntimeException("Test crash")
- val thread = Thread.currentThread()
-
- // Note: IllegalStateException extends RuntimeException, so it gets caught by the RuntimeException handler
- shouldThrow {
- runBlocking {
- crashReporter.saveCrash(thread, throwable)
- }
- }
-
- verify { mockLogger.error(match { it.contains("Failed to save crash report") }) }
- }
-
- test("saveCrash should set timestamp") {
- val crashReporter = OtelCrashReporter(mockOpenTelemetry, mockLogger)
- val throwable = RuntimeException("Test crash")
- val thread = Thread.currentThread()
-
- runBlocking {
- crashReporter.saveCrash(thread, throwable)
- }
-
- verify { mockLogRecordBuilder.setTimestamp(any()) }
- }
-})
diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashUploaderTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashUploaderTest.kt
deleted file mode 100644
index 3f8ffb0a54..0000000000
--- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashUploaderTest.kt
+++ /dev/null
@@ -1,160 +0,0 @@
-package com.onesignal.otel.crash
-
-import com.onesignal.otel.IOtelLogger
-import com.onesignal.otel.IOtelOpenTelemetryRemote
-import com.onesignal.otel.IOtelPlatformProvider
-import io.kotest.matchers.shouldBe
-import io.kotest.matchers.shouldNotBe
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.verify
-import io.opentelemetry.api.common.Attributes
-import io.opentelemetry.sdk.common.CompletableResultCode
-import io.opentelemetry.sdk.logs.data.LogRecordData
-import io.opentelemetry.sdk.logs.export.LogRecordExporter
-import kotlinx.coroutines.runBlocking
-import org.junit.Before
-import org.junit.Test
-import java.io.File
-
-class OtelCrashUploaderTest {
- private lateinit var mockRemoteTelemetry: IOtelOpenTelemetryRemote
- private lateinit var mockPlatformProvider: IOtelPlatformProvider
- private lateinit var mockLogger: IOtelLogger
- private lateinit var mockExporter: LogRecordExporter
-
- @Before
- fun setUp() {
- mockRemoteTelemetry = mockk(relaxed = true)
- mockPlatformProvider = mockk(relaxed = true)
- mockLogger = mockk(relaxed = true)
- mockExporter = mockk(relaxed = true)
- }
-
- private fun createTempDir(): String {
- val tempDir = File(System.getProperty("java.io.tmpdir"), "otel-test-${System.currentTimeMillis()}")
- tempDir.mkdirs()
- return tempDir.absolutePath
- }
-
- private fun setupDefaultMocks(
- remoteLogLevel: String? = "ERROR",
- crashStoragePath: String? = null,
- minFileAgeForReadMillis: Long = 2_001L,
- ) {
- val path = crashStoragePath ?: createTempDir()
- every { mockPlatformProvider.remoteLogLevel } returns remoteLogLevel
- every { mockPlatformProvider.crashStoragePath } returns path
- every { mockPlatformProvider.minFileAgeForReadMillis } returns minFileAgeForReadMillis
- every { mockRemoteTelemetry.logExporter } returns mockExporter
- every { mockExporter.export(any()) } returns CompletableResultCode.ofSuccess()
- }
-
- @Test
- fun `should create uploader with dependencies`() {
- setupDefaultMocks()
-
- val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger)
-
- uploader shouldNotBe null
- }
-
- @Test
- fun `start should return immediately when remote logging is disabled with null level`() {
- setupDefaultMocks(remoteLogLevel = null)
-
- val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger)
-
- runBlocking { uploader.start() }
-
- verify { mockLogger.info("OtelCrashUploader: remote logging disabled (level: null)") }
- }
-
- @Test
- fun `start should return immediately when remote logging is NONE`() {
- setupDefaultMocks(remoteLogLevel = "NONE")
-
- val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger)
-
- runBlocking { uploader.start() }
-
- verify { mockLogger.info("OtelCrashUploader: remote logging disabled (level: NONE)") }
- }
-
- @Test
- fun `start should proceed when remote logging is enabled`() {
- setupDefaultMocks(remoteLogLevel = "ERROR")
-
- val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger)
-
- runBlocking { uploader.start() }
-
- verify {
- mockLogger.info(
- match {
- it.startsWith("OtelCrashUploader: starting path=") &&
- it.endsWith("minFileAgeMs=2001 level=ERROR")
- },
- )
- }
- verify { mockLogger.info(match { it.contains("disk before-read — no files") }) }
- verify(exactly = 2) {
- mockLogger.info("OtelCrashUploader: pass complete sentBatches=0 stoppedOnFailure=false")
- }
- }
-
- @Test
- fun `sendCrashReports logs a preview and successful summary`() {
- setupDefaultMocks()
- val record = mockk(relaxed = true)
- every { record.severityText } returns "FATAL"
- every { record.body.asString() } returns "example crash"
- every { record.attributes } returns Attributes.builder().put("exception.type", "Example").build()
- val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger)
-
- uploader.sendCrashReports(listOf(listOf(record)).iterator())
-
- verify { mockExporter.export(match { it.single() === record }) }
- verify {
- mockLogger.info(
- "OtelCrashUploader: posting batch records=1 " +
- "preview=[severity=FATAL body=example crash attrs=[exception.type]]",
- )
- }
- verify { mockLogger.info("OtelCrashUploader: batch done failed=false") }
- verify { mockLogger.info("OtelCrashUploader: pass complete sentBatches=1 stoppedOnFailure=false") }
- }
-
- @Test
- fun `sendCrashReports stops after a failed batch`() {
- setupDefaultMocks()
- every { mockExporter.export(any()) } returns CompletableResultCode.ofFailure()
- val record = mockk(relaxed = true)
- every { record.attributes } returns Attributes.empty()
- val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger)
-
- uploader.sendCrashReports(listOf(listOf(record), listOf(record)).iterator())
-
- verify(exactly = 1) { mockExporter.export(any()) }
- verify { mockLogger.info("OtelCrashUploader: pass complete sentBatches=0 stoppedOnFailure=true") }
- }
-
- @Test
- fun `logDiskFiles includes file names and sizes`() {
- val directory = File(createTempDir())
- File(directory, "crash.log").writeText("crash")
- setupDefaultMocks(crashStoragePath = directory.path)
- val uploader = OtelCrashUploader(mockRemoteTelemetry, mockPlatformProvider, mockLogger)
-
- uploader.logDiskFiles("test")
-
- verify {
- mockLogger.info(match { it.contains("disk test count=1 [name=crash.log bytes=5]") })
- }
- }
-
- @Test
- fun `SEND_TIMEOUT_SECONDS should be 30 seconds`() {
- OtelCrashUploader.SEND_TIMEOUT_SECONDS shouldBe 30L
- }
-}
diff --git a/OneSignalSDK/settings.gradle b/OneSignalSDK/settings.gradle
index d385e2a3bd..1f75cb3e99 100644
--- a/OneSignalSDK/settings.gradle
+++ b/OneSignalSDK/settings.gradle
@@ -15,7 +15,6 @@ gradle.rootProject {
substitute(module('com.onesignal:notifications')).using(project(':OneSignal:notifications'))
substitute(module('com.onesignal:location')).using(project(':OneSignal:location'))
substitute(module('com.onesignal:in-app-messages')).using(project(':OneSignal:in-app-messages'))
- substitute(module('com.onesignal:otel')).using(project(':OneSignal:otel'))
substitute(module('com.onesignal:kmp')).using(project(':OneSignal:kmp'))
}
}
@@ -32,7 +31,6 @@ include ':OneSignal:in-app-messages'
include ':OneSignal:location'
include ':OneSignal:notifications'
include ':OneSignal:testhelpers'
-include ':OneSignal:otel'
// The :kmp module is the shared Kotlin Multiplatform module maintained in the standalone
// OneSignal-KMP-SDK repo and consumed here as a git submodule (pinned commit). Its
diff --git a/examples/demo/app/proguard-rules.pro b/examples/demo/app/proguard-rules.pro
index 574f565e4b..375f267ce1 100644
--- a/examples/demo/app/proguard-rules.pro
+++ b/examples/demo/app/proguard-rules.pro
@@ -20,6 +20,5 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-# No app-level -dontwarn for OneSignal OTel here: when com.onesignal:core pulls in com.onesignal:otel
-# (implementation dependency), AGP merges otel's consumer-rules.pro for R8 (SDK-4207 / #2596).
-# Older SDK lines without otel never put those optional classes on the classpath, so duplicates are unnecessary.
+# No app-level -dontwarn for OpenTelemetry: the SDK no longer depends on it, so the optional
+# Jackson / AutoValue / io.opentelemetry classes it used to reference are never on the classpath.