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.