From 786a3735dbf92eb437bbac340e1139dd72989f79 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 10:48:36 -0500 Subject: [PATCH 01/12] refactor: [SDK-5065] remove the otel observability path and OpenTelemetry dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multiplatform logger module is validated in production, so the legacy OpenTelemetry pipeline it was built to replace is now dead weight. Keeping both meant shipping two ANR detectors, two crash reporters, two platform providers and two lifecycle managers behind a startup feature-flag branch, and it kept the io.opentelemetry tree on every integrator's classpath — the source of the recurring R8 "Missing class" failures in SDK-4820 and SDK-5006. The logger pipeline is now unconditional. LoggerModuleSwitch, the SDK_CUSTOM_LOGGING gate and resolveCustomLoggingEnabled are gone, which also fixes the first-launch gap: with no cached config the switch defaulted to otel, so a freshly installed app would have had no observability at all once otel was deleted. Code the logger path shared with otel is kept and renamed off the otel prefix rather than deleted: OtelPlatformProvider now implements ILoggerPlatformProvider directly (retiring the adapter), OtelIdResolver becomes LoggerIdResolver, and the OtelConfig/OtelSdkSupport pair becomes ObservabilityConfig/ObservabilitySdkSupport. The crash directory keeps its `onesignal/otel/crashes` path on purpose. Renaming it would orphan logger-owned records an upgrading install still has pending; OTel-format records left in it are reclaimed by the existing suffix-based purge. Verified: no io.opentelemetry in any published module's releaseRuntimeClasspath or POM, none in the release APK, and the example app minifies under R8 full mode for both flavors with no missing-class diagnostics. Co-authored-by: Cursor --- MIGRATION_GUIDE.md | 20 + OneSignalSDK/build.gradle | 4 - OneSignalSDK/onesignal/core/build.gradle | 4 +- .../core/src/main/AndroidManifest.xml | 6 +- .../common/threading/OneSignalDispatchers.kt | 4 +- .../core/internal/http/OneSignalService.kt | 2 +- .../debug/internal/crash/AnrCheckEvaluator.kt | 7 +- ...kSupport.kt => ObservabilitySdkSupport.kt} | 6 +- .../crash/OneSignalCrashHandlerFactory.kt | 44 --- .../crash/OneSignalCrashUploaderWrapper.kt | 66 +--- .../debug/internal/crash/OtelAnrDetector.kt | 358 ------------------ .../debug/internal/logging/Logging.kt | 82 +--- .../logging/logger/LoggerModuleSwitch.kt | 28 -- .../logger/android/AndroidLogAnrDetector.kt | 5 +- .../logger/android/AndroidLogCrashHandler.kt | 3 +- .../logging/logger/android/AndroidLogger.kt | 2 +- .../logging/logger/android/CrashDirCleanup.kt | 4 +- .../logging/logger/android/FileLogStore.kt | 8 +- .../android/LoggerIdResolver.kt} | 21 +- .../logger/android/LoggerPlatformFactory.kt | 19 - .../android/LoggerPlatformProvider.kt} | 59 +-- .../android/LoggerPlatformProviderAdapter.kt | 52 --- .../logger/android/OneSignalLogHttpSender.kt | 2 +- .../logging/otel/android/AndroidOtelLogger.kt | 26 -- .../IObservabilityLifecycleManager.kt | 5 +- .../internal/LoggerLifecycleManager.kt | 40 +- .../internal/ObservabilityConfigEvaluator.kt | 68 ++++ .../com/onesignal/internal/OneSignalImp.kt | 32 +- .../onesignal/internal/OtelConfigEvaluator.kt | 68 ---- .../internal/OtelLifecycleManager.kt | 244 ------------ .../core/src/test/AndroidManifest.xml | 2 +- .../internal/crash/AnrCheckEvaluatorTest.kt | 2 +- ...Test.kt => ObservabilitySdkSupportTest.kt} | 22 +- .../crash/OneSignalCrashHandlerFactoryTest.kt | 81 ---- .../OneSignalCrashUploaderWrapperTest.kt | 32 ++ .../internal/crash/OtelAnrDetectorTest.kt | 262 ------------- .../internal/crash/OtelIntegrationTest.kt | 165 -------- .../debug/internal/logging/LoggingOtelTest.kt | 232 ------------ .../internal/logging/LoggingRemoteTest.kt | 118 ++++++ .../debug/internal/logging/LoggingTest.kt | 60 +-- .../logging/logger/LoggerModuleSwitchTest.kt | 63 --- .../logger/android/FileLogStoreTest.kt | 2 +- .../android/LoggerIdResolverTest.kt} | 161 +++----- .../android/LoggerPlatformProviderTest.kt} | 165 ++++---- .../otel/android/AndroidOtelLoggerTest.kt | 74 ---- ...rTest.kt => LoggerLifecycleManagerTest.kt} | 109 +++--- .../ObservabilityConfigEvaluatorTest.kt | 102 +++++ .../internal/OtelConfigEvaluatorTest.kt | 102 ----- .../internal/OtelLifecycleManagerFaultTest.kt | 318 ---------------- .../internal/StartupDiagnosticsTest.kt | 9 +- .../internal/display/impl/WebViewManager.kt | 6 +- OneSignalSDK/onesignal/otel/.gitignore | 1 - OneSignalSDK/onesignal/otel/build.gradle | 71 ---- .../onesignal/otel/consumer-rules.pro | 17 - .../onesignal/otel/proguard-rules.pro | 21 - .../otel/src/main/AndroidManifest.xml | 4 - .../com/onesignal/otel/IOtelCrashHandler.kt | 19 - .../com/onesignal/otel/IOtelCrashReporter.kt | 20 - .../java/com/onesignal/otel/IOtelLogger.kt | 35 -- .../com/onesignal/otel/IOtelOpenTelemetry.kt | 45 --- .../onesignal/otel/IOtelPlatformProvider.kt | 121 ------ .../onesignal/otel/OneSignalOpenTelemetry.kt | 135 ------- .../java/com/onesignal/otel/OtelFactory.kt | 115 ------ .../com/onesignal/otel/OtelLoggingHelper.kt | 65 ---- .../otel/attributes/OtelFieldsPerEvent.kt | 45 --- .../otel/attributes/OtelFieldsTopLevel.kt | 78 ---- .../otel/config/OtelConfigCrashFile.kt | 50 --- .../otel/config/OtelConfigRemoteOneSignal.kt | 134 ------- .../onesignal/otel/config/OtelConfigShared.kt | 58 --- .../onesignal/otel/crash/IOtelAnrDetector.kt | 21 - .../onesignal/otel/crash/OtelCrashHandler.kt | 127 ------- .../onesignal/otel/crash/OtelCrashReporter.kt | 82 ---- .../onesignal/otel/crash/OtelCrashUploader.kt | 127 ------- .../otel/OneSignalOpenTelemetryTest.kt | 175 --------- .../com/onesignal/otel/OtelFactoryTest.kt | 204 ---------- .../onesignal/otel/OtelLoggingHelperTest.kt | 145 ------- .../otel/attributes/OtelFieldsPerEventTest.kt | 119 ------ .../otel/attributes/OtelFieldsTopLevelTest.kt | 147 ------- .../onesignal/otel/config/OtelConfigTest.kt | 137 ------- .../otel/crash/OtelCrashHandlerTest.kt | 169 --------- .../otel/crash/OtelCrashReporterTest.kt | 190 ---------- .../otel/crash/OtelCrashUploaderTest.kt | 160 -------- OneSignalSDK/settings.gradle | 2 - examples/demo/app/proguard-rules.pro | 5 +- 84 files changed, 662 insertions(+), 5558 deletions(-) rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/{OtelSdkSupport.kt => ObservabilitySdkSupport.kt} (80%) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactory.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OtelAnrDetector.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/{otel/android/OtelIdResolver.kt => logger/android/LoggerIdResolver.kt} (90%) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformFactory.kt rename OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/{otel/android/OtelPlatformProvider.kt => logger/android/LoggerPlatformProvider.kt} (79%) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLogger.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/ObservabilityConfigEvaluator.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelConfigEvaluator.kt delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt rename OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/{OtelSdkSupportTest.kt => ObservabilitySdkSupportTest.kt} (50%) delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OneSignalCrashHandlerFactoryTest.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelAnrDetectorTest.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/OtelIntegrationTest.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingOtelTest.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingRemoteTest.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt rename OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/{otel/android/OtelIdResolverTest.kt => logger/android/LoggerIdResolverTest.kt} (88%) rename OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/{otel/android/OtelPlatformProviderTest.kt => logger/android/LoggerPlatformProviderTest.kt} (78%) delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/AndroidOtelLoggerTest.kt rename OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/{OtelLifecycleManagerTest.kt => LoggerLifecycleManagerTest.kt} (51%) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/ObservabilityConfigEvaluatorTest.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelConfigEvaluatorTest.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerFaultTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/.gitignore delete mode 100644 OneSignalSDK/onesignal/otel/build.gradle delete mode 100644 OneSignalSDK/onesignal/otel/consumer-rules.pro delete mode 100644 OneSignalSDK/onesignal/otel/proguard-rules.pro delete mode 100644 OneSignalSDK/onesignal/otel/src/main/AndroidManifest.xml delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashHandler.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelCrashReporter.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelLogger.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelOpenTelemetry.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelFactory.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OtelLoggingHelper.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsPerEvent.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/attributes/OtelFieldsTopLevel.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigCrashFile.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigShared.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/IOtelAnrDetector.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashHandler.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashReporter.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/crash/OtelCrashUploader.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelFactoryTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OtelLoggingHelperTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsPerEventTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/attributes/OtelFieldsTopLevelTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashHandlerTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashReporterTest.kt delete mode 100644 OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/crash/OtelCrashUploaderTest.kt diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 48f037b0a5..504c7cdf7e 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -50,6 +50,26 @@ 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 + +The SDK no longer depends on OpenTelemetry. The `com.onesignal:otel` artifact has been removed, and with it the entire `io.opentelemetry` dependency tree (`opentelemetry-api`, `-sdk`, `-exporter-otlp`, `-semconv`, and `opentelemetry-disk-buffering`). SDK diagnostics are now handled by an internal implementation with no third-party telemetry dependencies. + +There is no API change — this is a dependency-only change. 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. + + ## 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/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/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..5ace6da4a1 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 @@ -7,8 +7,9 @@ import java.util.concurrent.atomic.AtomicLong * * 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]. + * Android shell ([com.onesignal.debug.internal.logging.logger.android.AndroidLogAnrDetector]) owns + * the thread, the real sleep, and the reporting side effects, and delegates every per-iteration + * decision 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 +22,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) 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..3bb79ca767 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,34 @@ 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.logger.LoggerFactory -import com.onesignal.otel.OtelFactory -import com.onesignal.otel.crash.OtelCrashUploader import java.io.File import kotlin.coroutines.cancellation.CancellationException /** - * Android-specific wrapper for OtelCrashUploader that implements IStartableService. + * Android-specific wrapper for the shared crash uploader 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 + * 2. Creates platform-agnostic implementations (ILoggerPlatformProvider, ILogger) + * 3. Wraps the platform-agnostic LogCrashUploader 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 - * ``` + * The uploader itself is fully platform-agnostic and can be used directly in KMP projects + * by providing platform-specific implementations of: + * - ILoggerPlatformProvider (inject all platform values) + * - ILogger (platform logging interface) */ 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 +43,15 @@ 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() - } + // 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. + uploader.start() + logCrashDirInventory("after-cleanup") } catch (e: CancellationException) { throw e } catch (t: Throwable) { @@ -95,13 +63,13 @@ internal class OneSignalCrashUploaderWrapper( } } - /** Resolves the shared crash directory both modules write to. */ + /** Resolves the crash directory the logger module reads and writes. */ private fun crashStoragePath(): String = - createAndroidOtelPlatformProvider(applicationService.appContext) { featureManager } + createAndroidLoggerPlatformProvider(applicationService.appContext) { featureManager } .crashStoragePath /** - * 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. */ 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..e8393bbd96 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 @@ -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, 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 index 377aeab71f..3b53533117 100644 --- 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 @@ -1,10 +1,10 @@ package com.onesignal.debug.internal.logging.logger.android /** - * Pure, Android-free helpers for the shared logger/otel crash directory. + * Pure, Android-free helpers for the 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 + * (bare-millis names left by a pre-upgrade otel session, 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" 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..db63c84440 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 @@ -19,10 +19,10 @@ import kotlin.coroutines.cancellation.CancellationException * `minFileAgeForReadMillis` behavior (never read a file the crashing process may * still have been writing). * - * 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. + * The directory is inherited from the removed otel module, so ownership is distinguished + * purely by [CRASH_OWNED_SUFFIX]: everything the logger writes ends in `.otlp`; anything + * else (bare-millis files left by an otel session before upgrade, stray `.tmp`s) is + * foreign and reclaimable via [deleteUnrecognizedEntries]. */ internal class FileLogStore( private val rootPath: String, 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..849da654ba 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,10 @@ 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 [selectUnrecognizedEntries]. + */ +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..a2974a2262 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 @@ -24,13 +24,9 @@ import com.onesignal.logger.ILoggerPlatformProvider import com.onesignal.logger.LoggerFactory /** - * 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]). - * - * 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. + * 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]. */ @Suppress("TooManyFunctions") internal class LoggerLifecycleManager( @@ -51,18 +47,18 @@ internal class LoggerLifecycleManager( 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 @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 +73,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,19 +93,19 @@ 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) { + private fun applyAction(action: ObservabilityConfigAction, newConfig: ObservabilityConfig) { 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") + is ObservabilityConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR) + is ObservabilityConfigAction.Disable -> disableFeatures() + is ObservabilityConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel) + is ObservabilityConfigAction.NoChange -> Logging.debug("OneSignal: logger config unchanged") } currentConfig = newConfig } 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..ac1a44e6ff --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/ObservabilityConfigEvaluator.kt @@ -0,0 +1,68 @@ +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. + * + * Designed to be fully unit-testable without mocks. + */ +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..2c0b9c7e5e 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 @@ -6,7 +6,7 @@ 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]. + * on a real JVM (and therefore counted by coverage), unlike the Android shell in [AndroidLogAnrDetector]. * * Defaults mirror AnrConstants: 5s foreground ANR, 2s check interval, 2s frozen slack, 10s background * warning, 30s dedup window. 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..f63d5e9dc4 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 @@ -109,4 +114,31 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ wrapper shouldNotBe null } + + // Upgrading installs inherit a crash dir holding OTel-format records that nothing can + // read anymore. They must not linger. This covers the first launch after upgrade, where + // there is no cached config yet, so the uploader reclaims them without an upload pass. + test("start reclaims records left in the crash dir by a pre-upgrade otel session") { + val crashDir = File(getCrashStoragePath(appContext)).apply { mkdirs() } + // OTel's disk-buffering 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..10a143e2a4 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/LoggingRemoteTest.kt @@ -0,0 +1,118 @@ +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.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.robolectric.annotation.Config + +/** + * 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() + Logging.setLoggerTelemetry(telemetry) { true } + + Logging.error("boom") + runBlocking { delay(200) } + + 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() + Logging.setLoggerTelemetry(telemetry) { true } + + Logging.error("with cause", IllegalStateException("bad state")) + runBlocking { delay(200) } + + 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(200) } + + 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(200) } + + 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(200) } + + coVerify(exactly = 0) { telemetry.emit(any()) } + } + + test("a throwing sink does not propagate to the caller") { + val telemetry = mockk() + io.mockk.coEvery { telemetry.emit(any()) } throws RuntimeException("sink down") + Logging.setLoggerTelemetry(telemetry) { true } + + Logging.error("survives a broken sink") + runBlocking { delay(200) } + + coVerify { telemetry.emit(any()) } + } + + test("every severity is forwarded") { + val telemetry = mockk(relaxed = true) + Logging.setLoggerTelemetry(telemetry) { true } + + Logging.verbose("v") + Logging.debug("d") + Logging.info("i") + Logging.warn("w") + Logging.error("e") + Logging.fatal("f") + runBlocking { delay(300) } + + coVerify(exactly = 6) { telemetry.emit(any()) } + } +}) 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/FileLogStoreTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/FileLogStoreTest.kt index d7083a58d3..840c2a5f45 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 @@ -30,7 +30,7 @@ class FileLogStoreTest : FunSpec({ } 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 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/OtelLifecycleManagerTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt similarity index 51% rename from OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt rename to OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt index 5de65dfbc3..7b42d742d3 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OtelLifecycleManagerTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt @@ -8,105 +8,95 @@ 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.crash.ObservabilitySdkSupport +import com.onesignal.debug.internal.logging.logger.android.AndroidLogCrashHandler import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.every import io.mockk.mockk import org.robolectric.annotation.Config +/** + * The logger pipeline is the SDK's only observability path, so these cover the config + * state machine that brings it up and tears it down. + */ @RobolectricTest @Config(sdk = [Build.VERSION_CODES.O]) -class OtelLifecycleManagerTest : FunSpec({ +class LoggerLifecycleManagerTest : FunSpec({ lateinit var context: Context lateinit var featureManager: IFeatureManager + var originalHandler: Thread.UncaughtExceptionHandler? = null - 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 }) - } + fun newManager(): LoggerLifecycleManager = + LoggerLifecycleManager(context = context, featureManagerProvider = { featureManager }) beforeEach { context = ApplicationProvider.getApplicationContext() featureManager = mockk().also { every { it.enabledFeatureKeys() } returns emptyList() } - OtelSdkSupport.isSupported = true + originalHandler = Thread.getDefaultUncaughtExceptionHandler() + ObservabilitySdkSupport.isSupported = true } afterEach { - OtelSdkSupport.reset() + ObservabilitySdkSupport.reset() + Thread.setDefaultUncaughtExceptionHandler(originalHandler) } - test("initializeFromCachedConfig does not crash when SDK unsupported") { - OtelSdkSupport.isSupported = false - val manager = newManager() - manager.initializeFromCachedConfig() - } + test("initializeFromCachedConfig is a no-op when the SDK level is unsupported") { + ObservabilitySdkSupport.isSupported = false - test("initializeFromCachedConfig does not throw on supported SDK") { - val manager = newManager() - manager.initializeFromCachedConfig() - } + newManager().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) + 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) + test("initializeFromCachedConfig with no cached config leaves features off") { + newManager().initializeFromCachedConfig() + + Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler } - test("onModelReplaced enable then disable does not throw") { + 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) - manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE) + + Thread.getDefaultUncaughtExceptionHandler().shouldBeInstanceOf() } - test("onModelReplaced updates log level without throwing") { + test("onModelReplaced is ignored when the SDK level is unsupported") { + ObservabilitySdkSupport.isSupported = false 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) + + Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler } - test("onModelReplaced with same config is no-op") { + test("onModelReplaced ignores non-HYDRATE tags") { 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) + manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.NORMAL) + + Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler } - test("disable clears Otel telemetry from Logging") { + 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) - Logging.info("test message after otel disabled") + Thread.getDefaultUncaughtExceptionHandler() shouldBe originalHandler } - test("full lifecycle: init -> enable -> update level -> disable -> re-enable") { + test("full lifecycle: enable, change level, disable, re-enable") { val manager = newManager() manager.initializeFromCachedConfig() @@ -115,26 +105,19 @@ class OtelLifecycleManagerTest : FunSpec({ 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("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 - }, - ) + test("repeating the same config does not re-register the handler") { + val manager = newManager() 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") + 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 } }) 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/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. From 1a3930455e555da6efcb7b93a556144adf1d8cab Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 11:03:09 -0500 Subject: [PATCH 02/12] fix: [SDK-5065] attribute JaCoCo coverage for Robolectric-loaded classes Robolectric loads classes through its own instrumenting classloader, which strips the source-location metadata JaCoCo uses to attribute execution. Every class exercised only by a @RobolectricTest therefore reported 0% coverage no matter how well tested it was, while plain-JVM tests in the same module reported ~96-100%. CrashDirCleanup's doc comment already alludes to this, noting that keeping the logic free of Robolectric is what gets it "counted by Jacoco on the plain JVM". The gap was invisible until the otel removal renamed ~180 lines of Robolectric-only-tested code, which moved them into the diff-coverage denominator and failed the changed-lines gate at 11%. Enabling includeNoLocationClasses fixes the attribution. Nothing about the tests changed, only what the report can see: LoggerPlatformProvider 1.2% -> 98.8% LoggerIdResolver 0.0% -> 90.7% LoggerLifecycleManager 0.0% -> 84.7% OneSignalCrashUploaderWrapper 0.0% -> 82.6% Logging 47.0% -> 84.0% OneSignalImp 28.7% -> 71.9% Untouched Robolectric-tested classes are now measured honestly too (AndroidLogAnrDetector 0% -> 49.5%, FileLogStore 0% -> 34.5%), so the reported figures reflect real coverage rather than a measurement artifact. Co-authored-by: Cursor --- OneSignalSDK/coverage/jacoco.gradle | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 = [ From a94b1e24f452e97dbdd24ff04e82bc11b5a93e96 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 12:08:42 -0500 Subject: [PATCH 03/12] fix: [SDK-5065] bound crash-record retention and restore lifecycle test seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing :otel took its disk-buffering config with it, including the 72h maxFileAgeForRead and the per-file/per-folder size limits. FileLogStore only had a lower age bound, and the purge deliberately skips owned .otlp records at any age, so a record that never uploaded — including one written while remote logging is off, which is never even read — would be retried every launch forever. Restore both bounds and delete over-limit records rather than merely hiding them from listReadable. The fold-in of OtelLifecycleManager also dropped its injectable factories, which left the surviving pipeline's try/catch isolation, ANR start/stop, and remote-sink wiring untestable. Restore the seams with production defaults so runtime wiring is unchanged, and port the fault matrix. Also correct the migration guide: the otel artifact is no longer published and Logging.setOtelTelemetry is gone, so "no API change" was wrong. Co-authored-by: Cursor --- MIGRATION_GUIDE.md | 8 +- .../logging/logger/android/CrashDirCleanup.kt | 64 ++++ .../logging/logger/android/FileLogStore.kt | 99 ++++-- .../internal/LoggerLifecycleManager.kt | 67 ++-- .../logger/android/FileLogStoreTest.kt | 63 +++- .../LoggerLifecycleManagerFaultTest.kt | 303 ++++++++++++++++++ .../internal/LoggerLifecycleManagerTest.kt | 68 ++++ 7 files changed, 627 insertions(+), 45 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 504c7cdf7e..f52504f91d 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -52,9 +52,11 @@ The above statement will bring in the entire OneSignalSDK and is the desired sta ## OpenTelemetry Dependency Removal -The SDK no longer depends on OpenTelemetry. The `com.onesignal:otel` artifact has been removed, and with it the entire `io.opentelemetry` dependency tree (`opentelemetry-api`, `-sdk`, `-exporter-otlp`, `-semconv`, and `opentelemetry-disk-buffering`). SDK diagnostics are now handled by an internal implementation with no third-party telemetry dependencies. +As of 5.10.0, 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. -There is no API change — this is a dependency-only change. For most integrations no action is required, but note the following: +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: @@ -69,6 +71,8 @@ There is no API change — this is a dependency-only change. For most integratio - **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: any crash report still buffered on disk from before the upgrade was written in OpenTelemetry's format, which the new implementation cannot read. Those leftover reports are deleted on the next launch rather than uploaded, so a crash captured immediately before the upgrade may never arrive. Reports captured from the upgraded version onward are unaffected. + ## Code Modularization 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 index 3b53533117..660041b7fa 100644 --- 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 @@ -9,6 +9,26 @@ package com.onesignal.debug.internal.logging.logger.android */ internal const val CRASH_OWNED_SUFFIX = ".otlp" +/** + * Upper bound on how long an owned record stays eligible for upload, carried over from the + * disk-buffering config the otel module used. Past this the payload is too stale to be worth + * shipping, and without a ceiling a record that never uploads successfully — including one + * written while remote logging is off, which is never even read — would be retried on every + * launch forever. + */ +internal const val CRASH_MAX_READ_AGE_MILLIS = 72L * 60 * 60 * 1000 + +/** + * Accumulation caps, applied oldest-first. The count bound is what normally binds: crash + * records are single-event OTLP payloads of a few KB, so 50 covers far more unsent crashes + * than a healthy install will ever hold. The byte bound is the backstop for pathological + * payloads (deep stacktraces, huge exception messages) where count alone would not keep the + * directory small. The newest record is always retained even if it alone exceeds the byte cap. + */ +internal const val CRASH_MAX_RECORD_COUNT = 50 + +internal const val CRASH_MAX_TOTAL_BYTES = 2L * 1024 * 1024 + internal data class CrashDirEntry( val name: String, val lastModifiedMs: Long, @@ -34,6 +54,50 @@ internal fun selectUnrecognizedEntries( nowMs - entry.lastModifiedMs >= minAgeMillis } +/** + * Returns owned entries past [maxAgeMillis] — no longer uploadable, so they are reclaimed + * rather than skipped. Foreign entries are left to [selectUnrecognizedEntries]. + */ +internal fun selectExpiredOwnedEntries( + entries: List, + nowMs: Long, + maxAgeMillis: Long = CRASH_MAX_READ_AGE_MILLIS, + ownedSuffix: String = CRASH_OWNED_SUFFIX, +): List = + entries.filter { entry -> + isOwnedCrashFile(entry.name, ownedSuffix) && + nowMs - entry.lastModifiedMs > maxAgeMillis + } + +/** + * Returns the owned entries to evict so the directory fits within [maxCount] and + * [maxTotalBytes]. Newest records are kept; the excess is returned oldest-first. The single + * newest record is never evicted, so an oversized payload cannot starve the cache. + */ +internal fun selectOverflowOwnedEntries( + entries: List, + maxCount: Int = CRASH_MAX_RECORD_COUNT, + maxTotalBytes: Long = CRASH_MAX_TOTAL_BYTES, + ownedSuffix: String = CRASH_OWNED_SUFFIX, +): List { + // Name breaks ties: owned names are millis-prefixed, so it orders consistently with mtime + // when a filesystem reports coarse timestamps. + val newestFirst = + entries + .filter { isOwnedCrashFile(it.name, ownedSuffix) } + .sortedWith(compareByDescending { it.lastModifiedMs }.thenByDescending { it.name }) + + val kept = HashSet() + var keptBytes = 0L + for (entry in newestFirst) { + if (kept.size >= maxCount) break + if (kept.isNotEmpty() && keptBytes + entry.lengthBytes > maxTotalBytes) break + kept.add(entry.name) + keptBytes += entry.lengthBytes + } + return newestFirst.filterNot { kept.contains(it.name) }.reversed() +} + /** * Builds the human-readable crash-dir inventory line used for rollout verification. * Per-file detail is capped at [maxSample] so Logcat is not flooded. 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 db63c84440..b39eacb017 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 @@ -23,6 +23,11 @@ import kotlin.coroutines.cancellation.CancellationException * purely by [CRASH_OWNED_SUFFIX]: everything the logger writes ends in `.otlp`; anything * else (bare-millis files left by an otel session before upgrade, stray `.tmp`s) is * foreign and reclaimable via [deleteUnrecognizedEntries]. + * + * Owned records are bounded on both axes, replacing the caps disk-buffering used to apply: + * [CRASH_MAX_READ_AGE_MILLIS] ages records out, and [CRASH_MAX_RECORD_COUNT] / + * [CRASH_MAX_TOTAL_BYTES] cap accumulation. Over-limit records are deleted, not just hidden + * from [listReadable], so a record that never uploads cannot grow the cache forever. */ internal class FileLogStore( private val rootPath: String, @@ -50,6 +55,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) true } catch (t: Throwable) { // Crash-path safety: never throw from persistence; signal failure to caller. @@ -58,21 +64,78 @@ internal class FileLogStore( } } + /** + * Evicts oldest-first until the owned records fit the accumulation caps. + * + * Runs inline on the crashing thread — it is a single directory listing plus at most a + * few deletes, and deferring it would mean the write that breached the cap is the one + * that never gets trimmed. Uses raw Logcat for the same reason [save] does. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun enforceAccumulationCaps(dir: File) { + try { + val overflow = selectOverflowOwnedEntries(listEntries(dir)) + if (overflow.isEmpty()) return + var evicted = 0 + for (entry in overflow) { + if (File(dir, entry.name).delete()) evicted++ + } + Log.i(TAG, "FileLogStore: evicted $evicted over-cap record(s) in ${dir.path}") + } catch (t: Throwable) { + // Never let cache trimming turn a successful crash write into a failure. + Log.w(TAG, "FileLogStore: cap enforcement failed: ${t.message}") + } + } + + 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 [CRASH_MAX_READ_AGE_MILLIS]. 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 names of the expired records, whether or not the delete succeeded — a record + * past the ceiling must not be read even if it could not be removed this pass + */ + private fun reclaimExpiredOwnedRecords(entries: List, nowMs: Long): Set { + val expired = selectExpiredOwnedEntries(entries, nowMs) + 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 record(s) in ${rootDir.path}") + 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) + val expired = reclaimExpiredOwnedRecords(entries, now) + val suffixMatches = + entries.filter { isOwnedCrashFile(it.name) && !expired.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} " + - "suffix=${suffixMatches.size} readable=${readable.size} " + - "legacy=${allFiles.size - suffixMatches.size}", + "FileLogStore: listReadable minAgeMs=$minAgeMillis total=${entries.size} " + + "suffix=${suffixMatches.size} readable=${readable.size} expired=${expired.size} " + + "legacy=${entries.count { !isOwnedCrashFile(it.name) }}", ) readable } catch (e: CancellationException) { @@ -109,28 +172,24 @@ 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. + * 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 + * [CRASH_MAX_READ_AGE_MILLIS], which are no longer uploadable. * * Implements the shared [ILogFileStore] contract: the KMP `LogCrashUploader` - * invokes this after its owned-record upload pass. Idempotent and safe to call - * repeatedly. + * invokes this after its owned-record upload pass, and — unlike [listReadable] — + * also when remote logging is disabled, which is the only chance to age out 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 expired 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 listed = listEntries(rootDir) + reclaimExpiredOwnedRecords(listed, now) val foreign = selectUnrecognizedEntries(listed, now, minAgeMillis) if (foreign.isEmpty()) { Logging.debug("FileLogStore: no unrecognized files to purge in ${rootDir.path}") 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 a2974a2262..38d7d6c52c 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 @@ -19,30 +19,69 @@ 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, + ) + /** * 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]. + * + * Production callers supply only [context] and [featureManagerProvider]; every other + * parameter defaults to the real implementation, so runtime wiring is unchanged. Tests + * override them to inject mocks or throwing stubs. */ -@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 @@ -166,9 +205,7 @@ internal class LoggerLifecycleManager( private fun startCrashHandler() { if (crashHandler != null) return - val crashTelemetry = LoggerFactory.createCrashLocalTelemetry(platformProvider, fileStore) - val reporter = LoggerFactory.createCrashReporter(crashTelemetry, logger) - val handler = AndroidLogCrashHandler(reporter, logger) + val handler = crashHandlerFactory(platformProvider, fileStore, logger) handler.initialize() crashHandler = handler Logging.info("OneSignal: logger crash handler initialized — logs at: ${platformProvider.crashStoragePath}") @@ -176,19 +213,7 @@ internal class LoggerLifecycleManager( 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" }, - ) + val detector = anrDetectorFactory(platformProvider, fileStore, logger) detector.start() anrDetector = detector Logging.info("OneSignal: logger ANR detector started") @@ -196,7 +221,7 @@ internal class LoggerLifecycleManager( private fun startLogging(logLevel: LogLevel) { remoteTelemetry?.shutdown() - val telemetry = LoggerFactory.createRemoteTelemetry(platformProvider, httpSender) + val telemetry = remoteTelemetryFactory(platformProvider, httpSender) remoteTelemetry = telemetry val shouldSend: (LogLevel) -> Boolean = { level -> logLevel != LogLevel.NONE && level <= logLevel 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 840c2a5f45..4da008b218 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 @@ -23,9 +23,9 @@ 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) } @@ -88,4 +88,63 @@ 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 = CRASH_MAX_READ_AGE_MILLIS + 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 = CRASH_MAX_READ_AGE_MILLIS - 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 = CRASH_MAX_READ_AGE_MILLIS + 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(CRASH_MAX_RECORD_COUNT) { i -> + write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) + } + val oldest = "seed-${CRASH_MAX_RECORD_COUNT - 1}.otlp" + + FileLogStore(dir.path).save("new".toByteArray()) shouldBe true + + dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe CRASH_MAX_RECORD_COUNT + 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") { + val large = (CRASH_MAX_TOTAL_BYTES * 3 / 4).toInt() + write("big-oldest.otlp", ageMsAgo = 20_000, sizeBytes = large) + write("big-newer.otlp", ageMsAgo = 10_000, sizeBytes = large) + + FileLogStore(dir.path).save("new".toByteArray()) shouldBe true + + File(dir, "big-oldest.otlp").exists() shouldBe false + File(dir, "big-newer.otlp").exists() shouldBe true + dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe 2 + } }) 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..6cd3dd9dbd --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt @@ -0,0 +1,303 @@ +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.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 the SDK's only observability pipeline, ported from the + * deleted otel equivalent. + * + * Every collaborator is constructed behind an injectable factory, so these drive the + * `try/catch` isolation in [LoggerLifecycleManager] directly: 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) + } + + fun enabledConfig(logLevel: LogLevel = LogLevel.ERROR): ConfigModel = + ConfigModel().apply { + remoteLoggingParams.isEnabled = true + remoteLoggingParams.logLevel = logLevel + } + + fun disabledConfig(): ConfigModel = + ConfigModel().apply { remoteLoggingParams.isEnabled = false } + + /** + * Builds a manager whose collaborators are all mocks unless a factory is overridden to + * throw. The platform provider is relaxed so property reads during startup are inert. + */ + 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("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("enable called twice does not create duplicate crash handler or ANR detector") { + var handlerCount = 0 + var detectorCount = 0 + val manager = managerWith( + crashHandler = { handlerCount++; mockk(relaxed = true) }, + anrDetector = { detectorCount++; mockk(relaxed = true) }, + ) + + manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE) + // A second Enable can only arrive via disable/re-enable; a repeated identical config + // evaluates to NoChange, so drive the guard directly with a level change instead. + manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE) + + handlerCount shouldBe 1 + detectorCount shouldBe 1 + } + + 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 index 7b42d742d3..369d853233 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt @@ -9,12 +9,21 @@ 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.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import org.robolectric.annotation.Config /** @@ -43,6 +52,8 @@ class LoggerLifecycleManagerTest : FunSpec({ 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") { @@ -119,8 +130,65 @@ class LoggerLifecycleManagerTest : FunSpec({ 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") { + val telemetry = 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 = { _, _, _ -> mockk(relaxed = true) }, + remoteTelemetryFactory = { _, _ -> telemetry }, + ) + + manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE) + // Sink is live: an ERROR at the configured level reaches the telemetry. + Logging.error("routed while enabled") + runBlocking { delay(SINK_SETTLE_MS) } + coVerify { telemetry.emit(any()) } + + manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE) + verify { telemetry.shutdown() } + + // Sink is detached: nothing further reaches it. + clearMocks(telemetry, answers = false) + Logging.error("dropped after disable") + runBlocking { delay(SINK_SETTLE_MS) } + coVerify(exactly = 0) { telemetry.emit(any()) } + } }) +/** Remote emission hops to a background scope; long enough to settle without being flaky. */ +private const val SINK_SETTLE_MS = 300L + private fun configWith(isEnabled: Boolean, logLevel: LogLevel?): ConfigModel { val config = ConfigModel() config.remoteLoggingParams.isEnabled = isEnabled From 9a42d06f7453c8f3ac20abb9ecaed0b045453bc0 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 12:49:17 -0500 Subject: [PATCH 04/12] fix: [SDK-5065] enforce crash-record caps on every path and correct upgrade docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review found the accumulation caps were enforced only in save(), so an install carrying a backlog from a build without caps — which includes the large 5.9.x cohort already on the logger path — was fully listed and re-POSTed every launch until a new crash happened to trim it. Both bounds now run on listReadable and deleteUnrecognizedEntries too, reclaiming before payloads are read so an over-cap directory is never fully loaded. The crash path keeps only a cheap bounded trim; bulk reclaim happens on the uploader's IO paths. The byte cap also treated the first over-budget record as a cutoff, so one oversized payload evicted the entire older backlog — the opposite of what the cap is for. Skip it instead, and add a per-record cap so an outsized payload is dropped alone. selectOverflowOwnedEntries now also pins the record save() just wrote, so a backwards clock step cannot make it sort oldest and delete it. disableFeatures cleared each field only after the teardown call returned, so a throwing stop()/unregister() left the field set and the start guards then treated the dead component as running for the rest of the process. The migration guide claimed all pre-upgrade crash records are deleted. That is true only for OTel-format records; logger-path records are uploaded normally, and telling integrators otherwise would misdirect support. Tests: JVM coverage for both selectors including boundary, tie-break, oversized and keepName cases; re-enable-after-teardown-failure cases; the enable-twice case now asserts something; and the fault suite no longer leaks a mock sink into the global Logging object. Co-authored-by: Cursor --- MIGRATION_GUIDE.md | 9 +- .../logging/logger/android/CrashDirCleanup.kt | 50 +++++-- .../logging/logger/android/FileLogStore.kt | 90 ++++++++++--- .../internal/LoggerLifecycleManager.kt | 15 ++- .../logger/android/CrashDirCleanupTest.kt | 122 ++++++++++++++++++ .../logger/android/FileLogStoreTest.kt | 45 ++++++- .../LoggerLifecycleManagerFaultTest.kt | 57 +++++++- .../internal/LoggerLifecycleManagerTest.kt | 22 +++- 8 files changed, 363 insertions(+), 47 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index f52504f91d..f19d502359 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -52,7 +52,7 @@ The above statement will bring in the entire OneSignalSDK and is the desired sta ## OpenTelemetry Dependency Removal -As of 5.10.0, 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. +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. @@ -71,7 +71,12 @@ For most integrations no action is required, but note the following: - **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: any crash report still buffered on disk from before the upgrade was written in OpenTelemetry's format, which the new implementation cannot read. Those leftover reports are deleted on the next launch rather than uploaded, so a crash captured immediately before the upgrade may never arrive. Reports captured from the upgraded version onward are unaffected. +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 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 index 660041b7fa..9085d207c3 100644 --- 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 @@ -23,12 +23,18 @@ internal const val CRASH_MAX_READ_AGE_MILLIS = 72L * 60 * 60 * 1000 * records are single-event OTLP payloads of a few KB, so 50 covers far more unsent crashes * than a healthy install will ever hold. The byte bound is the backstop for pathological * payloads (deep stacktraces, huge exception messages) where count alone would not keep the - * directory small. The newest record is always retained even if it alone exceeds the byte cap. + * directory small. */ internal const val CRASH_MAX_RECORD_COUNT = 50 internal const val CRASH_MAX_TOTAL_BYTES = 2L * 1024 * 1024 +/** + * A single record above this is dropped on its own rather than being allowed to consume the + * whole byte budget. Without it, one huge payload would push every other pending record out. + */ +internal const val CRASH_MAX_RECORD_BYTES = 512L * 1024 + internal data class CrashDirEntry( val name: String, val lastModifiedMs: Long, @@ -57,6 +63,9 @@ internal fun selectUnrecognizedEntries( /** * Returns owned entries past [maxAgeMillis] — no longer uploadable, so they are reclaimed * rather than skipped. Foreign entries are left to [selectUnrecognizedEntries]. + * + * A negative age (mtime in the future, i.e. the clock moved backwards since the write) is + * never treated as expired; the record simply waits until the clock agrees it is old. */ internal fun selectExpiredOwnedEntries( entries: List, @@ -65,33 +74,56 @@ internal fun selectExpiredOwnedEntries( ownedSuffix: String = CRASH_OWNED_SUFFIX, ): List = entries.filter { entry -> - isOwnedCrashFile(entry.name, ownedSuffix) && - nowMs - entry.lastModifiedMs > maxAgeMillis + val age = nowMs - entry.lastModifiedMs + isOwnedCrashFile(entry.name, ownedSuffix) && age > maxAgeMillis } +/** Leading millis of a `{millis}-{uuid}.otlp` name, or null for anything else. */ +private fun leadingMillis(name: String): Long? = name.substringBefore('-').toLongOrNull() + /** * Returns the owned entries to evict so the directory fits within [maxCount] and - * [maxTotalBytes]. Newest records are kept; the excess is returned oldest-first. The single - * newest record is never evicted, so an oversized payload cannot starve the cache. + * [maxTotalBytes], newest kept and the excess returned oldest-first. + * + * A record larger than [maxRecordBytes] is evicted on its own rather than being allowed to + * exhaust the shared budget — otherwise a single deep-stacktrace payload would push out every + * other pending crash. For the same reason a record that merely does not fit the *remaining* + * budget is skipped, not treated as a cutoff: everything older still gets its chance to fit. + * + * [keepName] is the record the caller just wrote. It is retained regardless of size or sort + * position, so a backwards clock step cannot make a fresh record look oldest and delete it. */ internal fun selectOverflowOwnedEntries( entries: List, maxCount: Int = CRASH_MAX_RECORD_COUNT, maxTotalBytes: Long = CRASH_MAX_TOTAL_BYTES, + maxRecordBytes: Long = CRASH_MAX_RECORD_BYTES, + keepName: String? = null, ownedSuffix: String = CRASH_OWNED_SUFFIX, ): List { - // Name breaks ties: owned names are millis-prefixed, so it orders consistently with mtime - // when a filesystem reports coarse timestamps. + // Ties break on the millis embedded in the name, which is the write time the filesystem + // may have rounded away. Names that do not parse sort last among their timestamp group. val newestFirst = entries .filter { isOwnedCrashFile(it.name, ownedSuffix) } - .sortedWith(compareByDescending { it.lastModifiedMs }.thenByDescending { it.name }) + .sortedWith( + compareByDescending { it.lastModifiedMs } + .thenByDescending { leadingMillis(it.name) ?: Long.MIN_VALUE }, + ) val kept = HashSet() var keptBytes = 0L + keepName?.let { name -> + newestFirst.firstOrNull { it.name == name }?.let { + kept.add(it.name) + keptBytes += it.lengthBytes + } + } for (entry in newestFirst) { + if (kept.contains(entry.name)) continue if (kept.size >= maxCount) break - if (kept.isNotEmpty() && keptBytes + entry.lengthBytes > maxTotalBytes) break + if (entry.lengthBytes > maxRecordBytes) continue + if (keptBytes + entry.lengthBytes > maxTotalBytes) continue kept.add(entry.name) keptBytes += entry.lengthBytes } 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 b39eacb017..36b6ca7ef3 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 @@ -26,8 +26,11 @@ import kotlin.coroutines.cancellation.CancellationException * * Owned records are bounded on both axes, replacing the caps disk-buffering used to apply: * [CRASH_MAX_READ_AGE_MILLIS] ages records out, and [CRASH_MAX_RECORD_COUNT] / - * [CRASH_MAX_TOTAL_BYTES] cap accumulation. Over-limit records are deleted, not just hidden - * from [listReadable], so a record that never uploads cannot grow the cache forever. + * [CRASH_MAX_TOTAL_BYTES] cap accumulation. Both bounds are enforced on every path that + * touches the directory — [save], [listReadable] and [deleteUnrecognizedEntries] — so a + * backlog inherited from a build without caps is reclaimed on the next uploader pass rather + * than waiting for a crash. Over-limit records are deleted, not merely hidden from + * [listReadable], so a record that never uploads cannot grow the cache forever. */ internal class FileLogStore( private val rootPath: String, @@ -36,6 +39,9 @@ internal class FileLogStore( 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") @@ -55,7 +61,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) + enforceAccumulationCaps(dir, keepName = target.name) true } catch (t: Throwable) { // Crash-path safety: never throw from persistence; signal failure to caller. @@ -65,28 +71,68 @@ internal class FileLogStore( } /** - * Evicts oldest-first until the owned records fit the accumulation caps. + * 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 — it is a single directory listing plus at most a - * few deletes, and deferring it would mean the write that breached the cap is the one - * that never gets trimmed. Uses raw Logcat for the same reason [save] does. + * This runs inline on the crashing thread, so it stays cheap in the common case: when the + * directory is already within the count cap it costs one listing and stops. Bulk reclaim of + * a directory that arrived over-cap is left to [reclaimOverLimitRecords] on the uploader's + * IO paths, so an unbounded backlog is never walked while the process is dying. Uses raw + * Logcat for the same reason [save] does. */ @Suppress("TooGenericExceptionCaught", "SwallowedException") - private fun enforceAccumulationCaps(dir: File) { + private fun enforceAccumulationCaps(dir: File, keepName: String) { try { - val overflow = selectOverflowOwnedEntries(listEntries(dir)) + val entries = listEntries(dir) + val owned = entries.filter { isOwnedCrashFile(it.name) } + // Cheap exit for the common case. Both bounds must be checked: a directory can sit + // well under the count cap while a few large payloads breach the byte budget. + if (owned.size <= CRASH_MAX_RECORD_COUNT && owned.sumOf { it.lengthBytes } <= CRASH_MAX_TOTAL_BYTES) { + return + } + val overflow = selectOverflowOwnedEntries(entries, keepName = keepName) if (overflow.isEmpty()) return var evicted = 0 for (entry in overflow) { if (File(dir, entry.name).delete()) evicted++ } - Log.i(TAG, "FileLogStore: evicted $evicted over-cap record(s) in ${dir.path}") + 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 — an + * install upgrading from a build without caps, or one whose crash-path trim failed, is + * reclaimed here rather than waiting for the next crash. + * + * @return names of the evicted records, so callers can exclude them from the same pass + */ + private fun reclaimOverLimitRecords(entries: List): Set { + val overflow = selectOverflowOwnedEntries(entries) + 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( @@ -115,7 +161,10 @@ internal class FileLogStore( Logging.warn("FileLogStore: failed to reclaim expired record ${entry.name}") } } - Logging.info("FileLogStore: reclaimed $deleted expired record(s) in ${rootDir.path}") + 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 } } @@ -125,16 +174,21 @@ internal class FileLogStore( try { val now = System.currentTimeMillis() 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) }) + val dropped = expired + evicted val suffixMatches = - entries.filter { isOwnedCrashFile(it.name) && !expired.contains(it.name) } + entries.filter { isOwnedCrashFile(it.name) && !dropped.contains(it.name) } val readable = suffixMatches .filter { now - it.lastModifiedMs >= minAgeMillis } .mapNotNull { entry -> readRecord(File(rootDir, entry.name)) } Logging.debug( "FileLogStore: listReadable minAgeMs=$minAgeMillis total=${entries.size} " + - "suffix=${suffixMatches.size} readable=${readable.size} expired=${expired.size} " + + "suffix=${suffixMatches.size} readable=${readable.size} " + + "expired=${expired.size} overCap=${evicted.size} " + "legacy=${entries.count { !isOwnedCrashFile(it.name) }}", ) readable @@ -174,14 +228,15 @@ internal class FileLogStore( * files (bare-millis names) 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 - * [CRASH_MAX_READ_AGE_MILLIS], which are no longer uploadable. + * [CRASH_MAX_READ_AGE_MILLIS] or beyond the accumulation caps, which are no longer + * uploadable or no longer affordable to keep. * * Implements the shared [ILogFileStore] contract: the KMP `LogCrashUploader` * invokes this after its owned-record upload pass, and — unlike [listReadable] — - * also when remote logging is disabled, which is the only chance to age out records + * also when remote logging is disabled. That 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, excluding expired owned records + * @return number of unrecognized entries deleted, excluding reclaimed owned records */ @Suppress("TooGenericExceptionCaught", "SwallowedException") override suspend fun deleteUnrecognizedEntries(minAgeMillis: Long): Int = @@ -189,7 +244,8 @@ internal class FileLogStore( try { val now = System.currentTimeMillis() val listed = listEntries(rootDir) - reclaimExpiredOwnedRecords(listed, now) + val expired = reclaimExpiredOwnedRecords(listed, now) + reclaimOverLimitRecords(listed.filterNot { expired.contains(it.name) }) val foreign = selectUnrecognizedEntries(listed, now, minAgeMillis) if (foreign.isEmpty()) { Logging.debug("FileLogStore: no unrecognized files to purge in ${rootDir.path}") 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 38d7d6c52c..0e45b8d369 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 @@ -172,22 +172,29 @@ internal class LoggerLifecycleManager( @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 + Logging.setLoggerTelemetry(null) { false } + telemetry?.shutdown() } catch (t: Throwable) { Logging.warn("OneSignal: Error disabling logger logging: ${t.message}", t) } 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 index 21f90abe04..1f0c495986 100644 --- 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 @@ -49,6 +49,128 @@ class CrashDirCleanupTest : FunSpec({ selected shouldBe emptyList() } + // ===== selectExpiredOwnedEntries ===== + + fun owned(name: String, ageMs: Long, bytes: Long = 1L) = + CrashDirEntry(name, lastModifiedMs = now - ageMs, lengthBytes = bytes) + + test("selectExpiredOwnedEntries takes only owned records strictly past the ceiling") { + val entries = + listOf( + owned("1-a.otlp", ageMs = CRASH_MAX_READ_AGE_MILLIS + 1), + owned("2-b.otlp", ageMs = CRASH_MAX_READ_AGE_MILLIS - 1), + CrashDirEntry("legacy", lastModifiedMs = now - CRASH_MAX_READ_AGE_MILLIS * 2), + ) + + selectExpiredOwnedEntries(entries, nowMs = now).map { it.name } shouldBe listOf("1-a.otlp") + } + + test("selectExpiredOwnedEntries treats a record at exactly the ceiling as still readable") { + val entries = listOf(owned("1-a.otlp", ageMs = CRASH_MAX_READ_AGE_MILLIS)) + + selectExpiredOwnedEntries(entries, nowMs = now) shouldBe emptyList() + } + + test("selectExpiredOwnedEntries ignores records whose mtime is in the future") { + // A backwards clock step must not look like extreme age in either direction. + val entries = listOf(owned("1-a.otlp", ageMs = -CRASH_MAX_READ_AGE_MILLIS * 2)) + + selectExpiredOwnedEntries(entries, nowMs = now) shouldBe emptyList() + } + + test("selectExpiredOwnedEntries is empty for an empty directory") { + selectExpiredOwnedEntries(emptyList(), nowMs = now) shouldBe emptyList() + } + + // ===== selectOverflowOwnedEntries ===== + + test("selectOverflowOwnedEntries returns nothing while within both caps") { + val entries = (1..3).map { owned("$it-a.otlp", ageMs = it * 1_000L) } + + selectOverflowOwnedEntries(entries) shouldBe emptyList() + } + + test("selectOverflowOwnedEntries evicts oldest-first past the count cap") { + val entries = (1..CRASH_MAX_RECORD_COUNT + 2).map { owned("$it-a.otlp", ageMs = it * 1_000L) } + + val evicted = selectOverflowOwnedEntries(entries) + + // Oldest has the largest age, so the two highest indices go, returned oldest-first. + evicted.map { it.name } shouldBe + listOf("${CRASH_MAX_RECORD_COUNT + 2}-a.otlp", "${CRASH_MAX_RECORD_COUNT + 1}-a.otlp") + } + + test("selectOverflowOwnedEntries never touches foreign entries") { + val entries = + (1..CRASH_MAX_RECORD_COUNT + 1).map { owned("$it-a.otlp", ageMs = it * 1_000L) } + + CrashDirEntry("legacy", lastModifiedMs = now - 999_000L) + + selectOverflowOwnedEntries(entries).none { it.name == "legacy" } shouldBe true + } + + test("an oversized record is evicted alone and does not displace the rest") { + // The regression this guards: treating the first over-budget record as a cutoff + // evicted every older record too, so one bad payload lost the whole backlog. + val entries = + listOf( + owned("5-newest.otlp", ageMs = 1_000, bytes = 10), + owned("4-huge.otlp", ageMs = 2_000, bytes = CRASH_MAX_RECORD_BYTES + 1), + owned("3-small.otlp", ageMs = 3_000, bytes = 10), + owned("2-small.otlp", ageMs = 4_000, bytes = 10), + ) + + selectOverflowOwnedEntries(entries).map { it.name } shouldBe listOf("4-huge.otlp") + } + + test("a record that does not fit the remaining budget is skipped, not treated as a cutoff") { + // Four records just under the per-record cap fill most of the budget. The next one + // cannot fit, but a smaller, *older* one still can — proving the loop skips rather + // than stopping at the first record that overflows. + val nearCap = CRASH_MAX_RECORD_BYTES - 12_288 + val entries = + (1..4).map { owned("${10 - it}-fills.otlp", ageMs = it * 1_000L, bytes = nearCap) } + + owned("5-does-not-fit.otlp", ageMs = 5_000, bytes = 200_000) + + owned("4-still-fits.otlp", ageMs = 6_000, bytes = 40_000) + + selectOverflowOwnedEntries(entries).map { it.name } shouldBe listOf("5-does-not-fit.otlp") + } + + test("keepName retains the just-written record even when it sorts oldest") { + // A backwards clock step can make a fresh write look older than its siblings. + val entries = + (1..CRASH_MAX_RECORD_COUNT).map { owned("$it-a.otlp", ageMs = it * 1_000L) } + + owned("fresh-a.otlp", ageMs = 999_000) + + val evicted = selectOverflowOwnedEntries(entries, keepName = "fresh-a.otlp") + + evicted.none { it.name == "fresh-a.otlp" } shouldBe true + evicted.map { it.name } shouldBe listOf("${CRASH_MAX_RECORD_COUNT}-a.otlp") + } + + test("keepName retains an oversized just-written record") { + val entries = listOf(owned("fresh-a.otlp", ageMs = 1_000, bytes = CRASH_MAX_RECORD_BYTES + 1)) + + selectOverflowOwnedEntries(entries, keepName = "fresh-a.otlp") shouldBe emptyList() + } + + test("equal timestamps break the tie on the millis embedded in the name") { + // Coarse filesystem timestamps collapse mtimes; the name preserves write order. + val entries = + listOf( + CrashDirEntry("100-a.otlp", lastModifiedMs = now, lengthBytes = 10), + CrashDirEntry("300-c.otlp", lastModifiedMs = now, lengthBytes = 10), + CrashDirEntry("200-b.otlp", lastModifiedMs = now, lengthBytes = 10), + ) + + val evicted = selectOverflowOwnedEntries(entries, maxCount = 2) + + evicted.map { it.name } shouldBe listOf("100-a.otlp") + } + + test("selectOverflowOwnedEntries is empty for an empty directory") { + selectOverflowOwnedEntries(emptyList()) shouldBe emptyList() + } + test("formatCrashDirInventory reports empty directories") { formatCrashDirInventory( label = "before-upload", 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 4da008b218..a259a0a8cc 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 @@ -137,14 +137,47 @@ class FileLogStoreTest : FunSpec({ } test("save evicts oldest-first once the total byte cap is exceeded") { - val large = (CRASH_MAX_TOTAL_BYTES * 3 / 4).toInt() - write("big-oldest.otlp", ageMsAgo = 20_000, sizeBytes = large) - write("big-newer.otlp", ageMsAgo = 10_000, sizeBytes = large) + // 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 = (CRASH_MAX_RECORD_BYTES - 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-oldest.otlp").exists() shouldBe false - File(dir, "big-newer.otlp").exists() shouldBe true - dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe 2 + File(dir, "big-4.otlp").exists() shouldBe false + File(dir, "big-0.otlp").exists() shouldBe true + } + + test("save never evicts the record it just wrote") { + repeat(CRASH_MAX_RECORD_COUNT + 5) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } + + FileLogStore(dir.path).save("new".toByteArray()) shouldBe true + + val remaining = dir.listFiles()!!.filter { it.name.endsWith(CRASH_OWNED_SUFFIX) } + remaining.none { it.name.startsWith("seed-") && it.readText() == "new" } shouldBe true + remaining.count { it.readText() == "new" } shouldBe 1 + } + + // 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(CRASH_MAX_RECORD_COUNT + 10) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } + + val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) } + + readable.size shouldBe CRASH_MAX_RECORD_COUNT + dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe CRASH_MAX_RECORD_COUNT + } + + test("deleteUnrecognizedEntries evicts an inherited over-cap backlog") { + repeat(CRASH_MAX_RECORD_COUNT + 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(CRASH_OWNED_SUFFIX) } shouldBe CRASH_MAX_RECORD_COUNT } }) 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 index 6cd3dd9dbd..e03cd9152c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt @@ -9,6 +9,7 @@ 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 @@ -50,6 +51,9 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ 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 = @@ -223,7 +227,7 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ // ===== Idempotency and full lifecycle ===== - test("enable called twice does not create duplicate crash handler or ANR detector") { + test("a repeated identical config is a no-op and does not rebuild collaborators") { var handlerCount = 0 var detectorCount = 0 val manager = managerWith( @@ -232,14 +236,61 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ ) manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE) - // A second Enable can only arrive via disable/re-enable; a repeated identical config - // evaluates to NoChange, so drive the guard directly with a level change instead. manager.onModelReplaced(enabledConfig(), ModelChangeTags.HYDRATE) + // The second HYDRATE evaluates to NoChange, so enableFeatures is never re-entered. handlerCount shouldBe 1 detectorCount 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. If it cleared after, + // a throwing stop()/unregister() would leave the field set and the start guards would + // treat the dead component as running, disabling it for the rest of the process. + + 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() } + } + test("enable creates all three features and disable tears all down") { val handler = mockk(relaxed = true) val detector = mockk(relaxed = true) 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 index 369d853233..a823890a6b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt @@ -18,12 +18,15 @@ 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 /** @@ -156,7 +159,13 @@ class LoggerLifecycleManagerTest : FunSpec({ } test("enabling wires the remote sink and disabling shuts it down and clears it") { + // Emission hops to a background scope, so the positive case waits on a signal from the + // sink rather than a fixed sleep. The negative case still needs a bounded wait — there + // is no event for "nothing happened" — but only after a real emit has been observed, + // which establishes 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, @@ -170,24 +179,25 @@ class LoggerLifecycleManagerTest : FunSpec({ ) manager.onModelReplaced(configWith(isEnabled = true, logLevel = LogLevel.ERROR), ModelChangeTags.HYDRATE) - // Sink is live: an ERROR at the configured level reaches the telemetry. Logging.error("routed while enabled") - runBlocking { delay(SINK_SETTLE_MS) } + runBlocking { withTimeout(SINK_TIMEOUT_MS) { emitted.await() } } coVerify { telemetry.emit(any()) } manager.onModelReplaced(configWith(isEnabled = false, logLevel = null), ModelChangeTags.HYDRATE) verify { telemetry.shutdown() } - // Sink is detached: nothing further reaches it. clearMocks(telemetry, answers = false) Logging.error("dropped after disable") - runBlocking { delay(SINK_SETTLE_MS) } + runBlocking { delay(SINK_QUIET_MS) } coVerify(exactly = 0) { telemetry.emit(any()) } } }) -/** Remote emission hops to a background scope; long enough to settle without being flaky. */ -private const val SINK_SETTLE_MS = 300L +/** 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() From 9524cfbba4f2cdd46e65f8eadc03c0cfcde71907 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 15:21:22 -0500 Subject: [PATCH 05/12] fix: [SDK-5065] stop size-capped crash records from destroying the backlog Round-3 review found two ways the retention policy could delete crash reports it was meant to protect. keepName pinned the just-written record but charged its full length to the shared budget. An oversized payload therefore started the budget over cap, every sibling failed the remaining-budget check, and the whole backlog was evicted -- then the uploader, which runs without keepName, dropped the oversized record too. One bad payload destroyed everything including itself. The test covering that path used a single-entry directory, so it could observe the retention but never the consequence. Separately, the cheap exit in enforceAccumulationCaps checked count and total bytes but not the per-record cap, so a lone 600 KiB report survived save() and was then deleted by the uploader before any upload was attempted. Fixed at the source instead of patching the selector: save() now refuses a payload over the per-record limit and says so, which makes "every stored record is within the shared budget" an invariant. Size is no longer grounds for eviction -- deleting a captured crash unread is worse than keeping it -- and each record now claims at most the per-record cap against the budget, so an oversized record inherited from a build without the write-time limit still gets an upload attempt without displacing anything. Also: startLogging never received the clear-before-teardown fix disableFeatures got, so a throwing shutdown() stranded a dead sink that NoChange would never replace; expired-but-undeletable records were filtered out of the byte accounting and could hold the directory over cap indefinitely; and three lifecycle tests spawned real ANR watchdog daemon threads that outlived the spec and wrote into the cache dir other specs assert on. Co-authored-by: Cursor --- .../logging/logger/android/CrashDirCleanup.kt | 31 +++++---- .../logging/logger/android/FileLogStore.kt | 66 ++++++++++++------- .../internal/LoggerLifecycleManager.kt | 12 +++- .../internal/logging/LoggingRemoteTest.kt | 42 +++++++++--- .../logger/android/CrashDirCleanupTest.kt | 25 +++++-- .../logger/android/FileLogStoreTest.kt | 19 ++++++ .../LoggerLifecycleManagerFaultTest.kt | 18 +++++ .../internal/LoggerLifecycleManagerTest.kt | 14 +++- 8 files changed, 177 insertions(+), 50 deletions(-) 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 index 9085d207c3..d8fb4edee1 100644 --- 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 @@ -30,8 +30,10 @@ internal const val CRASH_MAX_RECORD_COUNT = 50 internal const val CRASH_MAX_TOTAL_BYTES = 2L * 1024 * 1024 /** - * A single record above this is dropped on its own rather than being allowed to consume the - * whole byte budget. Without it, one huge payload would push every other pending record out. + * Largest payload [com.onesignal.debug.internal.logging.logger.android.FileLogStore] will + * write. Rejecting at the source keeps every stored record within the shared budget, so no + * single payload can push the rest out. It also caps how much budget an oversized record + * inherited from a build without this limit is allowed to claim. */ internal const val CRASH_MAX_RECORD_BYTES = 512L * 1024 @@ -85,13 +87,17 @@ private fun leadingMillis(name: String): Long? = name.substringBefore('-').toLon * Returns the owned entries to evict so the directory fits within [maxCount] and * [maxTotalBytes], newest kept and the excess returned oldest-first. * - * A record larger than [maxRecordBytes] is evicted on its own rather than being allowed to - * exhaust the shared budget — otherwise a single deep-stacktrace payload would push out every - * other pending crash. For the same reason a record that merely does not fit the *remaining* - * budget is skipped, not treated as a cutoff: everything older still gets its chance to fit. + * Size is never on its own a reason to evict. A record too large to upload should be refused + * at write time; deleting one that is already on disk would destroy a captured crash without + * ever attempting to send it. What size does control is *budget claim*: each record is charged + * at most [maxRecordBytes], so one outsized payload — necessarily inherited from a build + * without the write-time limit — cannot displace the rest of the backlog. * - * [keepName] is the record the caller just wrote. It is retained regardless of size or sort - * position, so a backwards clock step cannot make a fresh record look oldest and delete it. + * A record that does not fit the remaining budget is skipped rather than treated as a cutoff, + * so everything older still gets its chance to fit. + * + * [keepName] is the record the caller just wrote. It is retained regardless of sort position, + * so a backwards clock step cannot make a fresh record look oldest and delete it. */ internal fun selectOverflowOwnedEntries( entries: List, @@ -111,21 +117,22 @@ internal fun selectOverflowOwnedEntries( .thenByDescending { leadingMillis(it.name) ?: Long.MIN_VALUE }, ) + fun budgetClaim(entry: CrashDirEntry): Long = minOf(entry.lengthBytes, maxRecordBytes) + val kept = HashSet() var keptBytes = 0L keepName?.let { name -> newestFirst.firstOrNull { it.name == name }?.let { kept.add(it.name) - keptBytes += it.lengthBytes + keptBytes += budgetClaim(it) } } for (entry in newestFirst) { if (kept.contains(entry.name)) continue if (kept.size >= maxCount) break - if (entry.lengthBytes > maxRecordBytes) continue - if (keptBytes + entry.lengthBytes > maxTotalBytes) continue + if (keptBytes + budgetClaim(entry) > maxTotalBytes) continue kept.add(entry.name) - keptBytes += entry.lengthBytes + keptBytes += budgetClaim(entry) } return newestFirst.filterNot { kept.contains(it.name) }.reversed() } 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 36b6ca7ef3..69a992fdd2 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 @@ -47,6 +47,17 @@ internal class FileLogStore( @Suppress("TooGenericExceptionCaught", "SwallowedException") override fun save(bytes: ByteArray): Boolean { return try { + if (bytes.size > CRASH_MAX_RECORD_BYTES) { + // 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. + // Losing it loudly here beats losing it silently on a later launch. + Log.w( + TAG, + "FileLogStore: refusing record of ${bytes.size} bytes, " + + "over the $CRASH_MAX_RECORD_BYTES-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. @@ -74,20 +85,21 @@ 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). * - * This runs inline on the crashing thread, so it stays cheap in the common case: when the - * directory is already within the count cap it costs one listing and stops. Bulk reclaim of - * a directory that arrived over-cap is left to [reclaimOverLimitRecords] on the uploader's - * IO paths, so an unbounded backlog is never walked while the process is dying. Uses raw - * Logcat for the same reason [save] does. + * Runs inline on the crashing thread. In the steady state this is one directory listing and + * nothing else, because writes are size-capped and the previous launch left the directory + * within bounds. An inherited over-cap backlog does get fully sorted and trimmed here — that + * is a one-time cost on the first crash after upgrade, and [reclaimOverLimitRecords] on the + * uploader's IO paths usually gets there first. Uses raw Logcat for the same reason [save] does. */ @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun enforceAccumulationCaps(dir: File, keepName: String) { try { val entries = listEntries(dir) val owned = entries.filter { isOwnedCrashFile(it.name) } - // Cheap exit for the common case. Both bounds must be checked: a directory can sit - // well under the count cap while a few large payloads breach the byte budget. - if (owned.size <= CRASH_MAX_RECORD_COUNT && owned.sumOf { it.lengthBytes } <= CRASH_MAX_TOTAL_BYTES) { + // Cheap exit for the common case. Charges are capped per record to match the + // selector's accounting, so this agrees with it rather than second-guessing it. + val claimed = owned.sumOf { minOf(it.lengthBytes, CRASH_MAX_RECORD_BYTES) } + if (owned.size <= CRASH_MAX_RECORD_COUNT && claimed <= CRASH_MAX_TOTAL_BYTES) { return } val overflow = selectOverflowOwnedEntries(entries, keepName = keepName) @@ -142,30 +154,38 @@ internal class FileLogStore( ) }.orEmpty() + /** + * Outcome of an expiry pass. The two sets differ when a delete fails: the record is still + * unreadable, but it also still occupies the directory, so it must stay visible to the + * accumulation caps instead of being quietly exempted from them. + */ + private data class ExpiryOutcome(val expired: Set, val removed: Set) { + companion object { + val NONE = ExpiryOutcome(emptySet(), emptySet()) + } + } + /** * Deletes owned records past [CRASH_MAX_READ_AGE_MILLIS]. 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 names of the expired records, whether or not the delete succeeded — a record - * past the ceiling must not be read even if it could not be removed this pass */ - private fun reclaimExpiredOwnedRecords(entries: List, nowMs: Long): Set { + private fun reclaimExpiredOwnedRecords(entries: List, nowMs: Long): ExpiryOutcome { val expired = selectExpiredOwnedEntries(entries, nowMs) - if (expired.isEmpty()) return emptySet() - var deleted = 0 + if (expired.isEmpty()) return ExpiryOutcome.NONE + val removed = HashSet() for (entry in expired) { if (File(rootDir, entry.name).delete()) { - deleted++ + removed.add(entry.name) } else { Logging.warn("FileLogStore: failed to reclaim expired record ${entry.name}") } } Logging.info( - "FileLogStore: reclaimed $deleted/${expired.size} expired record(s) in ${rootDir.path}: " + + "FileLogStore: reclaimed ${removed.size}/${expired.size} expired record(s) in ${rootDir.path}: " + expired.take(MAX_NAMES_LOGGED).joinToString(", ") { it.name }, ) - return expired.mapTo(HashSet()) { it.name } + return ExpiryOutcome(expired = expired.mapTo(HashSet()) { it.name }, removed = removed) } @Suppress("TooGenericExceptionCaught", "SwallowedException") @@ -176,9 +196,9 @@ internal class FileLogStore( 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) }) - val dropped = expired + evicted + val expiry = reclaimExpiredOwnedRecords(entries, now) + val evicted = reclaimOverLimitRecords(entries.filterNot { expiry.removed.contains(it.name) }) + val dropped = expiry.expired + evicted val suffixMatches = entries.filter { isOwnedCrashFile(it.name) && !dropped.contains(it.name) } val readable = @@ -188,7 +208,7 @@ internal class FileLogStore( Logging.debug( "FileLogStore: listReadable minAgeMs=$minAgeMillis total=${entries.size} " + "suffix=${suffixMatches.size} readable=${readable.size} " + - "expired=${expired.size} overCap=${evicted.size} " + + "expired=${expiry.expired.size} overCap=${evicted.size} " + "legacy=${entries.count { !isOwnedCrashFile(it.name) }}", ) readable @@ -244,8 +264,8 @@ internal class FileLogStore( try { val now = System.currentTimeMillis() val listed = listEntries(rootDir) - val expired = reclaimExpiredOwnedRecords(listed, now) - reclaimOverLimitRecords(listed.filterNot { expired.contains(it.name) }) + val expiry = reclaimExpiredOwnedRecords(listed, now) + reclaimOverLimitRecords(listed.filterNot { expiry.removed.contains(it.name) }) val foreign = selectUnrecognizedEntries(listed, now, minAgeMillis) if (foreign.isEmpty()) { Logging.debug("FileLogStore: no unrecognized files to purge in ${rootDir.path}") 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 0e45b8d369..7a803fe4d7 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 @@ -226,8 +226,18 @@ internal class LoggerLifecycleManager( Logging.info("OneSignal: logger ANR detector started") } + @Suppress("TooGenericExceptionCaught") private fun startLogging(logLevel: LogLevel) { - remoteTelemetry?.shutdown() + // Same invariant as disableFeatures: drop the reference before tearing the old sink + // down. A throwing shutdown() must not leave the field pointing at a dead instance, + // because an identical later config evaluates to NoChange and would never replace it. + val previous = remoteTelemetry + remoteTelemetry = null + 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 -> 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 index 10a143e2a4..df91fc6e8d 100644 --- 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 @@ -7,11 +7,14 @@ 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 /** @@ -36,10 +39,11 @@ class LoggingRemoteTest : FunSpec({ 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") - runBlocking { delay(200) } + awaitEmit(emitted) coVerify { telemetry.emit(capture(record)) } record.captured.body shouldBe "[${Thread.currentThread().name}] boom" @@ -49,10 +53,11 @@ class LoggingRemoteTest : FunSpec({ 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")) - runBlocking { delay(200) } + awaitEmit(emitted) coVerify { telemetry.emit(capture(record)) } record.captured.attributes["exception.type"] shouldBe "java.lang.IllegalStateException" @@ -64,7 +69,7 @@ class LoggingRemoteTest : FunSpec({ Logging.setLoggerTelemetry(telemetry) { level -> level <= LogLevel.ERROR } Logging.info("filtered out") - runBlocking { delay(200) } + runBlocking { delay(QUIET_WINDOW_MS) } coVerify(exactly = 0) { telemetry.emit(any()) } } @@ -74,7 +79,7 @@ class LoggingRemoteTest : FunSpec({ Logging.setLoggerTelemetry(telemetry) { true } Logging.log(LogLevel.NONE, "should be dropped") - runBlocking { delay(200) } + runBlocking { delay(QUIET_WINDOW_MS) } coVerify(exactly = 0) { telemetry.emit(any()) } } @@ -85,24 +90,28 @@ class LoggingRemoteTest : FunSpec({ Logging.setLoggerTelemetry(null) { false } Logging.error("after clear") - runBlocking { delay(200) } + runBlocking { delay(QUIET_WINDOW_MS) } coVerify(exactly = 0) { telemetry.emit(any()) } } test("a throwing sink does not propagate to the caller") { val telemetry = mockk() - io.mockk.coEvery { telemetry.emit(any()) } throws RuntimeException("sink down") + 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") - runBlocking { delay(200) } + awaitEmit(emitted) coVerify { telemetry.emit(any()) } } test("every severity is forwarded") { val telemetry = mockk(relaxed = true) + val sixth = CompletableDeferred() + var seen = 0 + coEvery { telemetry.emit(any()) } answers { if (++seen == 6) sixth.complete(Unit); Unit } Logging.setLoggerTelemetry(telemetry) { true } Logging.verbose("v") @@ -111,8 +120,25 @@ class LoggingRemoteTest : FunSpec({ Logging.warn("w") Logging.error("e") Logging.fatal("f") - runBlocking { delay(300) } + 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/logger/android/CrashDirCleanupTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanupTest.kt index 1f0c495986..a4e9c88da4 100644 --- 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 @@ -108,18 +108,18 @@ class CrashDirCleanupTest : FunSpec({ selectOverflowOwnedEntries(entries).none { it.name == "legacy" } shouldBe true } - test("an oversized record is evicted alone and does not displace the rest") { - // The regression this guards: treating the first over-budget record as a cutoff - // evicted every older record too, so one bad payload lost the whole backlog. + test("an oversized record is retained but cannot displace the rest") { + // Size alone is never grounds for eviction — deleting a captured crash without ever + // attempting to upload it is worse than keeping it. What size limits is budget claim. val entries = listOf( owned("5-newest.otlp", ageMs = 1_000, bytes = 10), - owned("4-huge.otlp", ageMs = 2_000, bytes = CRASH_MAX_RECORD_BYTES + 1), + owned("4-huge.otlp", ageMs = 2_000, bytes = CRASH_MAX_TOTAL_BYTES * 2), owned("3-small.otlp", ageMs = 3_000, bytes = 10), owned("2-small.otlp", ageMs = 4_000, bytes = 10), ) - selectOverflowOwnedEntries(entries).map { it.name } shouldBe listOf("4-huge.otlp") + selectOverflowOwnedEntries(entries) shouldBe emptyList() } test("a record that does not fit the remaining budget is skipped, not treated as a cutoff") { @@ -153,6 +153,21 @@ class CrashDirCleanupTest : FunSpec({ selectOverflowOwnedEntries(entries, keepName = "fresh-a.otlp") shouldBe emptyList() } + test("an oversized keepName does not evict the pending backlog") { + // The regression this guards: charging keepName its full length started the budget + // over cap, so every sibling failed the remaining-budget check and the entire backlog + // was deleted — then the uploader dropped the oversized record too. A single-entry + // directory cannot observe this, which is why the case above did not catch it. + val backlog = (1..4).map { owned("$it-small.otlp", ageMs = it * 10_000L, bytes = 400_000) } + val entries = backlog + owned("fresh-a.otlp", ageMs = 1_000, bytes = CRASH_MAX_TOTAL_BYTES * 2) + + val evicted = selectOverflowOwnedEntries(entries, keepName = "fresh-a.otlp") + + // The oversized record claims only its capped share, leaving room for the backlog. + evicted.none { it.name == "fresh-a.otlp" } shouldBe true + evicted.map { it.name } shouldBe listOf("4-small.otlp") + } + test("equal timestamps break the tie on the millis embedded in the name") { // Coarse filesystem timestamps collapse mtimes; the name preserves write order. val entries = 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 a259a0a8cc..1bc333b489 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 @@ -148,6 +148,25 @@ class FileLogStoreTest : FunSpec({ File(dir, "big-0.otlp").exists() shouldBe true } + test("save refuses a payload over the per-record limit and writes nothing") { + val oversized = ByteArray((CRASH_MAX_RECORD_BYTES + 1).toInt()) + + FileLogStore(dir.path).save(oversized) shouldBe false + + dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } 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 = (CRASH_MAX_RECORD_BYTES + 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 + } + test("save never evicts the record it just wrote") { repeat(CRASH_MAX_RECORD_COUNT + 5) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } 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 index e03cd9152c..7ba4a88496 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt @@ -212,6 +212,24 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ manager.initializeFromCachedConfig() } + test("a throwing shutdown() during a level change still installs the replacement sink") { + // startLogging drops the reference before tearing the old sink down. If it cleared + // after, a throwing shutdown() would strand the dead instance in the field and every + // later identical config would evaluate to NoChange, leaving remote logging dead. + 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( 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 index a823890a6b..6bad6d8277 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt @@ -40,8 +40,20 @@ class LoggerLifecycleManagerTest : FunSpec({ 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 }) + LoggerLifecycleManager( + context = context, + featureManagerProvider = { featureManager }, + fileStoreFactory = { mockk(relaxed = true) }, + anrDetectorFactory = { _, _, _ -> mockk(relaxed = true) }, + remoteTelemetryFactory = { _, _ -> mockk(relaxed = true) }, + ) beforeEach { context = ApplicationProvider.getApplicationContext() From d7b42d65a6bb2491c6ddc6a0730bc91fc0ff5332 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 25 Aug 2026 12:47:46 -0500 Subject: [PATCH 06/12] fix: [SDK-5065] retry observability components that failed to start applyAction committed currentConfig even when a component never came up. Since a stable remote payload produces an identical config on the next refresh, the evaluator returned NoChange and the dead crash handler, ANR detector or sink stayed down for the rest of the process. enableFeatures now reports whether everything started, the config is only committed once it did, and startLogging is null-guarded like its siblings so a retry cannot tear down a healthy sink. startLogging also only had half the teardown invariant: it cleared its own field but left Logging's global pointing at the old sink while shutting it down. Every log emitted between shutdown and the replacement being installed -- including the warn in that window -- went to a telemetry whose consumer was already cancelled, where it queued and was never drained. On a throwing factory the global stayed on the dead instance for the session. Reverts the ExpiryOutcome split from the previous commit. It was added on the theory that an expired record whose delete failed could hold the directory over cap while invisible to the byte accounting. Writing the test disproved it: expired records are by definition the oldest, so the selector always picks them for eviction rather than retention, and only retained records claim budget. Including them in the candidate set changes no outcome, so the two-set bookkeeping was inert complexity. Kept a test that the record stays unreadable when its delete fails, which is the part that does matter. Also drops a tautological assertion that passed regardless of keepName now that size is not grounds for eviction, replaces a counter mutated from six concurrent coroutines with an AtomicInteger, stops building a throwaway platform provider just to read a path the pure helper computes, and corrects two KDocs that still claimed the byte cap bounds disk rather than claim. Co-authored-by: Cursor --- .../crash/OneSignalCrashUploaderWrapper.kt | 12 ++-- .../logging/logger/android/CrashDirCleanup.kt | 7 ++ .../logging/logger/android/FileLogStore.kt | 44 ++++++------- .../internal/LoggerLifecycleManager.kt | 65 ++++++++++++++----- .../internal/logging/LoggingRemoteTest.kt | 6 +- .../logger/android/CrashDirCleanupTest.kt | 6 -- .../logger/android/FileLogStoreTest.kt | 15 +++++ .../LoggerLifecycleManagerFaultTest.kt | 61 +++++++++++++++++ 8 files changed, 165 insertions(+), 51 deletions(-) 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 3bb79ca767..2f3818f761 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 @@ -11,6 +11,7 @@ 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.logger.android.getCrashStoragePath import com.onesignal.logger.LoggerFactory import java.io.File import kotlin.coroutines.cancellation.CancellationException @@ -63,10 +64,13 @@ internal class OneSignalCrashUploaderWrapper( } } - /** Resolves the crash directory the logger module reads and writes. */ - private fun crashStoragePath(): String = - createAndroidLoggerPlatformProvider(applicationService.appContext) { featureManager } - .crashStoragePath + /** + * Resolves the crash directory the logger module reads and writes. Uses the pure path + * helper rather than a provider: building one costs a `PackageManager` round-trip and an + * ID resolver, and re-emits the provider's "Crash logs stored at" line, all to read a + * value derived from the context alone. + */ + private fun crashStoragePath(): String = getCrashStoragePath(applicationService.appContext) /** * Logs a snapshot of the crash dir (counts of owned `.otlp` vs foreign/legacy 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 index d8fb4edee1..686ea852bb 100644 --- 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 @@ -24,6 +24,13 @@ internal const val CRASH_MAX_READ_AGE_MILLIS = 72L * 60 * 60 * 1000 * than a healthy install will ever hold. The byte bound is the backstop for pathological * payloads (deep stacktraces, huge exception messages) where count alone would not keep the * directory small. + * + * [CRASH_MAX_TOTAL_BYTES] bounds *claim*, not bytes on disk. Since writes are size-limited, + * the two coincide for anything this build wrote. They diverge only for records inherited + * from a build without that limit: each claims at most [CRASH_MAX_RECORD_BYTES], so a handful + * of oversized leftovers can occupy more than this while still counting as within cap. That + * is deliberate — they are real crashes and deserve an upload attempt — and it is bounded by + * the count cap and by [CRASH_MAX_READ_AGE_MILLIS] aging them out. */ internal const val CRASH_MAX_RECORD_COUNT = 50 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 69a992fdd2..22553178cd 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 @@ -26,7 +26,9 @@ import kotlin.coroutines.cancellation.CancellationException * * Owned records are bounded on both axes, replacing the caps disk-buffering used to apply: * [CRASH_MAX_READ_AGE_MILLIS] ages records out, and [CRASH_MAX_RECORD_COUNT] / - * [CRASH_MAX_TOTAL_BYTES] cap accumulation. Both bounds are enforced on every path that + * [CRASH_MAX_TOTAL_BYTES] cap accumulation — the latter by budget claim rather than raw disk + * bytes, which differ only for oversized records inherited from a build that predates the + * write-time limit in [save]. Both bounds are enforced on every path that * touches the directory — [save], [listReadable] and [deleteUnrecognizedEntries] — so a * backlog inherited from a build without caps is reclaimed on the next uploader pass rather * than waiting for a crash. Over-limit records are deleted, not merely hidden from @@ -154,38 +156,32 @@ internal class FileLogStore( ) }.orEmpty() - /** - * Outcome of an expiry pass. The two sets differ when a delete fails: the record is still - * unreadable, but it also still occupies the directory, so it must stay visible to the - * accumulation caps instead of being quietly exempted from them. - */ - private data class ExpiryOutcome(val expired: Set, val removed: Set) { - companion object { - val NONE = ExpiryOutcome(emptySet(), emptySet()) - } - } - /** * Deletes owned records past [CRASH_MAX_READ_AGE_MILLIS]. 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, and it cannot distort the accumulation caps either: + * expired records are by definition the oldest, so the selector always picks them for + * eviction rather than retention, and only retained records claim budget. */ - private fun reclaimExpiredOwnedRecords(entries: List, nowMs: Long): ExpiryOutcome { + private fun reclaimExpiredOwnedRecords(entries: List, nowMs: Long): Set { val expired = selectExpiredOwnedEntries(entries, nowMs) - if (expired.isEmpty()) return ExpiryOutcome.NONE - val removed = HashSet() + if (expired.isEmpty()) return emptySet() + var deleted = 0 for (entry in expired) { if (File(rootDir, entry.name).delete()) { - removed.add(entry.name) + deleted++ } else { Logging.warn("FileLogStore: failed to reclaim expired record ${entry.name}") } } Logging.info( - "FileLogStore: reclaimed ${removed.size}/${expired.size} expired record(s) in ${rootDir.path}: " + + "FileLogStore: reclaimed $deleted/${expired.size} expired record(s) in ${rootDir.path}: " + expired.take(MAX_NAMES_LOGGED).joinToString(", ") { it.name }, ) - return ExpiryOutcome(expired = expired.mapTo(HashSet()) { it.name }, removed = removed) + return expired.mapTo(HashSet()) { it.name } } @Suppress("TooGenericExceptionCaught", "SwallowedException") @@ -196,9 +192,9 @@ internal class FileLogStore( 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 expiry = reclaimExpiredOwnedRecords(entries, now) - val evicted = reclaimOverLimitRecords(entries.filterNot { expiry.removed.contains(it.name) }) - val dropped = expiry.expired + evicted + val expired = reclaimExpiredOwnedRecords(entries, now) + val evicted = reclaimOverLimitRecords(entries.filterNot { expired.contains(it.name) }) + val dropped = expired + evicted val suffixMatches = entries.filter { isOwnedCrashFile(it.name) && !dropped.contains(it.name) } val readable = @@ -208,7 +204,7 @@ internal class FileLogStore( Logging.debug( "FileLogStore: listReadable minAgeMs=$minAgeMillis total=${entries.size} " + "suffix=${suffixMatches.size} readable=${readable.size} " + - "expired=${expiry.expired.size} overCap=${evicted.size} " + + "expired=${expired.size} overCap=${evicted.size} " + "legacy=${entries.count { !isOwnedCrashFile(it.name) }}", ) readable @@ -264,8 +260,8 @@ internal class FileLogStore( try { val now = System.currentTimeMillis() val listed = listEntries(rootDir) - val expiry = reclaimExpiredOwnedRecords(listed, now) - reclaimOverLimitRecords(listed.filterNot { expiry.removed.contains(it.name) }) + val expired = reclaimExpiredOwnedRecords(listed, now) + reclaimOverLimitRecords(listed.filterNot { expired.contains(it.name) }) val foreign = selectUnrecognizedEntries(listed, now, minAgeMillis) if (foreign.isEmpty()) { Logging.debug("FileLogStore: no unrecognized files to purge in ${rootDir.path}") 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 7a803fe4d7..738b39f0b8 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 @@ -138,35 +138,64 @@ internal class LoggerLifecycleManager( return ObservabilityConfig(isEnabled = enabled, logLevel = level) } - /** Must be called while holding [lock]. */ + /** + * 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) { - when (action) { - is ObservabilityConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR) - is ObservabilityConfigAction.Disable -> disableFeatures() - is ObservabilityConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel) - is ObservabilityConfigAction.NoChange -> Logging.debug("OneSignal: logger config unchanged") - } - 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 } + /** + * 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. + if (remoteTelemetry == null) 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") @@ -200,13 +229,16 @@ internal class LoggerLifecycleManager( } } + /** @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 } } @@ -228,11 +260,14 @@ internal class LoggerLifecycleManager( @Suppress("TooGenericExceptionCaught") private fun startLogging(logLevel: LogLevel) { - // Same invariant as disableFeatures: drop the reference before tearing the old sink - // down. A throwing shutdown() must not leave the field pointing at a dead instance, - // because an identical later config evaluates to NoChange and would never replace it. + // Same invariant as disableFeatures: detach both the field and Logging's global before + // tearing the old sink down. Shutting down first would leave every log emitted until + // the replacement is installed — including the warn below — going to a telemetry whose + // consumer is already cancelled, where it is queued and never drained. If the factory + // then throws, the global would keep pointing at that dead instance for the session. val previous = remoteTelemetry remoteTelemetry = null + Logging.setLoggerTelemetry(null) { false } try { previous?.shutdown() } catch (t: Throwable) { 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 index df91fc6e8d..4fee8f77a3 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -110,8 +111,9 @@ class LoggingRemoteTest : FunSpec({ test("every severity is forwarded") { val telemetry = mockk(relaxed = true) val sixth = CompletableDeferred() - var seen = 0 - coEvery { telemetry.emit(any()) } answers { if (++seen == 6) sixth.complete(Unit); Unit } + // 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") 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 index a4e9c88da4..d37a663d9f 100644 --- 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 @@ -147,12 +147,6 @@ class CrashDirCleanupTest : FunSpec({ evicted.map { it.name } shouldBe listOf("${CRASH_MAX_RECORD_COUNT}-a.otlp") } - test("keepName retains an oversized just-written record") { - val entries = listOf(owned("fresh-a.otlp", ageMs = 1_000, bytes = CRASH_MAX_RECORD_BYTES + 1)) - - selectOverflowOwnedEntries(entries, keepName = "fresh-a.otlp") shouldBe emptyList() - } - test("an oversized keepName does not evict the pending backlog") { // The regression this guards: charging keepName its full length started the budget // over cap, so every sibling failed the remaining-budget check and the entire backlog 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 1bc333b489..4961495d3b 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 @@ -189,6 +189,21 @@ class FileLogStoreTest : FunSpec({ dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe CRASH_MAX_RECORD_COUNT } + // 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 = CRASH_MAX_READ_AGE_MILLIS + 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(CRASH_MAX_RECORD_COUNT + 10) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } write("1784621689841") 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 index 7ba4a88496..2555431765 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt @@ -261,6 +261,67 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ detectorCount shouldBe 1 } + // A component that failed to start must be retried on the next config refresh. Committing + // currentConfig after a partial failure collapsed the next identical HYDRATE to NoChange, + // which left the dead component down for the rest of the process. + + 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 From ab4ecea191a2aa7757d4764f3fdf5dc0dd9c8169 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 25 Aug 2026 15:25:22 -0500 Subject: [PATCH 07/12] fix: [SDK-5065] restore canonical stacktrace formatting on ANR records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the OpenTelemetry path also removed the synthetic Throwable the ANR detector used to build, so ANR records stopped being serialized via stackTraceToString() and were hand-joined instead — no `type: message` header and no `\tat ` frame prefix. Ordinary crashes still went through stackTraceToString(), so the pipeline emitted two different stacktrace formats depending on record type, and consumers that parse `exception.stacktrace` as a Java stacktrace (frame extraction, grouping/fingerprinting, symbolication, the Grafana `^\s*at ` transform) silently stopped matching ANR records only. Both ANR paths now go through shared `buildAnrCrashData` / `buildBackgroundBlockCrashData` builders backed by one `formatJvmStacktrace` helper that emits the canonical layout. Chose hand-formatting over re-synthesizing a Throwable for two reasons: this runs on the ANR watchdog thread while reporting a possibly-wedged app, so avoiding a throwable allocation and its stack fill keeps it cheap and non-throwing; and a real exception class would put its fully-qualified name in the header, which would no longer match the bare `exceptionType` the record reports. Against drift, a test pins `formatJvmStacktrace` output against a real `Throwable.stackTraceToString()`, so the ANR and crash paths cannot diverge again without a red test. `exceptionType` values are unchanged — this touches the `stacktrace` field only. Co-authored-by: Cursor --- .../debug/internal/crash/AnrCheckEvaluator.kt | 71 +++++++++++++++++++ .../logger/android/AndroidLogAnrDetector.kt | 23 ++---- .../internal/crash/AnrCheckEvaluatorTest.kt | 66 +++++++++++++++++ 3 files changed, 141 insertions(+), 19 deletions(-) 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 5ace6da4a1..1aa66f12af 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,5 +1,6 @@ package com.onesignal.debug.internal.crash +import com.onesignal.logger.CrashData import java.util.concurrent.atomic.AtomicLong /** @@ -181,3 +182,73 @@ internal fun buildBlockFingerprint(stackTrace: Array): String 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, not from a thrown exception, so there is no + * throwable to serialize. Formatting by hand rather than synthesizing one keeps the watchdog cheap + * and non-throwing while it reports a possibly-wedged app, and lets the header carry the same bare + * exception type the record itself reports. `AnrCheckEvaluatorTest` pins this against a real + * [Throwable.stackTraceToString] so ANR and crash records cannot drift into two formats again. + */ +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. The message carries a compact + * stack fingerprint (top frame + first OneSignal frame) so these can be triaged without parsing the + * full stack. + */ +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/logging/logger/android/AndroidLogAnrDetector.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt index e8393bbd96..ee080d81b5 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 @@ -163,14 +163,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}") @@ -191,15 +184,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/test/java/com/onesignal/debug/internal/crash/AnrCheckEvaluatorTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/crash/AnrCheckEvaluatorTest.kt index 2c0b9c7e5e..00a7bbf145 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 @@ -201,4 +201,70 @@ class AnrCheckEvaluatorTest : FunSpec({ test("buildBlockFingerprint handles an empty stack") { buildBlockFingerprint(emptyArray()) shouldBe "top=unknown|onesignal=none" } + + // ===== record formatting ===== + // + // ANR records must be serialized in the same canonical JVM layout ordinary crashes get from + // Throwable.stackTraceToString(), or consumers that parse `exception.stacktrace` as a Java + // 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") { + // Pins the hand-formatting against the crash path's stackTraceToString(), so the two record + // types cannot drift into different formats again. + 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 + } + } }) From 6aba52e05b8e5bf3411051535e2cf59061a3fd14 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Aug 2026 10:38:44 -0500 Subject: [PATCH 08/12] fix: [SDK-5065] reclaim future-dated crash records and require record size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CrashDirCleanup is a near-duplicate of the shared CrashRetention policy in the KMP submodule and is slated for deletion once that lands and the pin is bumped. This PR may merge first, so the two correctness defects are fixed here rather than left to merge ordering. Ports commit a95117a from the KMP repo, deliberately excluding its CrashRetentionPolicy value type: that change exists to shorten Swift call sites, which have no Kotlin default arguments after the Objective-C export. Android has no such boundary, so the parameter-heavy signatures stay and the diff stays reviewable. Reclaim records dated far enough into the future to be unrecoverable. The read path gates on `now - lastModifiedMs >= minAgeMillis`, which a future timestamp never satisfies, and selectExpiredOwnedEntries ignored every negative age, so such a record was unreadable for its entire life while still holding a count slot and budget — and it sorted newest during overflow, so it displaced genuine records that could still have been uploaded. The threshold is a full retention window ahead of now, not merely "in the future". That preserves the deliberate protection against a modest backwards clock step, which is what the negative-age handling was there for: a record dated modestly ahead is still left to wait until the clock agrees it is old. Clamping the timestamp for ordering alone would not have been sufficient — a record clamped to nowMs still ranks as the newest entry and keeps its slot. selectOverflowOwnedEntries now takes nowMs and applies the same judgement when ordering. This is needed on Android for the same reason it is on iOS: FileLogStore.enforceAccumulationCaps runs on the crash write path and enforces caps without running an expiry pass first, so ordering cannot assume the zombie has already been removed. Ordinary future dates clamp to nowMs; unrecoverable ones sort last. The two uploader-side callers already had a `now` in scope. Make CrashDirEntry.lengthBytes required. Budget claim is `min(lengthBytes, maxRecordBytes)`, so the previous `= 0L` default meant a caller that omitted the size claimed nothing and disabled the byte budget for that record. Both production call sites already passed a real length, so this was latent — but several test cases relied on the default, which is exactly the hazard. Tests now pass an explicit size. Replace the test that pinned the bug as correct. It asserted a record dated two full retention windows into the future was correctly ignored, citing backwards- clock protection — but two windows ahead is not a clock step, and the case it described is an hour of skew. It is now split into a plausible one-hour backwards step that must be left alone and a boundary case at exactly one window, matching KMP. Also coerce formatCrashDirInventory's maxSample to at least zero. Both callers pass literals today, but List.take throws on a negative argument and this is a logging helper on a crash-adjacent path. Each new test was confirmed red against the reverted production change and green after: reverting the expiry clause fails only "reclaims a record dated past the window into the future"; reverting the overflow sort key fails only "a future-dated record is evicted before any record that could still upload"; reverting the maxSample coercion fails only "treats a negative sample size as zero". The three tests guarding the lower bound — the one-hour step, the exactly-one-window boundary, and the modestly-future ordering case — were confirmed red against an over-correction that reclaims any future date, since no under-correction can fail them. Behavior now matches the KMP implementation exactly; only the signatures differ, which is what the comparison should find when the duplicate is deleted. Co-authored-by: Cursor --- .../logging/logger/android/CrashDirCleanup.kt | 75 ++++++++++--- .../logging/logger/android/FileLogStore.kt | 11 +- .../logger/android/CrashDirCleanupTest.kt | 102 ++++++++++++++---- 3 files changed, 151 insertions(+), 37 deletions(-) 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 index 686ea852bb..feb23a1caa 100644 --- 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 @@ -44,10 +44,18 @@ internal const val CRASH_MAX_TOTAL_BYTES = 2L * 1024 * 1024 */ internal const val CRASH_MAX_RECORD_BYTES = 512L * 1024 +/** + * @property name on-disk file name. Ownership is decided from its suffix, so it must be the real + * name and not a display label. + * @property lastModifiedMs write time in epoch millis. + * @property lengthBytes size on disk. Required rather than defaulted: budget claim is + * `min(lengthBytes, maxRecordBytes)`, so an omitted size would silently claim zero and disable + * the byte budget for that record. + */ internal data class CrashDirEntry( val name: String, val lastModifiedMs: Long, - val lengthBytes: Long = 0L, + val lengthBytes: Long, ) /** True when [name] is a logger-owned crash record. */ @@ -70,11 +78,19 @@ internal fun selectUnrecognizedEntries( } /** - * Returns owned entries past [maxAgeMillis] — no longer uploadable, so they are reclaimed - * rather than skipped. Foreign entries are left to [selectUnrecognizedEntries]. + * Returns owned entries no longer worth uploading, so they are reclaimed rather than skipped. + * Foreign entries are left to [selectUnrecognizedEntries]. * - * A negative age (mtime in the future, i.e. the clock moved backwards since the write) is - * never treated as expired; the record simply waits until the clock agrees it is old. + * Two ways a record qualifies. The ordinary one is age past [maxAgeMillis]. The other is an + * mtime so far in the future that it can no longer be a clock artifact: the read path gates on + * `nowMs - lastModifiedMs >= minAgeMillis`, which a future timestamp never satisfies, so such a + * record is unreadable for its entire life while still consuming a count slot and budget. + * Reclaiming it is the only way it ever leaves the directory. + * + * The threshold is the retention window itself, which keeps the deliberate backwards-clock + * protection intact: a record dated modestly ahead of now — the clock stepped back since it was + * written — is left alone to wait until the clock agrees it is old. Only one that would still be + * in the future after the entire window has elapsed is written off. */ internal fun selectExpiredOwnedEntries( entries: List, @@ -83,10 +99,23 @@ internal fun selectExpiredOwnedEntries( ownedSuffix: String = CRASH_OWNED_SUFFIX, ): List = entries.filter { entry -> - val age = nowMs - entry.lastModifiedMs - isOwnedCrashFile(entry.name, ownedSuffix) && age > maxAgeMillis + isOwnedCrashFile(entry.name, ownedSuffix) && + ( + nowMs - entry.lastModifiedMs > maxAgeMillis || + isUnrecoverablyFutureDated(entry, nowMs, maxAgeMillis) + ) } +/** + * True when [entry] is dated so far ahead of [nowMs] that it can no longer be explained by a + * clock step, and so can never become readable. + */ +private fun isUnrecoverablyFutureDated( + entry: CrashDirEntry, + nowMs: Long, + maxAgeMillis: Long, +): Boolean = entry.lastModifiedMs - nowMs > maxAgeMillis + /** Leading millis of a `{millis}-{uuid}.otlp` name, or null for anything else. */ private fun leadingMillis(name: String): Long? = name.substringBefore('-').toLongOrNull() @@ -105,23 +134,40 @@ private fun leadingMillis(name: String): Long? = name.substringBefore('-').toLon * * [keepName] is the record the caller just wrote. It is retained regardless of sort position, * so a backwards clock step cannot make a fresh record look oldest and delete it. + * + * [nowMs] bounds how new a record is allowed to sort. A future mtime would otherwise sort ahead + * of every genuine record and hold a keep slot against the whole backlog. Ordinary future dates + * are clamped to [nowMs]; one far enough ahead to be unrecoverable — the same judgement + * [selectExpiredOwnedEntries] makes — sorts last instead, so it is evicted before any record + * that could still be uploaded. Ordering does not assume an expiry pass has run, because + * [FileLogStore]'s write path enforces caps on its own. */ +@Suppress("LongParameterList") internal fun selectOverflowOwnedEntries( entries: List, + nowMs: Long, maxCount: Int = CRASH_MAX_RECORD_COUNT, maxTotalBytes: Long = CRASH_MAX_TOTAL_BYTES, maxRecordBytes: Long = CRASH_MAX_RECORD_BYTES, + maxAgeMillis: Long = CRASH_MAX_READ_AGE_MILLIS, keepName: String? = null, ownedSuffix: String = CRASH_OWNED_SUFFIX, ): List { + fun sortKey(entry: CrashDirEntry): Long = + if (isUnrecoverablyFutureDated(entry, nowMs, maxAgeMillis)) { + Long.MIN_VALUE + } else { + minOf(entry.lastModifiedMs, nowMs) + } + // Ties break on the millis embedded in the name, which is the write time the filesystem // may have rounded away. Names that do not parse sort last among their timestamp group. val newestFirst = entries .filter { isOwnedCrashFile(it.name, ownedSuffix) } .sortedWith( - compareByDescending { it.lastModifiedMs } - .thenByDescending { leadingMillis(it.name) ?: Long.MIN_VALUE }, + compareByDescending { sortKey(it) } + .thenByDescending { leadingMillis(it.name)?.coerceAtMost(nowMs) ?: Long.MIN_VALUE }, ) fun budgetClaim(entry: CrashDirEntry): Long = minOf(entry.lengthBytes, maxRecordBytes) @@ -146,7 +192,9 @@ internal fun selectOverflowOwnedEntries( /** * Builds the human-readable crash-dir inventory line used for rollout verification. - * Per-file detail is capped at [maxSample] so Logcat is not flooded. + * Per-file detail is capped at [maxSample] so Logcat is not flooded. A negative [maxSample] is + * treated as zero rather than throwing — this runs on a crash-adjacent path where a logging + * helper must not be the thing that fails. */ internal fun formatCrashDirInventory( label: String, @@ -159,16 +207,17 @@ internal fun formatCrashDirInventory( if (entries.isEmpty()) { return "OneSignal: Crash storage inventory [$label] ($path): empty" } + val sampleSize = maxSample.coerceAtLeast(0) val otlp = entries.count { isOwnedCrashFile(it.name, ownedSuffix) } val legacy = entries.size - otlp - val sample = entries.take(maxSample) + val sample = entries.take(sampleSize) 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)" + if (entries.size > sampleSize) { + " …(+${entries.size - sampleSize} more)" } else { "" } 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 22553178cd..fd6761036a 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 @@ -104,7 +104,8 @@ internal class FileLogStore( if (owned.size <= CRASH_MAX_RECORD_COUNT && claimed <= CRASH_MAX_TOTAL_BYTES) { return } - val overflow = selectOverflowOwnedEntries(entries, keepName = keepName) + val overflow = + selectOverflowOwnedEntries(entries, nowMs = System.currentTimeMillis(), keepName = keepName) if (overflow.isEmpty()) return var evicted = 0 for (entry in overflow) { @@ -129,8 +130,8 @@ internal class FileLogStore( * * @return names of the evicted records, so callers can exclude them from the same pass */ - private fun reclaimOverLimitRecords(entries: List): Set { - val overflow = selectOverflowOwnedEntries(entries) + private fun reclaimOverLimitRecords(entries: List, nowMs: Long): Set { + val overflow = selectOverflowOwnedEntries(entries, nowMs) if (overflow.isEmpty()) return emptySet() var deleted = 0 for (entry in overflow) { @@ -193,7 +194,7 @@ internal class FileLogStore( // 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) }) + val evicted = reclaimOverLimitRecords(entries.filterNot { expired.contains(it.name) }, now) val dropped = expired + evicted val suffixMatches = entries.filter { isOwnedCrashFile(it.name) && !dropped.contains(it.name) } @@ -261,7 +262,7 @@ internal class FileLogStore( val now = System.currentTimeMillis() val listed = listEntries(rootDir) val expired = reclaimExpiredOwnedRecords(listed, now) - reclaimOverLimitRecords(listed.filterNot { expired.contains(it.name) }) + reclaimOverLimitRecords(listed.filterNot { expired.contains(it.name) }, now) val foreign = selectUnrecognizedEntries(listed, now, minAgeMillis) if (foreign.isEmpty()) { Logging.debug("FileLogStore: no unrecognized files to purge in ${rootDir.path}") 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 index d37a663d9f..aab215a5a5 100644 --- 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 @@ -22,10 +22,10 @@ class CrashDirCleanupTest : FunSpec({ 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), + CrashDirEntry("123-abc.otlp", lastModifiedMs = now - 60_000, lengthBytes = 1L), + CrashDirEntry("too-young-legacy", lastModifiedMs = now - 100, lengthBytes = 1L), + CrashDirEntry("stale-legacy", lastModifiedMs = now - 10_000, lengthBytes = 1L), + CrashDirEntry("stale.tmp", lastModifiedMs = now - 60_000, lengthBytes = 1L), ) val selected = @@ -41,7 +41,7 @@ class CrashDirCleanupTest : FunSpec({ test("selectUnrecognizedEntries is empty when only owned records exist") { val selected = selectUnrecognizedEntries( - entries = listOf(CrashDirEntry("123-abc.otlp", lastModifiedMs = now - 60_000)), + entries = listOf(CrashDirEntry("123-abc.otlp", lastModifiedMs = now - 60_000, lengthBytes = 1L)), nowMs = now, minAgeMillis = 0, ) @@ -59,7 +59,7 @@ class CrashDirCleanupTest : FunSpec({ listOf( owned("1-a.otlp", ageMs = CRASH_MAX_READ_AGE_MILLIS + 1), owned("2-b.otlp", ageMs = CRASH_MAX_READ_AGE_MILLIS - 1), - CrashDirEntry("legacy", lastModifiedMs = now - CRASH_MAX_READ_AGE_MILLIS * 2), + CrashDirEntry("legacy", lastModifiedMs = now - CRASH_MAX_READ_AGE_MILLIS * 2, lengthBytes = 1L), ) selectExpiredOwnedEntries(entries, nowMs = now).map { it.name } shouldBe listOf("1-a.otlp") @@ -71,9 +71,26 @@ class CrashDirCleanupTest : FunSpec({ selectExpiredOwnedEntries(entries, nowMs = now) shouldBe emptyList() } - test("selectExpiredOwnedEntries ignores records whose mtime is in the future") { - // A backwards clock step must not look like extreme age in either direction. - val entries = listOf(owned("1-a.otlp", ageMs = -CRASH_MAX_READ_AGE_MILLIS * 2)) + test("selectExpiredOwnedEntries ignores a plausible backwards clock step") { + // The clock moved back an hour since the record was written. It is a real, recent, + // uploadable crash — it just has to wait for the clock to agree it is old. + val entries = listOf(owned("1-a.otlp", ageMs = -60L * 60 * 1000)) + + selectExpiredOwnedEntries(entries, nowMs = now) shouldBe emptyList() + } + + test("selectExpiredOwnedEntries reclaims a record dated past the window into the future") { + // Beyond a full retention window ahead of now, no clock correction brings it back: the + // read gate (now - mtime >= minAge) can never pass, so the record is unreadable for life + // while still holding a count slot and budget. Expiry is the only thing that removes it. + val entries = listOf(owned("1-a.otlp", ageMs = -(CRASH_MAX_READ_AGE_MILLIS + 1))) + + selectExpiredOwnedEntries(entries, nowMs = now).map { it.name } shouldBe listOf("1-a.otlp") + } + + test("selectExpiredOwnedEntries leaves a record exactly one window into the future") { + // The boundary belongs to the backwards-clock case, matching the past-side ceiling. + val entries = listOf(owned("1-a.otlp", ageMs = -CRASH_MAX_READ_AGE_MILLIS)) selectExpiredOwnedEntries(entries, nowMs = now) shouldBe emptyList() } @@ -87,13 +104,13 @@ class CrashDirCleanupTest : FunSpec({ test("selectOverflowOwnedEntries returns nothing while within both caps") { val entries = (1..3).map { owned("$it-a.otlp", ageMs = it * 1_000L) } - selectOverflowOwnedEntries(entries) shouldBe emptyList() + selectOverflowOwnedEntries(entries, nowMs = now) shouldBe emptyList() } test("selectOverflowOwnedEntries evicts oldest-first past the count cap") { val entries = (1..CRASH_MAX_RECORD_COUNT + 2).map { owned("$it-a.otlp", ageMs = it * 1_000L) } - val evicted = selectOverflowOwnedEntries(entries) + val evicted = selectOverflowOwnedEntries(entries, nowMs = now) // Oldest has the largest age, so the two highest indices go, returned oldest-first. evicted.map { it.name } shouldBe @@ -103,9 +120,9 @@ class CrashDirCleanupTest : FunSpec({ test("selectOverflowOwnedEntries never touches foreign entries") { val entries = (1..CRASH_MAX_RECORD_COUNT + 1).map { owned("$it-a.otlp", ageMs = it * 1_000L) } + - CrashDirEntry("legacy", lastModifiedMs = now - 999_000L) + CrashDirEntry("legacy", lastModifiedMs = now - 999_000L, lengthBytes = 1L) - selectOverflowOwnedEntries(entries).none { it.name == "legacy" } shouldBe true + selectOverflowOwnedEntries(entries, nowMs = now).none { it.name == "legacy" } shouldBe true } test("an oversized record is retained but cannot displace the rest") { @@ -119,7 +136,7 @@ class CrashDirCleanupTest : FunSpec({ owned("2-small.otlp", ageMs = 4_000, bytes = 10), ) - selectOverflowOwnedEntries(entries) shouldBe emptyList() + selectOverflowOwnedEntries(entries, nowMs = now) shouldBe emptyList() } test("a record that does not fit the remaining budget is skipped, not treated as a cutoff") { @@ -132,7 +149,7 @@ class CrashDirCleanupTest : FunSpec({ owned("5-does-not-fit.otlp", ageMs = 5_000, bytes = 200_000) + owned("4-still-fits.otlp", ageMs = 6_000, bytes = 40_000) - selectOverflowOwnedEntries(entries).map { it.name } shouldBe listOf("5-does-not-fit.otlp") + selectOverflowOwnedEntries(entries, nowMs = now).map { it.name } shouldBe listOf("5-does-not-fit.otlp") } test("keepName retains the just-written record even when it sorts oldest") { @@ -141,7 +158,7 @@ class CrashDirCleanupTest : FunSpec({ (1..CRASH_MAX_RECORD_COUNT).map { owned("$it-a.otlp", ageMs = it * 1_000L) } + owned("fresh-a.otlp", ageMs = 999_000) - val evicted = selectOverflowOwnedEntries(entries, keepName = "fresh-a.otlp") + val evicted = selectOverflowOwnedEntries(entries, nowMs = now, keepName = "fresh-a.otlp") evicted.none { it.name == "fresh-a.otlp" } shouldBe true evicted.map { it.name } shouldBe listOf("${CRASH_MAX_RECORD_COUNT}-a.otlp") @@ -155,7 +172,7 @@ class CrashDirCleanupTest : FunSpec({ val backlog = (1..4).map { owned("$it-small.otlp", ageMs = it * 10_000L, bytes = 400_000) } val entries = backlog + owned("fresh-a.otlp", ageMs = 1_000, bytes = CRASH_MAX_TOTAL_BYTES * 2) - val evicted = selectOverflowOwnedEntries(entries, keepName = "fresh-a.otlp") + val evicted = selectOverflowOwnedEntries(entries, nowMs = now, keepName = "fresh-a.otlp") // The oversized record claims only its capped share, leaving room for the backlog. evicted.none { it.name == "fresh-a.otlp" } shouldBe true @@ -171,13 +188,45 @@ class CrashDirCleanupTest : FunSpec({ CrashDirEntry("200-b.otlp", lastModifiedMs = now, lengthBytes = 10), ) - val evicted = selectOverflowOwnedEntries(entries, maxCount = 2) + val evicted = selectOverflowOwnedEntries(entries, nowMs = now, maxCount = 2) evicted.map { it.name } shouldBe listOf("100-a.otlp") } + test("a future-dated record is evicted before any record that could still upload") { + // The write path enforces caps without running expiry first, so ordering has to make this + // call on its own. Left unranked, the future record sorts newest, keeps its slot forever, + // and pushes out genuine records that are still uploadable. + val entries = + listOf( + owned("9-zombie.otlp", ageMs = -(CRASH_MAX_READ_AGE_MILLIS + 1)), + owned("300-a.otlp", ageMs = 1_000), + owned("200-b.otlp", ageMs = 2_000), + owned("100-c.otlp", ageMs = 3_000), + ) + + val evicted = selectOverflowOwnedEntries(entries, nowMs = now, maxCount = 2) + + evicted.map { it.name } shouldBe listOf("9-zombie.otlp", "100-c.otlp") + } + + test("a modestly future-dated record still ranks among the newest") { + // Only an unrecoverable date is written off. An ordinary backwards clock step leaves a + // real, recent record that must keep its place ahead of older ones. + val entries = + listOf( + owned("9-clock-skew.otlp", ageMs = -60_000), + owned("300-a.otlp", ageMs = 1_000), + owned("100-c.otlp", ageMs = 3_000), + ) + + val evicted = selectOverflowOwnedEntries(entries, nowMs = now, maxCount = 2) + + evicted.map { it.name } shouldBe listOf("100-c.otlp") + } + test("selectOverflowOwnedEntries is empty for an empty directory") { - selectOverflowOwnedEntries(emptyList()) shouldBe emptyList() + selectOverflowOwnedEntries(emptyList(), nowMs = now) shouldBe emptyList() } test("formatCrashDirInventory reports empty directories") { @@ -211,4 +260,19 @@ class CrashDirCleanupTest : FunSpec({ line shouldContain "…(+20 more)" line shouldNotContain "name=legacy-25" } + + test("formatCrashDirInventory treats a negative sample size as zero") { + // A logging helper on a crash-adjacent path must not be the thing that throws. + val line = + formatCrashDirInventory( + label = "after-cleanup", + path = "/cache/crashes", + entries = listOf(owned("1-a.otlp", ageMs = 1_000)), + nowMs = now, + maxSample = -1, + ) + + line shouldContain "total=1 otlp=1 legacy=0" + line shouldContain "…(+1 more)" + } }) From 0d0b9f2a193476a59a56c6c7998b9a714b768ebd Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Aug 2026 13:14:34 -0500 Subject: [PATCH 09/12] chore: [SDK-5065] bump KMP submodule to the shared crash-retention policy Moves the pin from 87e87fd to 64ce06b, picking up: - #20 shared crash-record retention policy (CrashRetention / CrashRetentionPolicy / CrashDirEntry in commonMain, with 29 commonTest cases running on both JVM and iOS) - #21 bounded retry/backoff for remote log export Pointer change only; Android still uses its local duplicate of the retention logic, which the next commit removes. Co-authored-by: Cursor --- OneSignal-KMP-SDK | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 825f67b806275b373c3f5762e72e9648f7cc4e9d Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Aug 2026 13:25:57 -0500 Subject: [PATCH 10/12] refactor: [SDK-5065] use the shared crash-retention policy on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CrashDirCleanup.kt was a near-duplicate of KMP's CrashRetention, written only because the shared version did not exist yet. Now that it does, Android consumes it and the local copy goes, leaving FileLogStore responsible for nothing but File I/O — turning a directory listing into CrashDirEntrys and applying the decisions the shared selectors return. Pure refactor, no behaviour change. The shared bounds are identical to the ones the deleted constants carried (72h read age, 50 records, 2 MiB budget, 512 KiB per record, ".otlp"), and the selector bodies match line for line, including the full-window future-date threshold and the clamp-vs-sort-last ordering. Shape differs deliberately: the shared API groups the bounds into a CrashRetentionPolicy that every selector takes, so FileLogStore holds one CrashRetention.defaultPolicy instance and passes the same one everywhere rather than relying on per-call defaults. The inline cheap-exit in enforceAccumulationCaps is now CrashRetention.isWithinCaps, which shares the selector's capped accounting instead of restating it. CrashDirCleanupTest goes with the implementation it covered: KMP's CrashRetentionTest is a strict superset of its 22 cases, and runs them on both JVM and iOS. FileLogStoreTest covers Android's own file I/O and stays, asserting against the shared policy rather than copies of its numbers. Co-authored-by: Cursor --- .../crash/OneSignalCrashUploaderWrapper.kt | 6 +- .../logging/logger/android/CrashDirCleanup.kt | 226 -------------- .../logging/logger/android/FileLogStore.kt | 54 ++-- .../logger/android/LoggerPlatformProvider.kt | 3 +- .../logger/android/CrashDirCleanupTest.kt | 278 ------------------ .../logger/android/FileLogStoreTest.kt | 41 +-- 6 files changed, 59 insertions(+), 549 deletions(-) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanup.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanupTest.kt 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 2f3818f761..ff547b2edf 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 @@ -6,13 +6,13 @@ 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.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.logger.android.getCrashStoragePath import com.onesignal.logger.LoggerFactory +import com.onesignal.logger.crash.CrashDirEntry +import com.onesignal.logger.crash.CrashRetention import java.io.File import kotlin.coroutines.cancellation.CancellationException @@ -91,7 +91,7 @@ internal class OneSignalCrashUploaderWrapper( ) }.orEmpty() Logging.info( - formatCrashDirInventory( + CrashRetention.formatInventory( label = label, path = path, entries = entries, 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 feb23a1caa..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanup.kt +++ /dev/null @@ -1,226 +0,0 @@ -package com.onesignal.debug.internal.logging.logger.android - -/** - * Pure, Android-free helpers for the crash directory. - * - * Ownership is suffix-based: logger-owned records end in [CRASH_OWNED_SUFFIX]; everything else - * (bare-millis names left by a pre-upgrade otel session, 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" - -/** - * Upper bound on how long an owned record stays eligible for upload, carried over from the - * disk-buffering config the otel module used. Past this the payload is too stale to be worth - * shipping, and without a ceiling a record that never uploads successfully — including one - * written while remote logging is off, which is never even read — would be retried on every - * launch forever. - */ -internal const val CRASH_MAX_READ_AGE_MILLIS = 72L * 60 * 60 * 1000 - -/** - * Accumulation caps, applied oldest-first. The count bound is what normally binds: crash - * records are single-event OTLP payloads of a few KB, so 50 covers far more unsent crashes - * than a healthy install will ever hold. The byte bound is the backstop for pathological - * payloads (deep stacktraces, huge exception messages) where count alone would not keep the - * directory small. - * - * [CRASH_MAX_TOTAL_BYTES] bounds *claim*, not bytes on disk. Since writes are size-limited, - * the two coincide for anything this build wrote. They diverge only for records inherited - * from a build without that limit: each claims at most [CRASH_MAX_RECORD_BYTES], so a handful - * of oversized leftovers can occupy more than this while still counting as within cap. That - * is deliberate — they are real crashes and deserve an upload attempt — and it is bounded by - * the count cap and by [CRASH_MAX_READ_AGE_MILLIS] aging them out. - */ -internal const val CRASH_MAX_RECORD_COUNT = 50 - -internal const val CRASH_MAX_TOTAL_BYTES = 2L * 1024 * 1024 - -/** - * Largest payload [com.onesignal.debug.internal.logging.logger.android.FileLogStore] will - * write. Rejecting at the source keeps every stored record within the shared budget, so no - * single payload can push the rest out. It also caps how much budget an oversized record - * inherited from a build without this limit is allowed to claim. - */ -internal const val CRASH_MAX_RECORD_BYTES = 512L * 1024 - -/** - * @property name on-disk file name. Ownership is decided from its suffix, so it must be the real - * name and not a display label. - * @property lastModifiedMs write time in epoch millis. - * @property lengthBytes size on disk. Required rather than defaulted: budget claim is - * `min(lengthBytes, maxRecordBytes)`, so an omitted size would silently claim zero and disable - * the byte budget for that record. - */ -internal data class CrashDirEntry( - val name: String, - val lastModifiedMs: Long, - val lengthBytes: Long, -) - -/** 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 - } - -/** - * Returns owned entries no longer worth uploading, so they are reclaimed rather than skipped. - * Foreign entries are left to [selectUnrecognizedEntries]. - * - * Two ways a record qualifies. The ordinary one is age past [maxAgeMillis]. The other is an - * mtime so far in the future that it can no longer be a clock artifact: the read path gates on - * `nowMs - lastModifiedMs >= minAgeMillis`, which a future timestamp never satisfies, so such a - * record is unreadable for its entire life while still consuming a count slot and budget. - * Reclaiming it is the only way it ever leaves the directory. - * - * The threshold is the retention window itself, which keeps the deliberate backwards-clock - * protection intact: a record dated modestly ahead of now — the clock stepped back since it was - * written — is left alone to wait until the clock agrees it is old. Only one that would still be - * in the future after the entire window has elapsed is written off. - */ -internal fun selectExpiredOwnedEntries( - entries: List, - nowMs: Long, - maxAgeMillis: Long = CRASH_MAX_READ_AGE_MILLIS, - ownedSuffix: String = CRASH_OWNED_SUFFIX, -): List = - entries.filter { entry -> - isOwnedCrashFile(entry.name, ownedSuffix) && - ( - nowMs - entry.lastModifiedMs > maxAgeMillis || - isUnrecoverablyFutureDated(entry, nowMs, maxAgeMillis) - ) - } - -/** - * True when [entry] is dated so far ahead of [nowMs] that it can no longer be explained by a - * clock step, and so can never become readable. - */ -private fun isUnrecoverablyFutureDated( - entry: CrashDirEntry, - nowMs: Long, - maxAgeMillis: Long, -): Boolean = entry.lastModifiedMs - nowMs > maxAgeMillis - -/** Leading millis of a `{millis}-{uuid}.otlp` name, or null for anything else. */ -private fun leadingMillis(name: String): Long? = name.substringBefore('-').toLongOrNull() - -/** - * Returns the owned entries to evict so the directory fits within [maxCount] and - * [maxTotalBytes], newest kept and the excess returned oldest-first. - * - * Size is never on its own a reason to evict. A record too large to upload should be refused - * at write time; deleting one that is already on disk would destroy a captured crash without - * ever attempting to send it. What size does control is *budget claim*: each record is charged - * at most [maxRecordBytes], so one outsized payload — necessarily inherited from a build - * without the write-time limit — cannot displace the rest of the backlog. - * - * A record that does not fit the remaining budget is skipped rather than treated as a cutoff, - * so everything older still gets its chance to fit. - * - * [keepName] is the record the caller just wrote. It is retained regardless of sort position, - * so a backwards clock step cannot make a fresh record look oldest and delete it. - * - * [nowMs] bounds how new a record is allowed to sort. A future mtime would otherwise sort ahead - * of every genuine record and hold a keep slot against the whole backlog. Ordinary future dates - * are clamped to [nowMs]; one far enough ahead to be unrecoverable — the same judgement - * [selectExpiredOwnedEntries] makes — sorts last instead, so it is evicted before any record - * that could still be uploaded. Ordering does not assume an expiry pass has run, because - * [FileLogStore]'s write path enforces caps on its own. - */ -@Suppress("LongParameterList") -internal fun selectOverflowOwnedEntries( - entries: List, - nowMs: Long, - maxCount: Int = CRASH_MAX_RECORD_COUNT, - maxTotalBytes: Long = CRASH_MAX_TOTAL_BYTES, - maxRecordBytes: Long = CRASH_MAX_RECORD_BYTES, - maxAgeMillis: Long = CRASH_MAX_READ_AGE_MILLIS, - keepName: String? = null, - ownedSuffix: String = CRASH_OWNED_SUFFIX, -): List { - fun sortKey(entry: CrashDirEntry): Long = - if (isUnrecoverablyFutureDated(entry, nowMs, maxAgeMillis)) { - Long.MIN_VALUE - } else { - minOf(entry.lastModifiedMs, nowMs) - } - - // Ties break on the millis embedded in the name, which is the write time the filesystem - // may have rounded away. Names that do not parse sort last among their timestamp group. - val newestFirst = - entries - .filter { isOwnedCrashFile(it.name, ownedSuffix) } - .sortedWith( - compareByDescending { sortKey(it) } - .thenByDescending { leadingMillis(it.name)?.coerceAtMost(nowMs) ?: Long.MIN_VALUE }, - ) - - fun budgetClaim(entry: CrashDirEntry): Long = minOf(entry.lengthBytes, maxRecordBytes) - - val kept = HashSet() - var keptBytes = 0L - keepName?.let { name -> - newestFirst.firstOrNull { it.name == name }?.let { - kept.add(it.name) - keptBytes += budgetClaim(it) - } - } - for (entry in newestFirst) { - if (kept.contains(entry.name)) continue - if (kept.size >= maxCount) break - if (keptBytes + budgetClaim(entry) > maxTotalBytes) continue - kept.add(entry.name) - keptBytes += budgetClaim(entry) - } - return newestFirst.filterNot { kept.contains(it.name) }.reversed() -} - -/** - * Builds the human-readable crash-dir inventory line used for rollout verification. - * Per-file detail is capped at [maxSample] so Logcat is not flooded. A negative [maxSample] is - * treated as zero rather than throwing — this runs on a crash-adjacent path where a logging - * helper must not be the thing that fails. - */ -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 sampleSize = maxSample.coerceAtLeast(0) - val otlp = entries.count { isOwnedCrashFile(it.name, ownedSuffix) } - val legacy = entries.size - otlp - val sample = entries.take(sampleSize) - val summary = - sample.joinToString(separator = "; ") { entry -> - "name=${entry.name} bytes=${entry.lengthBytes} ageMs=${nowMs - entry.lastModifiedMs}" - } - val truncated = - if (entries.size > sampleSize) { - " …(+${entries.size - sampleSize} 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 fd6761036a..b6a942c5dd 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 @@ -20,13 +22,15 @@ import kotlin.coroutines.cancellation.CancellationException * still have been writing). * * The directory is inherited from the removed otel module, so ownership is distinguished - * purely by [CRASH_OWNED_SUFFIX]: everything the logger writes ends in `.otlp`; anything + * purely by the policy's owned suffix: everything the logger writes ends in `.otlp`; anything * else (bare-millis files left by an otel session before upgrade, stray `.tmp`s) is * foreign and reclaimable via [deleteUnrecognizedEntries]. * - * Owned records are bounded on both axes, replacing the caps disk-buffering used to apply: - * [CRASH_MAX_READ_AGE_MILLIS] ages records out, and [CRASH_MAX_RECORD_COUNT] / - * [CRASH_MAX_TOTAL_BYTES] cap accumulation — the latter by budget claim rather than raw disk + * 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 + * results with `File` I/O. Owned records are bounded on both axes, replacing the caps + * disk-buffering used to apply: `maxReadAgeMillis` ages records out, and `maxRecordCount` / + * `maxTotalBytes` cap accumulation — the latter by budget claim rather than raw disk * bytes, which differ only for oversized records inherited from a build that predates the * write-time limit in [save]. Both bounds are enforced on every path that * touches the directory — [save], [listReadable] and [deleteUnrecognizedEntries] — so a @@ -39,6 +43,9 @@ internal class FileLogStore( ) : 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" @@ -49,21 +56,21 @@ internal class FileLogStore( @Suppress("TooGenericExceptionCaught", "SwallowedException") override fun save(bytes: ByteArray): Boolean { return try { - if (bytes.size > CRASH_MAX_RECORD_BYTES) { + 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. // Losing it loudly here beats losing it silently on a later launch. Log.w( TAG, "FileLogStore: refusing record of ${bytes.size} bytes, " + - "over the $CRASH_MAX_RECORD_BYTES-byte limit", + "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)) { @@ -97,15 +104,16 @@ internal class FileLogStore( private fun enforceAccumulationCaps(dir: File, keepName: String) { try { val entries = listEntries(dir) - val owned = entries.filter { isOwnedCrashFile(it.name) } - // Cheap exit for the common case. Charges are capped per record to match the - // selector's accounting, so this agrees with it rather than second-guessing it. - val claimed = owned.sumOf { minOf(it.lengthBytes, CRASH_MAX_RECORD_BYTES) } - if (owned.size <= CRASH_MAX_RECORD_COUNT && claimed <= CRASH_MAX_TOTAL_BYTES) { - return - } + // Cheap exit for the common case, using the selector's own capped accounting so + // the two cannot disagree about whether a trim is needed. + if (CrashRetention.isWithinCaps(entries, policy)) return val overflow = - selectOverflowOwnedEntries(entries, nowMs = System.currentTimeMillis(), keepName = keepName) + CrashRetention.selectOverflowOwned( + entries, + nowMs = System.currentTimeMillis(), + keepName = keepName, + policy = policy, + ) if (overflow.isEmpty()) return var evicted = 0 for (entry in overflow) { @@ -131,7 +139,7 @@ internal class FileLogStore( * @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 = selectOverflowOwnedEntries(entries, nowMs) + val overflow = CrashRetention.selectOverflowOwned(entries, nowMs, policy = policy) if (overflow.isEmpty()) return emptySet() var deleted = 0 for (entry in overflow) { @@ -158,7 +166,7 @@ internal class FileLogStore( }.orEmpty() /** - * Deletes owned records past [CRASH_MAX_READ_AGE_MILLIS]. Called from both read paths so + * 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]. * @@ -168,7 +176,7 @@ internal class FileLogStore( * eviction rather than retention, and only retained records claim budget. */ private fun reclaimExpiredOwnedRecords(entries: List, nowMs: Long): Set { - val expired = selectExpiredOwnedEntries(entries, nowMs) + val expired = CrashRetention.selectExpiredOwned(entries, nowMs, policy) if (expired.isEmpty()) return emptySet() var deleted = 0 for (entry in expired) { @@ -197,7 +205,7 @@ internal class FileLogStore( val evicted = reclaimOverLimitRecords(entries.filterNot { expired.contains(it.name) }, now) val dropped = expired + evicted val suffixMatches = - entries.filter { isOwnedCrashFile(it.name) && !dropped.contains(it.name) } + entries.filter { CrashRetention.isOwned(it.name, policy) && !dropped.contains(it.name) } val readable = suffixMatches .filter { now - it.lastModifiedMs >= minAgeMillis } @@ -206,7 +214,7 @@ internal class FileLogStore( "FileLogStore: listReadable minAgeMs=$minAgeMillis total=${entries.size} " + "suffix=${suffixMatches.size} readable=${readable.size} " + "expired=${expired.size} overCap=${evicted.size} " + - "legacy=${entries.count { !isOwnedCrashFile(it.name) }}", + "legacy=${entries.count { !CrashRetention.isOwned(it.name, policy) }}", ) readable } catch (e: CancellationException) { @@ -244,8 +252,8 @@ 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]. Owned `*.otlp` records are left untouched so failed - * / too-young uploads can still retry on the next launch, except for ones past - * [CRASH_MAX_READ_AGE_MILLIS] or beyond the accumulation caps, which are no longer + * / too-young uploads can still retry on the next launch, except for ones past the + * policy's read-age ceiling or beyond the accumulation caps, which are no longer * uploadable or no longer affordable to keep. * * Implements the shared [ILogFileStore] contract: the KMP `LogCrashUploader` @@ -263,7 +271,7 @@ internal class FileLogStore( val listed = listEntries(rootDir) val expired = reclaimExpiredOwnedRecords(listed, now) reclaimOverLimitRecords(listed.filterNot { expired.contains(it.name) }, now) - val foreign = selectUnrecognizedEntries(listed, now, minAgeMillis) + 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/logger/android/LoggerPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProvider.kt index 849da654ba..def491a252 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProvider.kt @@ -197,7 +197,8 @@ internal fun createAndroidLoggerPlatformProvider( /** * 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 [selectUnrecognizedEntries]. + * 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/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 aab215a5a5..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/android/CrashDirCleanupTest.kt +++ /dev/null @@ -1,278 +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, lengthBytes = 1L), - CrashDirEntry("too-young-legacy", lastModifiedMs = now - 100, lengthBytes = 1L), - CrashDirEntry("stale-legacy", lastModifiedMs = now - 10_000, lengthBytes = 1L), - CrashDirEntry("stale.tmp", lastModifiedMs = now - 60_000, lengthBytes = 1L), - ) - - 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, lengthBytes = 1L)), - nowMs = now, - minAgeMillis = 0, - ) - - selected shouldBe emptyList() - } - - // ===== selectExpiredOwnedEntries ===== - - fun owned(name: String, ageMs: Long, bytes: Long = 1L) = - CrashDirEntry(name, lastModifiedMs = now - ageMs, lengthBytes = bytes) - - test("selectExpiredOwnedEntries takes only owned records strictly past the ceiling") { - val entries = - listOf( - owned("1-a.otlp", ageMs = CRASH_MAX_READ_AGE_MILLIS + 1), - owned("2-b.otlp", ageMs = CRASH_MAX_READ_AGE_MILLIS - 1), - CrashDirEntry("legacy", lastModifiedMs = now - CRASH_MAX_READ_AGE_MILLIS * 2, lengthBytes = 1L), - ) - - selectExpiredOwnedEntries(entries, nowMs = now).map { it.name } shouldBe listOf("1-a.otlp") - } - - test("selectExpiredOwnedEntries treats a record at exactly the ceiling as still readable") { - val entries = listOf(owned("1-a.otlp", ageMs = CRASH_MAX_READ_AGE_MILLIS)) - - selectExpiredOwnedEntries(entries, nowMs = now) shouldBe emptyList() - } - - test("selectExpiredOwnedEntries ignores a plausible backwards clock step") { - // The clock moved back an hour since the record was written. It is a real, recent, - // uploadable crash — it just has to wait for the clock to agree it is old. - val entries = listOf(owned("1-a.otlp", ageMs = -60L * 60 * 1000)) - - selectExpiredOwnedEntries(entries, nowMs = now) shouldBe emptyList() - } - - test("selectExpiredOwnedEntries reclaims a record dated past the window into the future") { - // Beyond a full retention window ahead of now, no clock correction brings it back: the - // read gate (now - mtime >= minAge) can never pass, so the record is unreadable for life - // while still holding a count slot and budget. Expiry is the only thing that removes it. - val entries = listOf(owned("1-a.otlp", ageMs = -(CRASH_MAX_READ_AGE_MILLIS + 1))) - - selectExpiredOwnedEntries(entries, nowMs = now).map { it.name } shouldBe listOf("1-a.otlp") - } - - test("selectExpiredOwnedEntries leaves a record exactly one window into the future") { - // The boundary belongs to the backwards-clock case, matching the past-side ceiling. - val entries = listOf(owned("1-a.otlp", ageMs = -CRASH_MAX_READ_AGE_MILLIS)) - - selectExpiredOwnedEntries(entries, nowMs = now) shouldBe emptyList() - } - - test("selectExpiredOwnedEntries is empty for an empty directory") { - selectExpiredOwnedEntries(emptyList(), nowMs = now) shouldBe emptyList() - } - - // ===== selectOverflowOwnedEntries ===== - - test("selectOverflowOwnedEntries returns nothing while within both caps") { - val entries = (1..3).map { owned("$it-a.otlp", ageMs = it * 1_000L) } - - selectOverflowOwnedEntries(entries, nowMs = now) shouldBe emptyList() - } - - test("selectOverflowOwnedEntries evicts oldest-first past the count cap") { - val entries = (1..CRASH_MAX_RECORD_COUNT + 2).map { owned("$it-a.otlp", ageMs = it * 1_000L) } - - val evicted = selectOverflowOwnedEntries(entries, nowMs = now) - - // Oldest has the largest age, so the two highest indices go, returned oldest-first. - evicted.map { it.name } shouldBe - listOf("${CRASH_MAX_RECORD_COUNT + 2}-a.otlp", "${CRASH_MAX_RECORD_COUNT + 1}-a.otlp") - } - - test("selectOverflowOwnedEntries never touches foreign entries") { - val entries = - (1..CRASH_MAX_RECORD_COUNT + 1).map { owned("$it-a.otlp", ageMs = it * 1_000L) } + - CrashDirEntry("legacy", lastModifiedMs = now - 999_000L, lengthBytes = 1L) - - selectOverflowOwnedEntries(entries, nowMs = now).none { it.name == "legacy" } shouldBe true - } - - test("an oversized record is retained but cannot displace the rest") { - // Size alone is never grounds for eviction — deleting a captured crash without ever - // attempting to upload it is worse than keeping it. What size limits is budget claim. - val entries = - listOf( - owned("5-newest.otlp", ageMs = 1_000, bytes = 10), - owned("4-huge.otlp", ageMs = 2_000, bytes = CRASH_MAX_TOTAL_BYTES * 2), - owned("3-small.otlp", ageMs = 3_000, bytes = 10), - owned("2-small.otlp", ageMs = 4_000, bytes = 10), - ) - - selectOverflowOwnedEntries(entries, nowMs = now) shouldBe emptyList() - } - - test("a record that does not fit the remaining budget is skipped, not treated as a cutoff") { - // Four records just under the per-record cap fill most of the budget. The next one - // cannot fit, but a smaller, *older* one still can — proving the loop skips rather - // than stopping at the first record that overflows. - val nearCap = CRASH_MAX_RECORD_BYTES - 12_288 - val entries = - (1..4).map { owned("${10 - it}-fills.otlp", ageMs = it * 1_000L, bytes = nearCap) } + - owned("5-does-not-fit.otlp", ageMs = 5_000, bytes = 200_000) + - owned("4-still-fits.otlp", ageMs = 6_000, bytes = 40_000) - - selectOverflowOwnedEntries(entries, nowMs = now).map { it.name } shouldBe listOf("5-does-not-fit.otlp") - } - - test("keepName retains the just-written record even when it sorts oldest") { - // A backwards clock step can make a fresh write look older than its siblings. - val entries = - (1..CRASH_MAX_RECORD_COUNT).map { owned("$it-a.otlp", ageMs = it * 1_000L) } + - owned("fresh-a.otlp", ageMs = 999_000) - - val evicted = selectOverflowOwnedEntries(entries, nowMs = now, keepName = "fresh-a.otlp") - - evicted.none { it.name == "fresh-a.otlp" } shouldBe true - evicted.map { it.name } shouldBe listOf("${CRASH_MAX_RECORD_COUNT}-a.otlp") - } - - test("an oversized keepName does not evict the pending backlog") { - // The regression this guards: charging keepName its full length started the budget - // over cap, so every sibling failed the remaining-budget check and the entire backlog - // was deleted — then the uploader dropped the oversized record too. A single-entry - // directory cannot observe this, which is why the case above did not catch it. - val backlog = (1..4).map { owned("$it-small.otlp", ageMs = it * 10_000L, bytes = 400_000) } - val entries = backlog + owned("fresh-a.otlp", ageMs = 1_000, bytes = CRASH_MAX_TOTAL_BYTES * 2) - - val evicted = selectOverflowOwnedEntries(entries, nowMs = now, keepName = "fresh-a.otlp") - - // The oversized record claims only its capped share, leaving room for the backlog. - evicted.none { it.name == "fresh-a.otlp" } shouldBe true - evicted.map { it.name } shouldBe listOf("4-small.otlp") - } - - test("equal timestamps break the tie on the millis embedded in the name") { - // Coarse filesystem timestamps collapse mtimes; the name preserves write order. - val entries = - listOf( - CrashDirEntry("100-a.otlp", lastModifiedMs = now, lengthBytes = 10), - CrashDirEntry("300-c.otlp", lastModifiedMs = now, lengthBytes = 10), - CrashDirEntry("200-b.otlp", lastModifiedMs = now, lengthBytes = 10), - ) - - val evicted = selectOverflowOwnedEntries(entries, nowMs = now, maxCount = 2) - - evicted.map { it.name } shouldBe listOf("100-a.otlp") - } - - test("a future-dated record is evicted before any record that could still upload") { - // The write path enforces caps without running expiry first, so ordering has to make this - // call on its own. Left unranked, the future record sorts newest, keeps its slot forever, - // and pushes out genuine records that are still uploadable. - val entries = - listOf( - owned("9-zombie.otlp", ageMs = -(CRASH_MAX_READ_AGE_MILLIS + 1)), - owned("300-a.otlp", ageMs = 1_000), - owned("200-b.otlp", ageMs = 2_000), - owned("100-c.otlp", ageMs = 3_000), - ) - - val evicted = selectOverflowOwnedEntries(entries, nowMs = now, maxCount = 2) - - evicted.map { it.name } shouldBe listOf("9-zombie.otlp", "100-c.otlp") - } - - test("a modestly future-dated record still ranks among the newest") { - // Only an unrecoverable date is written off. An ordinary backwards clock step leaves a - // real, recent record that must keep its place ahead of older ones. - val entries = - listOf( - owned("9-clock-skew.otlp", ageMs = -60_000), - owned("300-a.otlp", ageMs = 1_000), - owned("100-c.otlp", ageMs = 3_000), - ) - - val evicted = selectOverflowOwnedEntries(entries, nowMs = now, maxCount = 2) - - evicted.map { it.name } shouldBe listOf("100-c.otlp") - } - - test("selectOverflowOwnedEntries is empty for an empty directory") { - selectOverflowOwnedEntries(emptyList(), nowMs = now) 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" - } - - test("formatCrashDirInventory treats a negative sample size as zero") { - // A logging helper on a crash-adjacent path must not be the thing that throws. - val line = - formatCrashDirInventory( - label = "after-cleanup", - path = "/cache/crashes", - entries = listOf(owned("1-a.otlp", ageMs = 1_000)), - nowMs = now, - maxSample = -1, - ) - - line shouldContain "total=1 otlp=1 legacy=0" - line shouldContain "…(+1 more)" - } -}) 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 4961495d3b..159f8b30d0 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() } @@ -90,7 +95,7 @@ class FileLogStoreTest : FunSpec({ } test("listReadable drops an owned record past the max read age and deletes it from disk") { - write("expired-123.otlp", ageMsAgo = CRASH_MAX_READ_AGE_MILLIS + 60_000) + 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) } @@ -101,7 +106,7 @@ class FileLogStoreTest : FunSpec({ } test("listReadable returns and retains an owned record inside the age window") { - write("edge-123.otlp", ageMsAgo = CRASH_MAX_READ_AGE_MILLIS - 60_000) + write("edge-123.otlp", ageMsAgo = policy.maxReadAgeMillis - 60_000) val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) } @@ -110,7 +115,7 @@ class FileLogStoreTest : FunSpec({ } test("deleteUnrecognizedEntries reclaims expired owned records without counting them as foreign") { - write("expired-123.otlp", ageMsAgo = CRASH_MAX_READ_AGE_MILLIS + 60_000) + write("expired-123.otlp", ageMsAgo = policy.maxReadAgeMillis + 60_000) write("fresh-456.otlp", ageMsAgo = 60_000) write("1784621689841") @@ -124,14 +129,14 @@ class FileLogStoreTest : FunSpec({ 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(CRASH_MAX_RECORD_COUNT) { i -> + repeat(policy.maxRecordCount) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } - val oldest = "seed-${CRASH_MAX_RECORD_COUNT - 1}.otlp" + val oldest = "seed-${policy.maxRecordCount - 1}.otlp" FileLogStore(dir.path).save("new".toByteArray()) shouldBe true - dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe CRASH_MAX_RECORD_COUNT + 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 } @@ -139,7 +144,7 @@ class FileLogStoreTest : FunSpec({ 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 = (CRASH_MAX_RECORD_BYTES - 12_288).toInt() + 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 @@ -149,17 +154,17 @@ class FileLogStoreTest : FunSpec({ } test("save refuses a payload over the per-record limit and writes nothing") { - val oversized = ByteArray((CRASH_MAX_RECORD_BYTES + 1).toInt()) + val oversized = ByteArray((policy.maxRecordBytes + 1).toInt()) FileLogStore(dir.path).save(oversized) shouldBe false - dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe 0 + 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 = (CRASH_MAX_RECORD_BYTES + 1).toInt()) + write("inherited.otlp", ageMsAgo = 60_000, sizeBytes = (policy.maxRecordBytes + 1).toInt()) val readable = runBlocking { FileLogStore(dir.path).listReadable(minAgeMillis = 0) } @@ -168,11 +173,11 @@ class FileLogStoreTest : FunSpec({ } test("save never evicts the record it just wrote") { - repeat(CRASH_MAX_RECORD_COUNT + 5) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } + repeat(policy.maxRecordCount + 5) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } FileLogStore(dir.path).save("new".toByteArray()) shouldBe true - val remaining = dir.listFiles()!!.filter { it.name.endsWith(CRASH_OWNED_SUFFIX) } + val remaining = dir.listFiles()!!.filter { it.name.endsWith(policy.ownedSuffix) } remaining.none { it.name.startsWith("seed-") && it.readText() == "new" } shouldBe true remaining.count { it.readText() == "new" } shouldBe 1 } @@ -181,18 +186,18 @@ class FileLogStoreTest : FunSpec({ // 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(CRASH_MAX_RECORD_COUNT + 10) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } + 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 CRASH_MAX_RECORD_COUNT - dir.listFiles()!!.count { it.name.endsWith(CRASH_OWNED_SUFFIX) } shouldBe CRASH_MAX_RECORD_COUNT + 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 = CRASH_MAX_READ_AGE_MILLIS + 60_000) + 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) @@ -205,13 +210,13 @@ class FileLogStoreTest : FunSpec({ } test("deleteUnrecognizedEntries evicts an inherited over-cap backlog") { - repeat(CRASH_MAX_RECORD_COUNT + 10) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } + 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(CRASH_OWNED_SUFFIX) } shouldBe CRASH_MAX_RECORD_COUNT + dir.listFiles()!!.count { it.name.endsWith(policy.ownedSuffix) } shouldBe policy.maxRecordCount } }) From ba75ecf1a751b98650ae97d11070586afa5b6efe Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Aug 2026 13:45:28 -0500 Subject: [PATCH 11/12] chore: trim comment verbosity in the logger observability files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the review standard already applied to the KMP side: a comment should state a constraint the code cannot show, never provenance, never a narration of the next line, never an argument aimed at a reviewer that the change is correct. Cut across FileLogStore, AnrCheckEvaluator, LoggerLifecycleManager, AndroidLogAnrDetector, OneSignalCrashUploaderWrapper and their tests: - Provenance: references to the removed OpenTelemetry disk-buffering library, "mirrors the old otel behavior", "ported from the deleted otel equivalent". That history lives in this PR body and in the commits that removed the module. - Reviewer-facing justification: paragraphs defending the one-time cost of a crash-path trim, the testability of the pure decision core, and why the crash-dir path helper is preferred over building a provider. - Repetition: the AnrCheckResult doc comments were restated verbatim on the BlockClassification entries; the teardown-ordering invariant was spelled out in the production code and again in two test comments; the stack fingerprint rationale appeared in three places. Kept the comments where the obvious reading is wrong: the byte cap bounds the budget claim rather than disk bytes, expired names are returned even when the unlink fails, save() must use raw Logcat because Logging.info can run app listeners, keepName exists so save() cannot evict its own record, and ANR stacktraces must stay byte-identical to the crash path's format. Comments and KDoc only — no non-comment line is touched. Co-authored-by: Cursor --- .../debug/internal/crash/AnrCheckEvaluator.kt | 48 ++++-------- .../crash/OneSignalCrashUploaderWrapper.kt | 25 ++----- .../logger/android/AndroidLogAnrDetector.kt | 7 +- .../logging/logger/android/FileLogStore.kt | 75 +++++++------------ .../internal/LoggerLifecycleManager.kt | 12 ++- .../internal/ObservabilityConfigEvaluator.kt | 2 - .../internal/crash/AnrCheckEvaluatorTest.kt | 15 ++-- .../OneSignalCrashUploaderWrapperTest.kt | 12 +-- .../LoggerLifecycleManagerFaultTest.kt | 30 +++----- .../internal/LoggerLifecycleManagerTest.kt | 12 +-- 10 files changed, 79 insertions(+), 159 deletions(-) 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 1aa66f12af..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 @@ -4,13 +4,10 @@ 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 ([com.onesignal.debug.internal.logging.logger.android.AndroidLogAnrDetector]) 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). @@ -83,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) } @@ -127,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, } @@ -174,8 +160,8 @@ 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" @@ -191,13 +177,10 @@ internal const val BACKGROUND_BLOCK_EXCEPTION_TYPE = "BackgroundMainThreadBlockE /** * 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, not from a thrown exception, so there is no - * throwable to serialize. Formatting by hand rather than synthesizing one keeps the watchdog cheap - * and non-throwing while it reports a possibly-wedged app, and lets the header carry the same bare - * exception type the record itself reports. `AnrCheckEvaluatorTest` pins this against a real - * [Throwable.stackTraceToString] so ANR and crash records cannot drift into two formats again. + * 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, @@ -234,9 +217,8 @@ internal fun buildAnrCrashData( } /** - * Builds the non-fatal record for a backgrounded main-thread block. The message carries a compact - * stack fingerprint (top frame + first OneSignal frame) so these can be triaged without parsing the - * full stack. + * Builds the non-fatal record for a backgrounded main-thread block, with a + * [buildBlockFingerprint] summary embedded in the message. */ internal fun buildBackgroundBlockCrashData( threadName: String, 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 ff547b2edf..07ed37c6e0 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 @@ -17,17 +17,8 @@ import java.io.File import kotlin.coroutines.cancellation.CancellationException /** - * Android-specific wrapper for the shared crash uploader that implements IStartableService. - * - * This is a thin adapter layer that: - * 1. Takes Android-specific services as dependencies - * 2. Creates platform-agnostic implementations (ILoggerPlatformProvider, ILogger) - * 3. Wraps the platform-agnostic LogCrashUploader for Android service architecture - * - * The uploader itself is fully platform-agnostic and can be used directly in KMP projects - * by providing platform-specific implementations of: - * - ILoggerPlatformProvider (inject all platform values) - * - ILogger (platform logging interface) + * Adapts the shared, platform-agnostic `LogCrashUploader` to [IStartableService], supplying it + * with Android implementations of `ILoggerPlatformProvider` and `ILogger`. */ internal class OneSignalCrashUploaderWrapper( private val applicationService: IApplicationService, @@ -48,9 +39,8 @@ internal class OneSignalCrashUploaderWrapper( OneSignalDispatchers.launchOnIO { try { logCrashDirInventory("before-upload") - // 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. + // 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) { @@ -65,10 +55,9 @@ internal class OneSignalCrashUploaderWrapper( } /** - * Resolves the crash directory the logger module reads and writes. Uses the pure path - * helper rather than a provider: building one costs a `PackageManager` round-trip and an - * ID resolver, and re-emits the provider's "Crash logs stored at" line, all to read a - * value derived from the context alone. + * 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) 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 ee080d81b5..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 @@ -37,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()) @@ -171,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) { 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 b6a942c5dd..f215cf82a0 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 @@ -13,30 +13,21 @@ 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 directory is inherited from the removed otel module, so ownership is distinguished - * purely by the policy's owned suffix: everything the logger writes ends in `.otlp`; anything - * else (bare-millis files left by an otel session before upgrade, stray `.tmp`s) is - * foreign and reclaimable via [deleteUnrecognizedEntries]. - * - * All retention decisions come from the shared [CrashRetention], so Android and iOS bound the + * 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 - * results with `File` I/O. Owned records are bounded on both axes, replacing the caps - * disk-buffering used to apply: `maxReadAgeMillis` ages records out, and `maxRecordCount` / - * `maxTotalBytes` cap accumulation — the latter by budget claim rather than raw disk - * bytes, which differ only for oversized records inherited from a build that predates the - * write-time limit in [save]. Both bounds are enforced on every path that - * touches the directory — [save], [listReadable] and [deleteUnrecognizedEntries] — so a - * backlog inherited from a build without caps is reclaimed on the next uploader pass rather - * than waiting for a crash. Over-limit records are deleted, not merely hidden from - * [listReadable], so a record that never uploads cannot grow the cache forever. + * 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, @@ -57,9 +48,8 @@ internal class FileLogStore( 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. - // Losing it loudly here beats losing it silently on a later launch. + // 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, " + @@ -94,18 +84,14 @@ 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. In the steady state this is one directory listing and - * nothing else, because writes are size-capped and the previous launch left the directory - * within bounds. An inherited over-cap backlog does get fully sorted and trimmed here — that - * is a one-time cost on the first crash after upgrade, and [reclaimOverLimitRecords] on the - * uploader's IO paths usually gets there first. Uses raw Logcat for the same reason [save] does. + * Runs inline on the crashing thread, and uses raw Logcat for the same reason [save] does. */ @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun enforceAccumulationCaps(dir: File, keepName: String) { try { val entries = listEntries(dir) - // Cheap exit for the common case, using the selector's own capped accounting so - // the two cannot disagree about whether a trim is needed. + // 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( @@ -132,9 +118,7 @@ internal class FileLogStore( /** * 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 — an - * install upgrading from a build without caps, or one whose crash-path trim failed, is - * reclaimed here rather than waiting for the next crash. + * 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 */ @@ -170,10 +154,8 @@ internal class FileLogStore( * 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, and it cannot distort the accumulation caps either: - * expired records are by definition the oldest, so the selector always picks them for - * eviction rather than retention, and only retained records claim budget. + * @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) @@ -249,17 +231,14 @@ 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]. Owned `*.otlp` records are left untouched so failed - * / too-young uploads can still retry on the next launch, except for ones past the - * policy's read-age ceiling or beyond the accumulation caps, which are no longer - * uploadable or no longer affordable to keep. + * 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, and — unlike [listReadable] — - * also when remote logging is disabled. That makes it the only chance to bound records - * written by a session that never uploads. 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, excluding reclaimed owned records */ 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 738b39f0b8..924dbd6bf7 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 @@ -43,9 +43,8 @@ private fun createReporter( * crash capture, ANR detection) and reacts to remote config changes via the shared * [ObservabilityConfig]/[ObservabilityConfigEvaluator]. * - * Production callers supply only [context] and [featureManagerProvider]; every other - * parameter defaults to the real implementation, so runtime wiring is unchanged. Tests - * override them to inject mocks or throwing stubs. + * Production callers supply only [context] and [featureManagerProvider]; the remaining + * parameters default to the real implementations and exist for test injection. */ @Suppress("TooManyFunctions", "LongParameterList") internal class LoggerLifecycleManager( @@ -261,10 +260,9 @@ internal class LoggerLifecycleManager( @Suppress("TooGenericExceptionCaught") private fun startLogging(logLevel: LogLevel) { // Same invariant as disableFeatures: detach both the field and Logging's global before - // tearing the old sink down. Shutting down first would leave every log emitted until - // the replacement is installed — including the warn below — going to a telemetry whose - // consumer is already cancelled, where it is queued and never drained. If the factory - // then throws, the global would keep pointing at that dead instance for the session. + // 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 Logging.setLoggerTelemetry(null) { false } 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 index ac1a44e6ff..85779c65fe 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/ObservabilityConfigEvaluator.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/ObservabilityConfigEvaluator.kt @@ -35,8 +35,6 @@ internal sealed class ObservabilityConfigAction { /** * Pure, side-effect-free evaluator that compares old and new [ObservabilityConfig] * and returns the [ObservabilityConfigAction] the lifecycle manager should execute. - * - * Designed to be fully unit-testable without mocks. */ internal object ObservabilityConfigEvaluator { /** 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 00a7bbf145..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 [AndroidLogAnrDetector]. + * 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({ @@ -204,9 +203,9 @@ class AnrCheckEvaluatorTest : FunSpec({ // ===== record formatting ===== // - // ANR records must be serialized in the same canonical JVM layout ordinary crashes get from - // Throwable.stackTraceToString(), or consumers that parse `exception.stacktrace` as a Java - // stacktrace (frame extraction, grouping, symbolication) silently stop matching ANRs only. + // 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), @@ -215,8 +214,6 @@ class AnrCheckEvaluatorTest : FunSpec({ val nl = System.lineSeparator() test("formatJvmStacktrace matches what the JVM itself produces for a real throwable") { - // Pins the hand-formatting against the crash path's stackTraceToString(), so the two record - // types cannot drift into different formats again. val throwable = IllegalStateException("boom") formatJvmStacktrace( 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 f63d5e9dc4..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 @@ -60,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() @@ -72,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() @@ -89,7 +86,6 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager()) - // Should complete without error even when no crash reports exist runBlocking { wrapper.start() } } @@ -99,7 +95,6 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ val wrapper = OneSignalCrashUploaderWrapper(mockApplicationService, mockFeatureManager()) - // Multiple calls should not throw runBlocking { wrapper.start() wrapper.start() @@ -115,12 +110,11 @@ class OneSignalCrashUploaderWrapperTest : FunSpec({ wrapper shouldNotBe null } - // Upgrading installs inherit a crash dir holding OTel-format records that nothing can - // read anymore. They must not linger. This covers the first launch after upgrade, where - // there is no cached config yet, so the uploader reclaims them without an upload pass. + // 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() } - // OTel's disk-buffering wrote bare-millis filenames; the logger owns `.otlp` only. + // 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) 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 index 2555431765..e44cd9b9e9 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerFaultTest.kt @@ -24,13 +24,9 @@ import io.mockk.verify import org.robolectric.annotation.Config /** - * Fault-isolation coverage for the SDK's only observability pipeline, ported from the - * deleted otel equivalent. - * - * Every collaborator is constructed behind an injectable factory, so these drive the - * `try/catch` isolation in [LoggerLifecycleManager] directly: 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. + * 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]) @@ -65,10 +61,7 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ fun disabledConfig(): ConfigModel = ConfigModel().apply { remoteLoggingParams.isEnabled = false } - /** - * Builds a manager whose collaborators are all mocks unless a factory is overridden to - * throw. The platform provider is relaxed so property reads during startup are inert. - */ + /** Collaborators are all relaxed mocks unless a factory is overridden to throw. */ fun managerWith( crashHandler: () -> ILogCrashHandler = { mockk(relaxed = true) }, anrDetector: () -> ILogAnrDetector = { mockk(relaxed = true) }, @@ -213,9 +206,8 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ } test("a throwing shutdown() during a level change still installs the replacement sink") { - // startLogging drops the reference before tearing the old sink down. If it cleared - // after, a throwing shutdown() would strand the dead instance in the field and every - // later identical config would evaluate to NoChange, leaving remote logging dead. + // 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) @@ -261,9 +253,8 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ detectorCount shouldBe 1 } - // A component that failed to start must be retried on the next config refresh. Committing - // currentConfig after a partial failure collapsed the next identical HYDRATE to NoChange, - // which left the dead component down for the rest of the process. + // 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) @@ -338,9 +329,8 @@ class LoggerLifecycleManagerFaultTest : FunSpec({ detectorCount shouldBe 2 } - // Teardown clears each reference before calling the collaborator. If it cleared after, - // a throwing stop()/unregister() would leave the field set and the start guards would - // treat the dead component as running, disabling it for the rest of the process. + // 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) 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 index 6bad6d8277..dcd952b724 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/LoggerLifecycleManagerTest.kt @@ -29,10 +29,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.robolectric.annotation.Config -/** - * The logger pipeline is the SDK's only observability path, so these cover the config - * state machine that brings it up and tears it down. - */ +/** 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({ @@ -171,10 +168,9 @@ class LoggerLifecycleManagerTest : FunSpec({ } test("enabling wires the remote sink and disabling shuts it down and clears it") { - // Emission hops to a background scope, so the positive case waits on a signal from the - // sink rather than a fixed sleep. The negative case still needs a bounded wait — there - // is no event for "nothing happened" — but only after a real emit has been observed, - // which establishes the pipeline is warm. + // 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 } From bb6227614b659ef12d76720d6e4b29b067f0ac58 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Aug 2026 14:19:15 -0500 Subject: [PATCH 12/12] test: [SDK-5065] pin the keepName wiring on the crash write path `save never evicts the record it just wrote` passed with `keepName` removed from `enforceAccumulationCaps` entirely, so the wiring was unverified. Both sort keys clamp to `nowMs`, so the record `save` just wrote can never sort strictly oldest; it lands in a tie group with any backlog dated at or ahead of the clock, and its position inside that group is whatever the filesystem happens to list. Only the explicit reservation keeps it. Measured on the old fixture, eviction without `keepName` was a coin flip that landed the safe way 7 times in 25, which is why a single attempt looked green. The fixture now dates the backlog ahead of the clock and repeats, so a false pass is vanishingly unlikely; it fails without the reservation and passes with it. Also restores the note explaining why the `isWithinCaps` short-circuit is what makes a full sort acceptable on the crashing thread, and passes the policy to `formatInventory` explicitly so every shared-selector call site reads alike. Co-authored-by: Cursor --- .../crash/OneSignalCrashUploaderWrapper.kt | 1 + .../logging/logger/android/FileLogStore.kt | 2 ++ .../logger/android/FileLogStoreTest.kt | 34 ++++++++++++++----- 3 files changed, 29 insertions(+), 8 deletions(-) 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 07ed37c6e0..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 @@ -86,6 +86,7 @@ internal class OneSignalCrashUploaderWrapper( 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/logging/logger/android/FileLogStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt index f215cf82a0..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 @@ -85,6 +85,8 @@ internal class FileLogStore( * 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) { 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 159f8b30d0..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 @@ -172,14 +172,32 @@ class FileLogStoreTest : FunSpec({ File(dir, "inherited.otlp").exists() shouldBe true } - test("save never evicts the record it just wrote") { - repeat(policy.maxRecordCount + 5) { i -> write("seed-$i.otlp", ageMsAgo = 1_000L * (i + 1)) } - - FileLogStore(dir.path).save("new".toByteArray()) shouldBe true - - val remaining = dir.listFiles()!!.filter { it.name.endsWith(policy.ownedSuffix) } - remaining.none { it.name.startsWith("seed-") && it.readText() == "new" } shouldBe true - remaining.count { it.readText() == "new" } shouldBe 1 + // 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 —